@geekity/cms 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +96 -160
  2. package/admin/pages/documents/editor.njk +48 -0
  3. package/admin/pages/federation/settings.njk +1 -59
  4. package/admin/pages/plugins/installed.njk +113 -0
  5. package/admin/pages/plugins/screen.njk +129 -0
  6. package/admin/static/admin.css +144 -4
  7. package/admin/static/editor-actions.js +202 -0
  8. package/dist/admin/accounts.d.ts +18 -39
  9. package/dist/admin/accounts.d.ts.map +1 -1
  10. package/dist/admin/accounts.js +19 -48
  11. package/dist/admin/accounts.js.map +1 -1
  12. package/dist/admin/documents.d.ts +5 -0
  13. package/dist/admin/documents.d.ts.map +1 -1
  14. package/dist/admin/documents.js +17 -8
  15. package/dist/admin/documents.js.map +1 -1
  16. package/dist/admin/editor-actions.d.ts +35 -0
  17. package/dist/admin/editor-actions.d.ts.map +1 -0
  18. package/dist/admin/editor-actions.js +187 -0
  19. package/dist/admin/editor-actions.js.map +1 -0
  20. package/dist/admin/federation.d.ts +4 -5
  21. package/dist/admin/federation.d.ts.map +1 -1
  22. package/dist/admin/federation.js +4 -5
  23. package/dist/admin/federation.js.map +1 -1
  24. package/dist/admin/index.d.ts +1 -1
  25. package/dist/admin/index.d.ts.map +1 -1
  26. package/dist/admin/index.js +1 -1
  27. package/dist/admin/index.js.map +1 -1
  28. package/dist/admin/menu.d.ts +1 -1
  29. package/dist/admin/menu.d.ts.map +1 -1
  30. package/dist/admin/menu.js +10 -3
  31. package/dist/admin/menu.js.map +1 -1
  32. package/dist/admin/plugins.d.ts +24 -0
  33. package/dist/admin/plugins.d.ts.map +1 -0
  34. package/dist/admin/plugins.js +312 -0
  35. package/dist/admin/plugins.js.map +1 -0
  36. package/dist/admin/routes.d.ts.map +1 -1
  37. package/dist/admin/routes.js +5 -1
  38. package/dist/admin/routes.js.map +1 -1
  39. package/dist/admin/settings-federation.d.ts +0 -25
  40. package/dist/admin/settings-federation.d.ts.map +1 -1
  41. package/dist/admin/settings-federation.js +1 -54
  42. package/dist/admin/settings-federation.js.map +1 -1
  43. package/dist/admin/settings.d.ts +2 -14
  44. package/dist/admin/settings.d.ts.map +1 -1
  45. package/dist/admin/settings.js +1 -21
  46. package/dist/admin/settings.js.map +1 -1
  47. package/dist/admin/store.d.ts +8 -1
  48. package/dist/admin/store.d.ts.map +1 -1
  49. package/dist/admin/store.js +7 -3
  50. package/dist/admin/store.js.map +1 -1
  51. package/dist/admin/templates.d.ts +2 -0
  52. package/dist/admin/templates.d.ts.map +1 -1
  53. package/dist/admin/templates.js +2 -0
  54. package/dist/admin/templates.js.map +1 -1
  55. package/dist/cli.d.ts +2 -3
  56. package/dist/cli.d.ts.map +1 -1
  57. package/dist/cli.js +278 -212
  58. package/dist/cli.js.map +1 -1
  59. package/dist/config.d.ts +20 -0
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/config.js +6 -0
  62. package/dist/config.js.map +1 -1
  63. package/dist/content/store.d.ts +6 -0
  64. package/dist/content/store.d.ts.map +1 -1
  65. package/dist/content/store.js +3 -0
  66. package/dist/content/store.js.map +1 -1
  67. package/dist/drain.d.ts +28 -0
  68. package/dist/drain.d.ts.map +1 -0
  69. package/dist/drain.js +106 -0
  70. package/dist/drain.js.map +1 -0
  71. package/dist/env.d.ts +11 -0
  72. package/dist/env.d.ts.map +1 -1
  73. package/dist/federation/federation.d.ts +4 -2
  74. package/dist/federation/federation.d.ts.map +1 -1
  75. package/dist/federation/federation.js +16 -21
  76. package/dist/federation/federation.js.map +1 -1
  77. package/dist/federation/inbox.d.ts +23 -3
  78. package/dist/federation/inbox.d.ts.map +1 -1
  79. package/dist/federation/inbox.js +63 -1
  80. package/dist/federation/inbox.js.map +1 -1
  81. package/dist/federation/index.d.ts +0 -4
  82. package/dist/federation/index.d.ts.map +1 -1
  83. package/dist/federation/index.js +0 -2
  84. package/dist/federation/index.js.map +1 -1
  85. package/dist/federation/mount.d.ts +6 -12
  86. package/dist/federation/mount.d.ts.map +1 -1
  87. package/dist/federation/mount.js +3 -50
  88. package/dist/federation/mount.js.map +1 -1
  89. package/dist/federation/queue.d.ts +22 -0
  90. package/dist/federation/queue.d.ts.map +1 -0
  91. package/dist/federation/queue.js +58 -0
  92. package/dist/federation/queue.js.map +1 -0
  93. package/dist/files/atomic.d.ts +13 -0
  94. package/dist/files/atomic.d.ts.map +1 -1
  95. package/dist/files/atomic.js +45 -0
  96. package/dist/files/atomic.js.map +1 -1
  97. package/dist/index.d.ts +30 -3
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +156 -79
  100. package/dist/index.js.map +1 -1
  101. package/dist/plugin.d.ts +524 -0
  102. package/dist/plugin.d.ts.map +1 -0
  103. package/dist/plugin.js +23 -0
  104. package/dist/plugin.js.map +1 -0
  105. package/dist/plugins/enabled.d.ts +23 -0
  106. package/dist/plugins/enabled.d.ts.map +1 -0
  107. package/dist/plugins/enabled.js +49 -0
  108. package/dist/plugins/enabled.js.map +1 -0
  109. package/dist/plugins/federation.d.ts +26 -0
  110. package/dist/plugins/federation.d.ts.map +1 -0
  111. package/dist/plugins/federation.js +114 -0
  112. package/dist/plugins/federation.js.map +1 -0
  113. package/dist/plugins/fetch.d.ts +15 -0
  114. package/dist/plugins/fetch.d.ts.map +1 -0
  115. package/dist/plugins/fetch.js +33 -0
  116. package/dist/plugins/fetch.js.map +1 -0
  117. package/dist/plugins/folder.d.ts +50 -0
  118. package/dist/plugins/folder.d.ts.map +1 -0
  119. package/dist/plugins/folder.js +171 -0
  120. package/dist/plugins/folder.js.map +1 -0
  121. package/dist/plugins/install.d.ts +45 -0
  122. package/dist/plugins/install.d.ts.map +1 -0
  123. package/dist/plugins/install.js +275 -0
  124. package/dist/plugins/install.js.map +1 -0
  125. package/dist/plugins/mount.d.ts +17 -0
  126. package/dist/plugins/mount.d.ts.map +1 -0
  127. package/dist/plugins/mount.js +44 -0
  128. package/dist/plugins/mount.js.map +1 -0
  129. package/dist/plugins/registry.d.ts +110 -0
  130. package/dist/plugins/registry.d.ts.map +1 -0
  131. package/dist/plugins/registry.js +447 -0
  132. package/dist/plugins/registry.js.map +1 -0
  133. package/dist/plugins/settings.d.ts +64 -0
  134. package/dist/plugins/settings.d.ts.map +1 -0
  135. package/dist/plugins/settings.js +219 -0
  136. package/dist/plugins/settings.js.map +1 -0
  137. package/dist/plugins/site.d.ts +17 -0
  138. package/dist/plugins/site.d.ts.map +1 -0
  139. package/dist/plugins/site.js +67 -0
  140. package/dist/plugins/site.js.map +1 -0
  141. package/dist/supervisor/primary.d.ts +47 -0
  142. package/dist/supervisor/primary.d.ts.map +1 -0
  143. package/dist/supervisor/primary.js +208 -0
  144. package/dist/supervisor/primary.js.map +1 -0
  145. package/dist/supervisor/protocol.d.ts +37 -0
  146. package/dist/supervisor/protocol.d.ts.map +1 -0
  147. package/dist/supervisor/protocol.js +29 -0
  148. package/dist/supervisor/protocol.js.map +1 -0
  149. package/dist/supervisor/supervision.d.ts +25 -0
  150. package/dist/supervisor/supervision.d.ts.map +1 -0
  151. package/dist/supervisor/supervision.js +7 -0
  152. package/dist/supervisor/supervision.js.map +1 -0
  153. package/dist/supervisor/worker.d.ts +29 -0
  154. package/dist/supervisor/worker.d.ts.map +1 -0
  155. package/dist/supervisor/worker.js +97 -0
  156. package/dist/supervisor/worker.js.map +1 -0
  157. package/dist/web/maintenance.d.ts +2 -2
  158. package/dist/web/maintenance.js +2 -2
  159. package/dist/web/negotiate.d.ts +4 -3
  160. package/dist/web/negotiate.d.ts.map +1 -1
  161. package/dist/web/negotiate.js +4 -3
  162. package/dist/web/negotiate.js.map +1 -1
  163. package/dist/web/routes.d.ts.map +1 -1
  164. package/dist/web/routes.js +33 -14
  165. package/dist/web/routes.js.map +1 -1
  166. package/package.json +10 -1
  167. package/dist/comments/conversation.d.ts +0 -29
  168. package/dist/comments/conversation.d.ts.map +0 -1
  169. package/dist/comments/conversation.js +0 -60
  170. package/dist/comments/conversation.js.map +0 -1
  171. package/dist/federation/import-wordpress.d.ts +0 -128
  172. package/dist/federation/import-wordpress.d.ts.map +0 -1
  173. package/dist/federation/import-wordpress.js +0 -426
  174. package/dist/federation/import-wordpress.js.map +0 -1
  175. package/dist/federation/wordpress.d.ts +0 -131
  176. package/dist/federation/wordpress.d.ts.map +0 -1
  177. package/dist/federation/wordpress.js +0 -342
  178. package/dist/federation/wordpress.js.map +0 -1
  179. package/dist/web/comments.d.ts +0 -60
  180. package/dist/web/comments.d.ts.map +0 -1
  181. package/dist/web/comments.js +0 -207
  182. package/dist/web/comments.js.map +0 -1
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import type { GeekityConfig } from './config.ts';
3
- export type Command = 'serve' | 'init' | 'sync' | 'rebuild' | 'resend' | 'user' | 'import' | 'maintenance' | 'strip-metadata' | 'help' | 'version';
3
+ export type Command = 'serve' | 'init' | 'sync' | 'rebuild' | 'resend' | 'user' | 'maintenance' | 'strip-metadata' | 'plugin' | 'help' | 'version';
4
4
  export interface ParsedArgs {
5
5
  command: Command;
6
6
  /** Path to the config file, relative to the working directory, if the user named one. */
@@ -24,8 +24,7 @@ export interface ParsedArgs {
24
24
  flags: Readonly<Record<string, string | true>>;
25
25
  /**
26
26
  * Positional arguments after the command: the directory for `init`, the
27
- * subcommand and its arguments for `user` and `import`. Flags are never in
28
- * here.
27
+ * subcommand and its arguments for `user`. Flags are never in here.
29
28
  */
30
29
  args: readonly string[];
31
30
  }
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAqBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AASjD,MAAM,MAAM,OAAO,GACf,OAAO,GACP,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,MAAM,GACN,QAAQ,GACR,aAAa,GACb,gBAAgB,GAChB,MAAM,GACN,SAAS,CAAC;AAoCd,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,yFAAyF;IACzF,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;;OAGG;IACH,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC;IAC/C;;;;OAIG;IACH,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACzB;AA2FD,kFAAkF;AAClF,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,CAsH7D;AASD;;;;;;;;;;GAUG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,OAAO,CAAC,aAAa,CAAC,CAyBxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAW3D;AAED;;;;;;;GAOG;AACH,wBAAsB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe7F;AAyqBD,gEAAgE;AAChE,KAAK,aAAa,GAAG,MAAM,CAAC,cAAc,GAAG;IAC3C,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CACzC,CAAC;AAEF,0EAA0E;AAC1E,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC/B,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4CrF"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAoBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAsBjD,MAAM,MAAM,OAAO,GACf,OAAO,GACP,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,MAAM,GACN,aAAa,GACb,gBAAgB,GAChB,QAAQ,GACR,MAAM,GACN,SAAS,CAAC;AAwBd,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,yFAAyF;IACzF,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;;OAGG;IACH,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC;IAC/C;;;OAGG;IACH,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACzB;AAwED,kFAAkF;AAClF,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,CAsH7D;AASD;;;;;;;;;;GAUG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,OAAO,CAAC,aAAa,CAAC,CAyBxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAW3D;AAED;;;;;;;GAOG;AACH,wBAAsB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe7F;AAwwBD,gEAAgE;AAChE,KAAK,aAAa,GAAG,MAAM,CAAC,cAAc,GAAG;IAC3C,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CACzC,CAAC;AAEF,0EAA0E;AAC1E,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC/B,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4CrF"}
package/dist/cli.js CHANGED
@@ -7,8 +7,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  }
8
8
  return path;
9
9
  };
10
+ import cluster from 'node:cluster';
10
11
  import { existsSync, realpathSync } from 'node:fs';
11
- import { readFile } from 'node:fs/promises';
12
12
  import { createRequire } from 'node:module';
13
13
  import path from 'node:path';
14
14
  import { createInterface } from 'node:readline';
@@ -22,9 +22,13 @@ import { databaseFile, discardDatabase } from './cache.js';
22
22
  import { resolveConfig } from './config.js';
23
23
  import { stripStoredUploads } from './content/metadata/sweep.js';
24
24
  import { UPLOAD_DIRECTORY } from './web/assets.js';
25
- import { importWordPressActor } from './federation/import-wordpress.js';
26
25
  import { createCms } from './index.js';
27
26
  import { initSite, ownManifest, seedStarterContent } from './init.js';
27
+ import { importPluginFolders, scanPluginFolders } from './plugins/folder.js';
28
+ import { addPlugin, DEFAULT_REGISTRY, installedFolders, parsePackageSpec, removePlugin, requirementNotes, } from './plugins/install.js';
29
+ import { pluginSite, sitePluginRegistry } from './plugins/site.js';
30
+ import { superviseCluster } from './supervisor/primary.js';
31
+ import { processChannel, superviseWorker } from './supervisor/worker.js';
28
32
  import { enterMaintenance, leaveMaintenance, maintenanceFile, readMaintenance, } from './maintenance.js';
29
33
  /** The commands a site can name on the command line, as opposed to the flags. */
30
34
  const COMMANDS = [
@@ -34,29 +38,17 @@ const COMMANDS = [
34
38
  'rebuild',
35
39
  'resend',
36
40
  'user',
37
- 'import',
38
41
  'maintenance',
39
42
  'strip-metadata',
43
+ 'plugin',
40
44
  ];
41
45
  /**
42
46
  * The options one command reads out of {@link ParsedArgs.flags}, each of which
43
47
  * carries a value.
44
- *
45
- * A table rather than a branch per flag, because `import wordpress-actor`
46
- * takes six of them and a parser with six more near-identical cases in it
47
- * would be six more places to get the same thing subtly wrong.
48
48
  */
49
- const VALUE_FLAGS = [
50
- 'actor-id',
51
- 'wordpress-id',
52
- 'private-key',
53
- 'public-key',
54
- 'keypair',
55
- 'followers',
56
- 'until',
57
- ];
49
+ const VALUE_FLAGS = ['until'];
58
50
  /** The options that are simply on or off. */
59
- const SWITCH_FLAGS = ['force', 'all'];
51
+ const SWITCH_FLAGS = ['all'];
60
52
  const CONFIG_FILENAMES = ['geekity.config.ts', 'geekity.config.js', 'geekity.config.mjs'];
61
53
  const USAGE = `geekity — the Geekity CMS command line
62
54
 
@@ -69,9 +61,7 @@ Usage:
69
61
  geekity maintenance (on [--until <time>] | off | status) [--config <file>]
70
62
  geekity strip-metadata [--config <file>]
71
63
  geekity user add <username> [--password <pw>] [--email <address>] [--config <file>]
72
- geekity import wordpress-actor <username> --actor-id <url> --wordpress-id <n>
73
- (--keypair <file> | --private-key <file> [--public-key <file>])
74
- [--followers <url|file|none>] [--force] [--config <file>]
64
+ geekity plugin (add <package>[@version] | remove <package>) [--config <file>]
75
65
 
76
66
  Commands:
77
67
  serve Start the CMS (the default when no command is given).
@@ -97,11 +87,13 @@ Commands:
97
87
  as it is.
98
88
  user add Create an admin user, so a site can get its first login
99
89
  without the setup screen.
100
- import
101
- wordpress-actor
102
- Bring one person across from the WordPress ActivityPub
103
- plugin: their key pair, the actor id their followers hold,
104
- the plugin's numeric actor id, and their followers.
90
+ plugin add Install a plugin package's bundle from the npm registry
91
+ (npm_config_registry, or registry.npmjs.org) into the
92
+ plugins folder, GEEKITY_PLUGINS_DIR. It installs only the
93
+ package named, and names any plugin it requires that is
94
+ missing. Reload on the Plugins screen loads it.
95
+ plugin remove Delete a plugin's folder from the plugins folder. Reload on
96
+ the Plugins screen unloads it.
105
97
 
106
98
  Options:
107
99
  --config <file> Config file to load. Defaults to the first of
@@ -112,36 +104,17 @@ Options:
112
104
  list and in shell history, so prefer being asked.
113
105
  --email <addr> The new user's email address. Optional; it is what a
114
106
  forgotten password is recovered through.
115
- --actor-id <url> The actor id WordPress published, query string and all,
116
- for example https://example.com/?author=2
117
- --wordpress-id <n>
118
- The WordPress user id, which is the number in the plugin's
119
- paths (/wp-json/activitypub/1.0/actors/<n>/inbox).
120
- --keypair <file> The JSON \`wp option get activitypub_keypair_for_<login>
121
- --format=json\` prints: {"private_key": …, "public_key": …}.
122
- --private-key <file>
123
- The private key as PEM, instead of --keypair.
124
- --public-key <file>
125
- The public key as PEM. Optional, and only checked against
126
- the private half — it is derived, never stored.
127
- --followers <url|file|none>
128
- Where the followers come from. Left off, the plugin's own
129
- public followers collection on the actor id's origin.
130
107
  --until <time> With maintenance on: when the site should be back, as an
131
108
  ISO 8601 time such as 2026-09-28T14:00:00Z. It is the
132
109
  Retry-After a client is sent, and the maintenance page
133
110
  says it.
134
111
  --all With resend: every announced post rather than named ones.
135
- --force Import over a key pair the user already has. Do this only
136
- when you are certain the pair being imported is the one the
137
- followers hold: a new key means none of them can verify
138
- this person again.
139
112
  -h, --help Show this help.
140
113
  -v, --version Show the installed version.
141
114
 
142
115
  Environment overrides:
143
116
  GEEKITY_PORT (or PORT), GEEKITY_CONTENT_DIR, GEEKITY_DATA_DIR,
144
- GEEKITY_THEMES_DIR, GEEKITY_BASE_URL, GEEKITY_WATCH,
117
+ GEEKITY_THEMES_DIR, GEEKITY_PLUGINS_DIR, GEEKITY_BASE_URL, GEEKITY_WATCH,
145
118
  GEEKITY_MAINTENANCE (on keeps the site in maintenance until a restart)
146
119
  `;
147
120
  /** Turn `process.argv.slice(2)` into a command, its options and its arguments. */
@@ -354,9 +327,13 @@ function isModuleNotFound(error, candidate) {
354
327
  }
355
328
  /** Run one command. Resolves with the exit code the process should use. */
356
329
  async function main(argv) {
330
+ const first = leadingWords(argv)[0];
331
+ if (first !== undefined && !isCommand(first) && !argv.some(isHelpOrVersion)) {
332
+ return await pluginCommand(argv);
333
+ }
357
334
  const { command, configPath, password, email, flags, args } = parseArgs(argv);
358
335
  if (command === 'help') {
359
- process.stdout.write(USAGE);
336
+ process.stdout.write(await usage(configPath));
360
337
  return 0;
361
338
  }
362
339
  if (command === 'version') {
@@ -367,8 +344,6 @@ async function main(argv) {
367
344
  return init(args);
368
345
  if (command === 'user')
369
346
  return userCommand(args, configPath, password, email);
370
- if (command === 'import')
371
- return importCommand(args, configPath, flags);
372
347
  if (command === 'sync')
373
348
  return syncCommand(configPath);
374
349
  if (command === 'rebuild')
@@ -379,6 +354,8 @@ async function main(argv) {
379
354
  return maintenanceCommand(args, configPath, flags);
380
355
  if (command === 'strip-metadata')
381
356
  return stripMetadataCommand(configPath);
357
+ if (command === 'plugin')
358
+ return pluginFolderCommand(args, configPath);
382
359
  return serveCommand(configPath);
383
360
  }
384
361
  /** `geekity init <directory>`: scaffold a new site. */
@@ -649,33 +626,105 @@ const SQLITE_LOCKED = 6;
649
626
  /**
650
627
  * `geekity serve`: the default. Runs until it is signalled.
651
628
  *
629
+ * This process is a supervisor (TASK-288): it owns the port and runs the CMS
630
+ * in a `node:cluster` worker that runs this same command, so Reload on the
631
+ * Plugins screen can boot a new worker beside the old one and a worker that
632
+ * crashes is respawned, both without restarting the container.
633
+ *
652
634
  * With `seedContent` on, a missing or empty content directory is given the
653
- * starter site before the CMS opens it, so a new box boots into something to
654
- * show and the setup screen. It happens here rather than in `createCms` so a
655
- * site that builds its own server never has content written for it.
635
+ * starter site before any worker opens it, so a new box boots into something
636
+ * to show and the setup screen. It happens here rather than in `createCms` so
637
+ * a site that builds its own server never has content written for it.
656
638
  */
657
639
  async function serveCommand(configPath) {
658
- const config = await loadConfig(process.cwd(), configPath);
659
- const resolved = resolveConfig(config);
640
+ if (cluster.isWorker)
641
+ return serveWorker(configPath);
642
+ const resolved = resolveConfig(await loadConfig(process.cwd(), configPath));
660
643
  if (resolved.seedContent &&
661
644
  (await seedStarterContent({ contentDir: resolved.contentDir, baseUrl: resolved.baseUrl }))) {
662
645
  process.stdout.write(`Seeded ${resolved.contentDir} with the starter site\n`);
663
646
  }
664
- // The access log is on here and off in `createCms`: a server that answers
665
- // the internet should be able to say what it answered, while a CMS embedded
666
- // in somebody else's app has no business writing to their stdout uninvited.
667
- // Named as a config value rather than forced, so `GEEKITY_ACCESS_LOG` still
668
- // wins and a site that wrote `accessLog: false` still gets silence.
669
- const cms = createCms({ ...config, accessLog: config.accessLog ?? true });
670
- const { port } = await cms.serve();
671
- process.stdout.write(`Geekity is serving ${cms.config.baseUrl} on port ${String(port)}\n`);
672
- for (const signal of ['SIGINT', 'SIGTERM']) {
673
- process.once(signal, () => {
674
- void cms.close().then(() => process.exit(0));
647
+ superviseCluster();
648
+ return 0;
649
+ }
650
+ /**
651
+ * One worker under the supervisor: the site's config and its plugins folder,
652
+ * read afresh, so a reload sees what changed. Signals are the supervisor's to
653
+ * act on; it tells this worker when to close.
654
+ */
655
+ async function serveWorker(configPath) {
656
+ const channel = processChannel();
657
+ for (const signal of ['SIGINT', 'SIGTERM'])
658
+ process.on(signal, () => undefined);
659
+ try {
660
+ const config = await loadConfig(process.cwd(), configPath);
661
+ const loaded = scanPluginFolders(resolveConfig(config).pluginsDir);
662
+ const worker = superviseWorker({
663
+ channel,
664
+ loaded,
665
+ exit: (code) => process.exit(code),
666
+ log: (line) => process.stderr.write(`${line}\n`),
675
667
  });
668
+ // The access log is on here and off in `createCms`: a server that answers
669
+ // the internet should be able to say what it answered, while a CMS embedded
670
+ // in somebody else's app has no business writing to their stdout uninvited.
671
+ // Named as a config value rather than forced, so `GEEKITY_ACCESS_LOG` still
672
+ // wins and a site that wrote `accessLog: false` still gets silence.
673
+ const cms = createCms({ ...config, accessLog: config.accessLog ?? true }, { folderPlugins: await importPluginFolders(loaded), supervision: worker.supervision });
674
+ const { port } = await cms.serve();
675
+ process.stdout.write(`Geekity is serving ${cms.config.baseUrl} on port ${String(port)}\n`);
676
+ worker.serving(cms);
677
+ }
678
+ catch (error) {
679
+ const message = error instanceof Error ? error.message : String(error);
680
+ channel.send({ type: 'boot-failed', error: message }, () => process.exit(1));
681
+ }
682
+ return 0;
683
+ }
684
+ /**
685
+ * `geekity plugin add <package>` and `geekity plugin remove <package>`
686
+ * (decision-33): change the plugins folder, which Reload on the Plugins
687
+ * screen then loads. Nothing a plugin requires is installed for it.
688
+ */
689
+ async function pluginFolderCommand(args, configPath) {
690
+ const [action, spec, ...rest] = args;
691
+ if ((action !== 'add' && action !== 'remove') || spec === undefined || rest.length > 0) {
692
+ throw new Error('geekity plugin needs add <package>[@version] or remove <package>.');
693
+ }
694
+ const config = resolveConfig(await loadConfig(process.cwd(), configPath));
695
+ const { pluginsDir } = config;
696
+ if (pluginsDir === undefined) {
697
+ throw new Error('geekity plugin needs a plugins folder: set GEEKITY_PLUGINS_DIR, or pluginsDir in the config.');
698
+ }
699
+ if (action === 'remove') {
700
+ const directory = await removePlugin(spec, pluginsDir);
701
+ process.stdout.write(`Removed ${spec} (${directory}). Reload on the Plugins screen to unload it.\n`);
702
+ return 0;
676
703
  }
704
+ const registry = firstSet(process.env['npm_config_registry'], process.env['NPM_CONFIG_REGISTRY']) ??
705
+ DEFAULT_REGISTRY;
706
+ const { manifest, directory, replaced } = await addPlugin(parsePackageSpec(spec), {
707
+ pluginsDir,
708
+ registry,
709
+ });
710
+ const installed = new Map([
711
+ ...config.plugins.map((plugin) => [plugin.name, plugin.version]),
712
+ ...installedFolders(pluginsDir),
713
+ ]);
714
+ const done = replaced === undefined || replaced === manifest.version
715
+ ? `Added ${manifest.name} ${manifest.version} to ${directory}.`
716
+ : `Replaced ${manifest.name} ${replaced} with ${manifest.version} in ${directory}.`;
717
+ process.stdout.write([
718
+ done,
719
+ ...requirementNotes(manifest, installed, ownManifest().version),
720
+ 'Reload on the Plugins screen to load it, then enable it there.',
721
+ '',
722
+ ].join('\n'));
677
723
  return 0;
678
724
  }
725
+ function firstSet(...values) {
726
+ return values.find((value) => value !== undefined && value !== '');
727
+ }
679
728
  /**
680
729
  * `geekity user add <username>`: create an admin without the setup screen.
681
730
  *
@@ -731,174 +780,191 @@ async function userCommand(args, configPath, flagPassword, flagEmail) {
731
780
  }
732
781
  /** Where the success line points a new admin. Relative, because the host is the site's. */
733
782
  const LOGIN_URL = '/admin/login';
783
+ /** One flag as the text it carries, or `undefined` when it was not given. */
784
+ function text(flags, name) {
785
+ const value = flags[name];
786
+ return typeof value === 'string' ? value : undefined;
787
+ }
788
+ function isHelpOrVersion(arg) {
789
+ return arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v';
790
+ }
791
+ /** The bare words before the first option other than `--config`. */
792
+ function leadingWords(argv) {
793
+ const words = [];
794
+ for (let i = 0; i < argv.length; i += 1) {
795
+ const arg = argv[i];
796
+ if (arg === '--config') {
797
+ i += 1;
798
+ continue;
799
+ }
800
+ if (arg.startsWith('--config='))
801
+ continue;
802
+ if (arg.startsWith('-'))
803
+ break;
804
+ words.push(arg);
805
+ }
806
+ return words;
807
+ }
808
+ /** `--config <file>` or `--config=<file>`, wherever it is. */
809
+ function configFlag(argv) {
810
+ for (let i = 0; i < argv.length; i += 1) {
811
+ const arg = argv[i];
812
+ if (arg === '--config')
813
+ return argv[i + 1];
814
+ if (arg.startsWith('--config='))
815
+ return arg.slice('--config='.length);
816
+ }
817
+ return undefined;
818
+ }
734
819
  /**
735
- * `geekity import wordpress-actor <username>`: the cutover, for one person.
736
- *
737
- * Everything the plugin holds that this CMS cannot mint for itself arrives
738
- * here (decision-14): the RSA pair its followers have cached, the actor id
739
- * they key the account by, the number its paths are built from, and the
740
- * followers themselves. It is a command rather than a screen because a stored
741
- * actor id is identity for the life of the account, and it is idempotent
742
- * because the cutover is a thing an owner will want to run twice — once before
743
- * the DNS moves and once after an instance that was down comes back.
820
+ * Every command the site's available plugins add, from its config and its
821
+ * plugins folder, longest words first.
822
+ */
823
+ async function pluginCommands(config) {
824
+ const folderPlugins = await importPluginFolders(scanPluginFolders(config.pluginsDir));
825
+ return sitePluginRegistry(config, folderPlugins)
826
+ .plugins.filter((entry) => entry.problem === undefined)
827
+ .flatMap((entry) => entry.commands.map((command) => ({ plugin: entry.plugin.name, command })))
828
+ .sort((a, b) => b.command.words.length - a.command.words.length);
829
+ }
830
+ /**
831
+ * Run a command a plugin added (decision-33). It needs no enabling: a
832
+ * migration command runs before the plugin it prepares for is turned on.
744
833
  *
745
- * The database is opened for the reason `user add` opens it: this is a door
746
- * that can reach `data/users.json` before any server has, so a site upgrading
747
- * from the version that kept its accounts in SQLite has to be migrated first
748
- * or the import would write into a file holding nobody.
834
+ * The database is opened for the reason `user add` opens it: a command can
835
+ * reach `data/users.json` before any server has, so a site upgrading from the
836
+ * version that kept its accounts in SQLite is migrated first.
749
837
  */
750
- async function importCommand(args, configPath, flags) {
751
- const subcommand = args[0];
752
- if (subcommand === undefined) {
753
- throw new Error('geekity import <what> needs a subcommand. The only one is: wordpress-actor.');
754
- }
755
- if (subcommand !== 'wordpress-actor') {
756
- throw new Error(`Unknown import subcommand "${subcommand}". The only one is: wordpress-actor.`);
757
- }
758
- const username = args[1];
759
- if (username === undefined) {
760
- throw new Error('geekity import wordpress-actor <username> needs the account on this site the ' +
761
- "plugin's actor becomes.");
762
- }
763
- const actorId = text(flags, 'actor-id');
764
- if (actorId === undefined) {
765
- throw new Error('--actor-id is the id WordPress published this person under, query string and all, ' +
766
- 'for example --actor-id "https://example.com/?author=2". Its followers key the ' +
767
- 'account by it, so it is the one thing the import cannot work out for itself.');
768
- }
769
- if (!isAbsoluteHttpUrl(actorId)) {
770
- throw new Error(`--actor-id must be an http or https URL; "${actorId}" is not one.`);
771
- }
772
- const number = text(flags, 'wordpress-id');
773
- if (number === undefined || !/^[1-9][0-9]*$/.test(number)) {
774
- throw new Error('--wordpress-id is the WordPress user id, a whole positive number: it is what the ' +
775
- "plugin's paths are built from, as in /wp-json/activitypub/1.0/actors/2/inbox.");
776
- }
777
- const { privateKeyPem, publicKeyPem } = await readKeyPair(flags);
838
+ async function pluginCommand(argv) {
839
+ const configPath = configFlag(argv);
778
840
  const config = resolveConfig(await loadConfig(process.cwd(), configPath));
841
+ const words = leadingWords(argv);
842
+ const found = (await pluginCommands(config)).find(({ command }) => command.words.every((word, index) => words[index] === word));
843
+ if (found === undefined) {
844
+ throw new Error(`Unknown command "${words[0] ?? ''}". Run geekity --help to see what is available.`);
845
+ }
846
+ const { args, options } = parseCommandArgs(withoutConfig(argv).slice(found.command.words.length), found.command);
779
847
  const admin = openAdminStore({ dataDir: config.dataDir });
780
848
  migrateUsersToFile({ admin, dataDir: config.dataDir });
781
849
  try {
782
- const report = await importWordPressActor({
783
- admin,
784
- dataDir: config.dataDir,
785
- contentDir: config.contentDir,
786
- username,
787
- actorId,
788
- wordpressActorId: Number(number),
789
- privateKeyPem,
790
- publicKeyPem,
791
- followers: followersSource(flags),
792
- force: flags['force'] === true,
850
+ return await found.command.run({
851
+ args,
852
+ options,
853
+ cwd: process.cwd(),
854
+ site: pluginSite({ admin, config }),
855
+ write: (output) => process.stdout.write(output),
793
856
  });
794
- process.stdout.write(importSummary(report));
795
- return 0;
796
857
  }
797
858
  finally {
798
859
  admin.close();
799
860
  }
800
861
  }
801
- /**
802
- * The key pair, from whichever of the two exports the owner had to hand.
803
- *
804
- * `--keypair` is the option the current plugin stores — one option holding
805
- * both halves — and the two PEM files are what `wp user meta get` prints for a
806
- * site old enough to still be on the legacy meta keys. The README says which
807
- * command produces which.
808
- */
809
- async function readKeyPair(flags) {
810
- const keypair = text(flags, 'keypair');
811
- const privateKey = text(flags, 'private-key');
812
- const publicKey = text(flags, 'public-key');
813
- if (keypair !== undefined) {
814
- if (privateKey !== undefined) {
815
- throw new Error('Pass either --keypair or --private-key, not both.');
816
- }
817
- let parsed;
818
- try {
819
- parsed = JSON.parse(await readFile(path.resolve(process.cwd(), keypair), 'utf8'));
862
+ function withoutConfig(argv) {
863
+ const kept = [];
864
+ for (let i = 0; i < argv.length; i += 1) {
865
+ const arg = argv[i];
866
+ if (arg === '--config') {
867
+ i += 1;
868
+ continue;
820
869
  }
821
- catch (error) {
822
- throw new Error(`${keypair} could not be read as the JSON "wp option get … --format=json" prints: ` +
823
- `${error instanceof Error ? error.message : String(error)}`, { cause: error });
824
- }
825
- const held = parsed;
826
- if (typeof held?.private_key !== 'string') {
827
- throw new Error(`${keypair} has no "private_key". Check what the export wrote.`);
828
- }
829
- return {
830
- privateKeyPem: held.private_key,
831
- publicKeyPem: typeof held.public_key === 'string' ? held.public_key : undefined,
832
- };
833
- }
834
- if (privateKey === undefined) {
835
- throw new Error('The key pair is missing: pass --keypair with the JSON WordPress stores, or ' +
836
- '--private-key with the PEM. Without it this person could not sign anything, and ' +
837
- 'every follower holds the public half of the key that signs it.');
838
- }
839
- return {
840
- privateKeyPem: await readFile(path.resolve(process.cwd(), privateKey), 'utf8'),
841
- publicKeyPem: publicKey === undefined
842
- ? undefined
843
- : await readFile(path.resolve(process.cwd(), publicKey), 'utf8'),
844
- };
870
+ if (!arg.startsWith('--config='))
871
+ kept.push(arg);
872
+ }
873
+ return kept;
874
+ }
875
+ /** A plugin command's arguments and options, refusing any option it does not take. */
876
+ function parseCommandArgs(argv, command) {
877
+ const args = [];
878
+ const options = {};
879
+ for (let i = 0; i < argv.length; i += 1) {
880
+ const arg = argv[i];
881
+ if (!arg.startsWith('--')) {
882
+ args.push(arg);
883
+ continue;
884
+ }
885
+ const equals = arg.indexOf('=');
886
+ const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
887
+ const inline = equals === -1 ? undefined : arg.slice(equals + 1);
888
+ const option = command.options?.find((entry) => entry.name === name);
889
+ if (option === undefined) {
890
+ throw new Error(`Unknown option "--${name}". Run geekity --help to see what is available.`);
891
+ }
892
+ if (option.value === undefined) {
893
+ if (inline !== undefined)
894
+ throw new Error(`--${name} takes no value.`);
895
+ options[name] = true;
896
+ continue;
897
+ }
898
+ if (inline !== undefined) {
899
+ if (inline === '')
900
+ throw new Error(`--${name} needs a value.`);
901
+ options[name] = inline;
902
+ continue;
903
+ }
904
+ const value = argv[i + 1];
905
+ if (value === undefined || value.startsWith('-'))
906
+ throw new Error(`--${name} needs a value.`);
907
+ options[name] = value;
908
+ i += 1;
909
+ }
910
+ return { args, options };
845
911
  }
846
912
  /**
847
- * What `--followers` means: a URL, a file resolved against the working
848
- * directory, `none`, or the plugin's own collection when it was left off.
913
+ * The help, with the commands the site's plugins add. A config that will not
914
+ * load costs the help its plugin section, not the help itself.
849
915
  */
850
- function followersSource(flags) {
851
- const given = text(flags, 'followers');
852
- if (given === undefined)
853
- return undefined;
854
- if (given === 'none')
855
- return false;
856
- return isAbsoluteHttpUrl(given) ? given : path.resolve(process.cwd(), given);
857
- }
858
- /** What the run did, as the operator reads it. */
859
- function importSummary(report) {
860
- const lines = [];
861
- const { followers } = report;
862
- lines.push(report.changed
863
- ? `Imported ${report.username} from ${report.actorId}`
864
- : `Nothing to change: ${report.username} is already ${report.actorId}`);
865
- lines.push(` ids ${report.identity === 'set' ? 'written to' : 'already in'} ` +
866
- `data/users.json (WordPress actor ${String(report.wordpressActorId)})`);
867
- for (const key of report.keys)
868
- lines.push(` key ${key.state} ${key.file}`);
869
- if (followers.source === undefined) {
870
- lines.push(' followers skipped');
871
- }
872
- else {
873
- lines.push(` followers ${count(followers.added.length, 'added', 'added')}, ` +
874
- `${String(followers.refreshed.length)} refreshed, ` +
875
- `${String(followers.unchanged.length)} unchanged, ` +
876
- `${String(followers.failed.length)} could not be fetched ` +
877
- `(${followers.source})`);
878
- for (const failure of followers.failed) {
879
- lines.push(` skipped ${failure.actor}: ${failure.reason}`);
880
- }
881
- if (followers.failed.length > 0) {
882
- lines.push(' Those followers were left out. Run this again once their servers answer; ' +
883
- 'nothing already imported is touched twice.');
884
- }
885
- }
886
- return `${lines.join('\n')}\n`;
887
- }
888
- /** One flag as the text it carries, or `undefined` when it was not given. */
889
- function text(flags, name) {
890
- const value = flags[name];
891
- return typeof value === 'string' ? value : undefined;
892
- }
893
- /** Whether a value is a URL a peer could fetch. */
894
- function isAbsoluteHttpUrl(value) {
916
+ async function usage(configPath) {
917
+ let found;
895
918
  try {
896
- const url = new URL(value);
897
- return url.protocol === 'http:' || url.protocol === 'https:';
919
+ found = await pluginCommands(resolveConfig(await loadConfig(process.cwd(), configPath)));
898
920
  }
899
- catch {
900
- return false;
921
+ catch (error) {
922
+ process.stderr.write(`Plugin commands are not listed: ${error instanceof Error ? error.message : String(error)}\n`);
923
+ return USAGE;
924
+ }
925
+ if (found.length === 0)
926
+ return USAGE;
927
+ const lines = ['', 'Plugin commands:'];
928
+ for (const { plugin, command } of found) {
929
+ const synopsis = wrap(`geekity ${[...command.words, command.usage].join(' ').trim()} [--config <file>]`, USAGE_CONTINUATION);
930
+ lines.push(` ${(synopsis[0] ?? '').trimStart()}`, ...synopsis.slice(1));
931
+ lines.push(...wrap(command.summary, HELP_INDENT), `${HELP_INDENT}From ${plugin}.`);
932
+ for (const option of command.options ?? []) {
933
+ const flag = `--${option.name}${option.value === undefined ? '' : ` ${option.value}`}`;
934
+ const described = wrap(option.description, HELP_INDENT);
935
+ const head = ` ${flag}`;
936
+ if (head.length < HELP_INDENT.length - 1 && described[0] !== undefined) {
937
+ described[0] = `${head.padEnd(HELP_INDENT.length)}${described[0].trimStart()}`;
938
+ lines.push(...described);
939
+ }
940
+ else {
941
+ lines.push(head, ...described);
942
+ }
943
+ }
944
+ }
945
+ return `${USAGE}${lines.join('\n')}\n`;
946
+ }
947
+ /** Where the help's descriptions start, as the core commands' do. */
948
+ const HELP_INDENT = ' '.repeat(19);
949
+ /** Where a usage line carries on, as the core commands' do. */
950
+ const USAGE_CONTINUATION = ' '.repeat(10);
951
+ const HELP_WIDTH = 79;
952
+ /** Text wrapped to the help's width, each line indented. */
953
+ function wrap(text, indent) {
954
+ const lines = [];
955
+ let line = '';
956
+ for (const word of text.split(/\s+/).filter((entry) => entry !== '')) {
957
+ if (line !== '' && indent.length + line.length + 1 + word.length > HELP_WIDTH) {
958
+ lines.push(`${indent}${line}`);
959
+ line = word;
960
+ }
961
+ else {
962
+ line = line === '' ? word : `${line} ${word}`;
963
+ }
901
964
  }
965
+ if (line !== '')
966
+ lines.push(`${indent}${line}`);
967
+ return lines;
902
968
  }
903
969
  /**
904
970
  * Ask for a password once, without putting it on the screen.