blogwright 0.3.2 → 0.4.0-beta.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 (70) hide show
  1. package/README.md +11 -11
  2. package/agent/agent-manifest.json +1 -1
  3. package/agent/server.js +37 -19
  4. package/dist/adapters/fetch-ping.d.ts +1 -1
  5. package/dist/adapters/fetch-ping.js +3 -4
  6. package/dist/adapters/node-module-loader.d.ts +11 -0
  7. package/dist/adapters/node-module-loader.js +146 -0
  8. package/dist/adapters/process-package-manager.d.ts +41 -0
  9. package/dist/adapters/process-package-manager.js +116 -0
  10. package/dist/adapters/process-vcs.d.ts +5 -4
  11. package/dist/adapters/process-vcs.js +6 -6
  12. package/dist/agent-package.d.ts +1 -1
  13. package/dist/agent-package.js +4 -4
  14. package/dist/bin.js +13 -3
  15. package/dist/cli.d.ts +68 -1
  16. package/dist/cli.js +270 -89
  17. package/dist/commands.d.ts +70 -3
  18. package/dist/commands.js +180 -32
  19. package/dist/config-block.d.ts +34 -0
  20. package/dist/config-block.js +262 -0
  21. package/dist/context.d.ts +76 -6
  22. package/dist/context.js +98 -19
  23. package/dist/deploy.d.ts +2 -2
  24. package/dist/deploy.js +14 -14
  25. package/dist/graph.d.ts +27 -16
  26. package/dist/graph.js +1 -2
  27. package/dist/init.d.ts +37 -3
  28. package/dist/init.js +146 -23
  29. package/dist/known-commands.d.ts +63 -0
  30. package/dist/known-commands.js +78 -0
  31. package/dist/logger.js +0 -1
  32. package/dist/microvms.d.ts +2 -2
  33. package/dist/microvms.js +3 -4
  34. package/dist/nodes.d.ts +5 -3
  35. package/dist/nodes.js +97 -31
  36. package/dist/plugin-commands.d.ts +298 -0
  37. package/dist/plugin-commands.js +990 -0
  38. package/dist/plugins.d.ts +194 -0
  39. package/dist/plugins.js +523 -0
  40. package/dist/ports.d.ts +89 -1
  41. package/dist/ports.js +0 -1
  42. package/dist/render.d.ts +55 -0
  43. package/dist/render.js +89 -2
  44. package/dist/repo.d.ts +3 -3
  45. package/dist/repo.js +8 -9
  46. package/dist/rkey.js +0 -1
  47. package/dist/seo.d.ts +1 -1
  48. package/dist/seo.js +1 -2
  49. package/package.json +6 -6
  50. package/dist/adapters/fetch-ping.js.map +0 -1
  51. package/dist/adapters/process-vcs.js.map +0 -1
  52. package/dist/agent-package.js.map +0 -1
  53. package/dist/bin.js.map +0 -1
  54. package/dist/cli.js.map +0 -1
  55. package/dist/commands.js.map +0 -1
  56. package/dist/context.js.map +0 -1
  57. package/dist/deploy.js.map +0 -1
  58. package/dist/graph.js.map +0 -1
  59. package/dist/init.js.map +0 -1
  60. package/dist/logger.js.map +0 -1
  61. package/dist/microvms.js.map +0 -1
  62. package/dist/nodes.js.map +0 -1
  63. package/dist/ports.js.map +0 -1
  64. package/dist/render.js.map +0 -1
  65. package/dist/repo.js.map +0 -1
  66. package/dist/rkey.js.map +0 -1
  67. package/dist/seo.js.map +0 -1
  68. package/dist/test-support.d.ts +0 -45
  69. package/dist/test-support.js +0 -126
  70. package/dist/test-support.js.map +0 -1
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Plugin discovery: finds every installed `blogwright-*` package that
3
+ * declares a `blogwright.plugin` manifest field, loads it, and validates its
4
+ * default export against core's `Plugin` contract.
5
+ *
6
+ * The candidate set is the UNION of two sources, each resolved from its own
7
+ * directory - see `collectCandidates` below - because a consuming repo
8
+ * depends on `blogwright`, not on `blogwright-pds`: scanning the consumer's
9
+ * own `package.json` alone would never find a plugin bundled inside the CLI
10
+ * itself.
11
+ *
12
+ * Collect, never throw, for anything candidate-specific: a broken plugin -
13
+ * one that fails to resolve, whose manifest is malformed, or whose default
14
+ * export fails validation - becomes an entry in `failures`, not a thrown
15
+ * error that aborts the whole discovery pass. One bad dependency must not
16
+ * make `blogwright deploy` (or any other built-in command) unusable. The
17
+ * only things this module *does* throw for are the two preconditions
18
+ * discovery cannot proceed without at all: the repo's own `package.json` and
19
+ * the CLI's own `package.json`, both read before any candidate is resolved.
20
+ *
21
+ * The same collect-never-throw rule governs the two namespace-collision
22
+ * checks this module applies itself, after a candidate has already loaded
23
+ * and validated cleanly: a plugin whose declared name is one the CLI
24
+ * dispatches itself (`RESERVED_COMMANDS`, `known-commands.ts` - a leaf
25
+ * module with no imports of its own, so this domain module never has to
26
+ * import the composition root just to read it), and two plugins that
27
+ * declare the same name as each other. §CLI → Namespace collisions calls
28
+ * this "rejected with an error", but - exactly like a malformed manifest or
29
+ * a failed `validatePlugin` check above - that rejection is a reported
30
+ * `failures` entry, not a thrown one. Throwing here would let a single
31
+ * colliding plugin abort discovery for every other, unrelated plugin and
32
+ * every built-in command that runs it; `blogwright plugin list` (task 17)
33
+ * depends on the collect outcome too, since it is the one place a collision
34
+ * becomes visible to a human.
35
+ *
36
+ * `pds` is deliberately absent from `RESERVED_COMMANDS`, and adding it would
37
+ * now BREAK the namespace rather than merely shadow it. Task 29 deleted
38
+ * `cli.ts`'s hardcoded `command === 'pds'` branch: there is no built-in
39
+ * `pds` command left for a reservation to protect, and `blogwright pds
40
+ * <action>` is answered by the bundled `blogwright-pds` package, which
41
+ * declares the plugin name `pds` and is discovered here like any other
42
+ * plugin. Reserving the name would therefore aim
43
+ * `resolveNamespaceCollisions` below at that bundled plugin itself: it would
44
+ * become a `failures` entry rather than an installed one, and the namespace
45
+ * would stop working outright - `blogwright pds sync` exiting 1 with `no
46
+ * built-in command or installed plugin claims "pds"`, `blogwright --help`
47
+ * listing none of its six actions, and `blogwright plugin list` reporting it
48
+ * as reserved for a built-in command that no longer exists. Verified by
49
+ * adding `'pds'` to the set: discovery rejects the real bundled package and
50
+ * this file's real-disk integration cases fail on exactly that reason
51
+ * string. So the name stays unreserved on purpose, pinned by a test below.
52
+ *
53
+ * DECISION (task 13, record here for task 16 to find): a plugin's declared
54
+ * ACTIONS can collide with a generic action the CLI contributes, distinct
55
+ * from the namespace collisions above. §CLI → `blogwright <plugin> init`
56
+ * names exactly one such collision a boundary check can reject: a plugin
57
+ * declaring BOTH an `init` command in its own `commands` AND an `init?(io)`
58
+ * contributor is unsatisfiable, because a declared command always wins
59
+ * dispatch (`plugin-commands.ts`'s `matchAction` matches a plugin's own
60
+ * `commands` before the generic action is ever considered), so the
61
+ * contributor would ask its questions nowhere. That check - `rejectDeclaredInitCollisions`
62
+ * below - lives HERE, in this module's collision pass, rather than in core's
63
+ * `validatePlugin` (`blogwright-core`'s `plugin.ts`), for the same reason the
64
+ * namespace checks do: it is about actions the *CLI* contributes generically
65
+ * (the config-writing `init`), which core must not know exists, and this
66
+ * module already reports a plugin-level rejection as a `failures` entry
67
+ * rather than a thrown error. §CLI → Plugin lifecycle adds the sibling rule
68
+ * for `bootstrap`/`destroy` (always generic; a plugin may never declare
69
+ * either, full stop - no "unless paired with a contributor" nuance, since
70
+ * there is no bootstrap/destroy contributor to pair with). Task 16 adds
71
+ * `rejectDeclaredLifecycleCollisions` below, a sibling function called from
72
+ * the same place in `discover`, with that rule, so every declared-action
73
+ * collision rejection greps to this one module instead of splitting across
74
+ * whichever of the two tasks happened to land first. `status` is deliberately
75
+ * NOT part of that rule: a plugin may declare its own `status` command
76
+ * freely - `read()` lives on the plugin's own nodes, so no engine call is
77
+ * required the way `bootstrap`/`destroy` need one - and `plugin-commands.ts`'s
78
+ * ordinary `matchAction` precedence (a plugin's own commands win before any
79
+ * generic fallback is even considered) already gives a declared `status`
80
+ * command priority with no boundary check needed here.
81
+ *
82
+ * DECISION (task 19, recorded here plainly because task 28 has to reason
83
+ * about it when pds's config validation moves out of core): a plugin's own
84
+ * config block is validated for the ONE plugin being DISPATCHED, in the
85
+ * dispatch path (`runPlugin` calls {@link resolvePluginConfig} below), and
86
+ * never for every discovered plugin. Two reasons, and neither is taste:
87
+ *
88
+ * - `createContext` (`context.ts`) is the path every built-in command
89
+ * takes and it accepts no plugin list, so validating there would have to
90
+ * run `discover` on `deploy`, `status` and `bootstrap` - breaking the
91
+ * laziness rule (§CLI -> Plugin discovery: a built-in command loads no
92
+ * plugin module) that task 10's own test pins. There is no seam in
93
+ * `createContext` through which the dispatched plugin ALONE could be
94
+ * reached, because at that point no plugin has been chosen yet.
95
+ * - Validating every discovered plugin, wherever it happened, would let an
96
+ * unrelated plugin's malformed block abort a command that has nothing to
97
+ * do with it. A block for a plugin that is not installed is already
98
+ * valid and inert - the same contract `pds` has today - and a block for
99
+ * an installed plugin that is not the one being run is inert for exactly
100
+ * the same reason: nothing reads it.
101
+ *
102
+ * The corollary is that `blogwright <plugin> <action>` is the only thing
103
+ * that reports a bad block, and it reports only its own plugin's.
104
+ */
105
+ import { join } from 'node:path';
106
+ import { FileNotFoundError, PLUGIN_NAME_PATTERN, pluginBlock, validatePlugin, } from 'blogwright-core';
107
+ import { RESERVED_COMMANDS } from './known-commands.js';
108
+ /** Only a dependency name starting with this becomes a plugin candidate. */
109
+ const PLUGIN_PACKAGE_PREFIX = 'blogwright-';
110
+ function isRecord(value) {
111
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
112
+ }
113
+ /**
114
+ * Read and parse `<dir>/package.json` through `ports.fs`, raising an error
115
+ * naming the path, `role`, and what would fix it when the file is absent or
116
+ * unparseable. This is a discovery precondition, not a per-candidate outcome
117
+ * - unlike a broken plugin, a repo or CLI install with no readable
118
+ * `package.json` leaves discovery with nothing to work from at all, so it
119
+ * aborts the whole pass rather than returning an empty result.
120
+ */
121
+ async function readDependencyManifest(fs, packageJsonPath, role) {
122
+ let text;
123
+ try {
124
+ text = await fs.readText(packageJsonPath);
125
+ }
126
+ catch (err) {
127
+ if (err instanceof FileNotFoundError) {
128
+ throw new Error(`no package.json found at ${packageJsonPath} for ${role} - plugin discovery reads its ` +
129
+ '"dependencies"/"devDependencies" to find installed blogwright-* plugins; create one there.', { cause: err });
130
+ }
131
+ throw err;
132
+ }
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(text);
136
+ }
137
+ catch (err) {
138
+ throw new Error(`failed to parse ${packageJsonPath} as JSON for ${role}: ${err.message}`, { cause: err });
139
+ }
140
+ if (!isRecord(parsed)) {
141
+ throw new Error(`${packageJsonPath} for ${role} must contain a JSON object, not ${text}`);
142
+ }
143
+ return parsed;
144
+ }
145
+ /** Every `dependencies`/`devDependencies` name starting with `blogwright-`, deduplicated and sorted. */
146
+ function pluginDependencyNames(pkg) {
147
+ const names = new Set();
148
+ for (const deps of [pkg.dependencies, pkg.devDependencies]) {
149
+ if (!deps)
150
+ continue;
151
+ for (const name of Object.keys(deps)) {
152
+ if (name.startsWith(PLUGIN_PACKAGE_PREFIX))
153
+ names.add(name);
154
+ }
155
+ }
156
+ return [...names].sort();
157
+ }
158
+ /**
159
+ * Build the candidate set: the consumer's own `blogwright-*` dependencies
160
+ * (resolved from `repoRoot`) union the CLI's own bundled `blogwright-*`
161
+ * dependencies (resolved from `cliPackageDir`). Both halves are required and
162
+ * each is resolved from its own directory - see the module comment. Neither
163
+ * `blogwright` nor `blogwright/package.json` is ever a candidate: the bare
164
+ * name never matches the `blogwright-` prefix, so it is filtered out before
165
+ * any resolution is attempted.
166
+ *
167
+ * A package name present in BOTH manifests - e.g. a plugin `blogwright
168
+ * plugin add` (task 18) pinned directly into the consuming repo, which
169
+ * already sits in the CLI's own bundled dependencies too - is one installed
170
+ * package, not two: it is deduped here, consumer half winning, so it is
171
+ * probed exactly once. Without this, the same package would reach
172
+ * `resolveNamespaceCollisions` as two `InstalledPlugin` entries sharing one
173
+ * `packageName` and reject itself as a "duplicate" of itself.
174
+ */
175
+ async function collectCandidates(ports, repoRoot, cliPackageDir) {
176
+ const consumerPkg = await readDependencyManifest(ports.fs, join(repoRoot, 'package.json'), 'the consuming repo');
177
+ const cliPkg = await readDependencyManifest(ports.fs, join(cliPackageDir, 'package.json'), "the CLI's own package");
178
+ const consumerNames = pluginDependencyNames(consumerPkg);
179
+ const consumerCandidates = consumerNames.map((packageName) => ({ packageName, fromDir: repoRoot }));
180
+ // The bundled half is NOT filtered against the consumer's names here. A name
181
+ // in both manifests must still be probed from both directories, because a
182
+ // consumer can DECLARE a plugin it cannot resolve - a pruned devDependency,
183
+ // `pnpm install --prod`, a hand-edited manifest before install - while the
184
+ // CLI's bundled copy is installed and working. Filtering here would suppress
185
+ // that copy and report nothing at all, which is the silent outcome the
186
+ // two-source union exists to prevent. `discover` instead skips the bundled
187
+ // probe only once the consumer's has actually resolved, so the consumer still
188
+ // wins wherever both work.
189
+ const bundledCandidates = pluginDependencyNames(cliPkg).map((packageName) => ({ packageName, fromDir: cliPackageDir }));
190
+ return [...consumerCandidates, ...bundledCandidates];
191
+ }
192
+ /** Validate a raw `blogwright` field into a `PluginManifest`, or `undefined` when it does not hold. */
193
+ function parsePluginManifest(value) {
194
+ if (!isRecord(value))
195
+ return undefined;
196
+ const plugin = value.plugin;
197
+ if (typeof plugin !== 'string' || !PLUGIN_NAME_PATTERN.test(plugin))
198
+ return undefined;
199
+ return { plugin };
200
+ }
201
+ /**
202
+ * Resolve, read, and (when it declares a plugin) load and validate one
203
+ * candidate. Every step past `packageJsonPathFor`'s `{ found: false }` branch
204
+ * runs inside one try/catch: any unexpected failure - a resolution error a
205
+ * downgraded resolver would raise (see `ModuleLoader.packageJsonPathFor`'s
206
+ * doc comment), an unreadable or unparseable manifest, or a `validatePlugin`
207
+ * rejection - becomes a `failure` naming `candidate.packageName`, never a
208
+ * thrown error, per the collect-versus-throw choice in the module comment.
209
+ */
210
+ async function loadCandidate(candidate, ports) {
211
+ try {
212
+ const manifestPath = await ports.loader.packageJsonPathFor(candidate.packageName, candidate.fromDir);
213
+ // Declared as a dependency but not actually resolvable (e.g. an
214
+ // out-of-sync lockfile, an optional dependency never installed): nothing
215
+ // to report, since it is not installed at all rather than broken.
216
+ if (!manifestPath.found)
217
+ return { kind: 'absent' };
218
+ let manifestJson;
219
+ try {
220
+ manifestJson = JSON.parse(await ports.fs.readText(manifestPath.path));
221
+ }
222
+ catch (err) {
223
+ throw new Error(`failed to read or parse ${manifestPath.path}: ${err.message}`, {
224
+ cause: err,
225
+ });
226
+ }
227
+ if (!isRecord(manifestJson) || manifestJson.blogwright === undefined) {
228
+ // No `blogwright.plugin` field at all: not a plugin. Skipped silently,
229
+ // per §CLI → Plugin discovery - most of a repo's blogwright-* deps
230
+ // (blogwright-core itself, for one) are not plugins.
231
+ return { kind: 'not-a-plugin' };
232
+ }
233
+ const manifest = parsePluginManifest(manifestJson.blogwright);
234
+ if (!manifest) {
235
+ throw new Error(`${manifestPath.path}'s "blogwright" field is malformed - expected ` +
236
+ `{ "plugin": "<namespace>" } with a namespace matching ${PLUGIN_NAME_PATTERN}`);
237
+ }
238
+ const entry = await ports.loader.resolve(candidate.packageName, candidate.fromDir);
239
+ if (!entry.found) {
240
+ throw new Error(`declares plugin namespace "${manifest.plugin}" in ${manifestPath.path} but could not ` +
241
+ `be resolved as an entry point from ${candidate.fromDir}`);
242
+ }
243
+ const mod = await ports.loader.load(entry.path);
244
+ return {
245
+ kind: 'plugin',
246
+ plugin: validatePlugin(mod, candidate.packageName),
247
+ packageJsonPath: manifestPath.path,
248
+ };
249
+ }
250
+ catch (err) {
251
+ return {
252
+ kind: 'failure',
253
+ failure: { packageName: candidate.packageName, reason: err.message },
254
+ };
255
+ }
256
+ }
257
+ /**
258
+ * Split every loaded plugin into survivors and namespace-collision failures.
259
+ * A name is checked for a reserved collision before a duplicate one: two
260
+ * plugins both claiming a reserved name are each reported once, against the
261
+ * reservation, not twice against each other. Neither check depends on the
262
+ * order `loaded` arrives in - a duplicate group has no "first" survivor (all
263
+ * of it fails, see the module comment), and every failure naming more than
264
+ * one package lists them from a `.sort()`ed array, so the rendered message
265
+ * text is identical no matter which candidate was resolved first.
266
+ */
267
+ function resolveNamespaceCollisions(loaded) {
268
+ const byName = new Map();
269
+ for (const entry of loaded) {
270
+ const bucket = byName.get(entry.plugin.name);
271
+ if (bucket)
272
+ bucket.push(entry);
273
+ else
274
+ byName.set(entry.plugin.name, [entry]);
275
+ }
276
+ const installed = [];
277
+ const failures = [];
278
+ for (const [name, entries] of byName) {
279
+ if (RESERVED_COMMANDS.has(name)) {
280
+ for (const entry of entries) {
281
+ failures.push({
282
+ packageName: entry.packageName,
283
+ reason: `${entry.packageName} declares plugin name "${name}", which is reserved for the ` +
284
+ `built-in "${name}" command - built-in commands always win`,
285
+ });
286
+ }
287
+ continue;
288
+ }
289
+ if (entries.length > 1) {
290
+ const packageNames = entries.map((entry) => entry.packageName).sort();
291
+ for (const packageName of packageNames) {
292
+ failures.push({
293
+ packageName,
294
+ reason: `plugin name "${name}" is claimed by more than one installed package: ${packageNames.join(', ')}`,
295
+ });
296
+ }
297
+ continue;
298
+ }
299
+ const [entry] = entries;
300
+ if (entry)
301
+ installed.push(entry);
302
+ }
303
+ return { installed, failures };
304
+ }
305
+ /**
306
+ * Split the namespace survivors again on the CONFIG KEY each claims: two
307
+ * installed plugins declaring the same `configKey` are both rejected, naming
308
+ * both packages and the shared key. §CLI -> Config ownership gives a plugin
309
+ * ONE top-level key it owns end to end, and two owners cannot both be it -
310
+ * whichever won would silently be handed the other's block, and
311
+ * `blogwright <plugin> init` would splice two different plugins' answers
312
+ * under one key.
313
+ *
314
+ * The whole group fails, exactly as a duplicate NAMESPACE group does
315
+ * (`resolveNamespaceCollisions` above): there is no "first" survivor to
316
+ * prefer, and the arriving order is an implementation detail of two
317
+ * `dependencies` maps. Reported as `failures` entries rather than a thrown
318
+ * error, per the module comment's collect-versus-throw rule - a colliding
319
+ * pair must not take `blogwright deploy`, or any unrelated plugin, down with
320
+ * it, and `blogwright plugin list` is where a human sees the collision.
321
+ *
322
+ * Runs AFTER the namespace pass, over its survivors, so a pair that collides
323
+ * on BOTH its name and its key is reported once - against the name, which is
324
+ * the collision an operator hits first.
325
+ *
326
+ * A plugin declaring no `configKey` at all owns nothing to collide over and
327
+ * is never grouped: it always survives.
328
+ */
329
+ function rejectDuplicateConfigKeys(loaded) {
330
+ const byKey = new Map();
331
+ for (const entry of loaded) {
332
+ const key = entry.plugin.configKey;
333
+ // A plugin owning no key owns nothing to collide over, so it is never
334
+ // grouped and always survives.
335
+ if (key === undefined)
336
+ continue;
337
+ const bucket = byKey.get(key);
338
+ if (bucket)
339
+ bucket.push(entry);
340
+ else
341
+ byKey.set(key, [entry]);
342
+ }
343
+ const failures = [];
344
+ const rejected = new Set();
345
+ for (const [key, entries] of byKey) {
346
+ if (entries.length === 1)
347
+ continue;
348
+ const packageNames = entries.map((entry) => entry.packageName).sort();
349
+ for (const packageName of packageNames) {
350
+ rejected.add(packageName);
351
+ failures.push({
352
+ packageName,
353
+ reason: `config key "${key}" is claimed by more than one installed plugin: ` +
354
+ `${packageNames.join(', ')} - a plugin owns exactly one top-level config key, and no ` +
355
+ 'two plugins may own the same one',
356
+ });
357
+ }
358
+ }
359
+ // Filtered rather than re-accumulated, so survivors arrive in the order
360
+ // they were discovered in rather than in the grouping map's.
361
+ return { survivors: loaded.filter((entry) => !rejected.has(entry.packageName)), failures };
362
+ }
363
+ /** The generic action name `init?(io)` contributors would otherwise collide with - see {@link rejectDeclaredInitCollisions}. */
364
+ const GENERIC_INIT_ACTION = 'init';
365
+ /**
366
+ * Reject a plugin declaring BOTH an `init` command in its own `commands` and
367
+ * an `init?(io)` contributor - the one half of §CLI → `blogwright <plugin>
368
+ * init`'s precedence rule a boundary check can decide (see the module
369
+ * comment's DECISION note on why this check lives here, in the collision
370
+ * pass, rather than core's `validatePlugin`). Declaring either alone is
371
+ * valid and passes through untouched: pds declares the `init` command and no
372
+ * contributor, analytics the contributor and no command.
373
+ */
374
+ function rejectDeclaredInitCollisions(loaded) {
375
+ const survivors = [];
376
+ const failures = [];
377
+ for (const entry of loaded) {
378
+ const declaresInitCommand = entry.plugin.commands.some((command) => command.action === GENERIC_INIT_ACTION);
379
+ const declaresInitContributor = typeof entry.plugin.init === 'function';
380
+ if (declaresInitCommand && declaresInitContributor) {
381
+ failures.push({
382
+ packageName: entry.packageName,
383
+ reason: `${entry.packageName} declares both an "init" command and an init(io) contributor - ` +
384
+ 'a declared command always wins dispatch, so the contributor would never run; declare only one',
385
+ });
386
+ continue;
387
+ }
388
+ survivors.push(entry);
389
+ }
390
+ return { survivors, failures };
391
+ }
392
+ /**
393
+ * The two action names §CLI → Plugin lifecycle reserves for the CLI's own
394
+ * generic engine (`applyGraph`/`destroyGraph`, `packages/cli/src/graph.ts`)
395
+ * - see {@link rejectDeclaredLifecycleCollisions}. `status` is excluded on
396
+ * purpose; see that function's doc comment.
397
+ */
398
+ const RESERVED_LIFECYCLE_ACTIONS = new Set(['bootstrap', 'destroy']);
399
+ /**
400
+ * Reject a plugin that declares `bootstrap` or `destroy` as one of its own
401
+ * `commands`. Unlike the `init` collision above, there is no contributor
402
+ * either could pair with that would make the collision conditional: a
403
+ * plugin may not import the CLI and so cannot run `applyGraph`/
404
+ * `destroyGraph` itself, which is exactly what `bootstrap`/`destroy` need to
405
+ * do - so declaring either is rejected outright, full stop. `status` is
406
+ * deliberately absent from {@link RESERVED_LIFECYCLE_ACTIONS}: a plugin MAY
407
+ * declare its own `status` command, because reading a resource's existence
408
+ * (`node.read(ctx)`) needs no engine call, and `plugin-commands.ts`'s
409
+ * ordinary `matchAction` precedence already lets a declared `status` win
410
+ * over the generic one with no boundary check required.
411
+ */
412
+ function rejectDeclaredLifecycleCollisions(loaded) {
413
+ const survivors = [];
414
+ const failures = [];
415
+ for (const entry of loaded) {
416
+ const collision = entry.plugin.commands.find((command) => RESERVED_LIFECYCLE_ACTIONS.has(command.action));
417
+ if (collision) {
418
+ failures.push({
419
+ packageName: entry.packageName,
420
+ reason: `${entry.packageName} declares a "${collision.action}" command - "bootstrap" and ` +
421
+ '"destroy" are always the generic lifecycle verbs, run by the CLI\'s own engine over ' +
422
+ "this plugin's nodes(ctx), because a plugin cannot run that engine itself; declare a " +
423
+ 'different action name',
424
+ });
425
+ continue;
426
+ }
427
+ survivors.push(entry);
428
+ }
429
+ return { survivors, failures };
430
+ }
431
+ /**
432
+ * Discover every installed plugin reachable from `repoRoot` (the consuming
433
+ * repo) and `cliPackageDir` (the CLI's own package directory, from
434
+ * {@link cliPackageDir} in `context.ts`). Never throws for a candidate-level
435
+ * problem - see the module comment - only for the two repo-level
436
+ * preconditions `collectCandidates` reads first.
437
+ */
438
+ export async function discover(repoRoot, cliPackageDir, ports) {
439
+ const candidates = await collectCandidates(ports, repoRoot, cliPackageDir);
440
+ const loaded = [];
441
+ const failures = [];
442
+ // A package named in both manifests appears twice, consumer entry first.
443
+ // Skip the second only when the first RESOLVED - to a plugin or to a failure -
444
+ // so a declared-but-unresolvable consumer entry still falls through to the
445
+ // CLI's bundled copy instead of silently suppressing it.
446
+ const resolved = new Set();
447
+ for (const candidate of candidates) {
448
+ if (resolved.has(candidate.packageName))
449
+ continue;
450
+ const outcome = await loadCandidate(candidate, ports);
451
+ if (outcome.kind === 'plugin') {
452
+ resolved.add(candidate.packageName);
453
+ loaded.push({
454
+ packageName: candidate.packageName,
455
+ packageJsonPath: outcome.packageJsonPath,
456
+ plugin: outcome.plugin,
457
+ });
458
+ }
459
+ else if (outcome.kind === 'failure') {
460
+ resolved.add(candidate.packageName);
461
+ failures.push(outcome.failure);
462
+ }
463
+ }
464
+ const initCollisions = rejectDeclaredInitCollisions(loaded);
465
+ const lifecycleCollisions = rejectDeclaredLifecycleCollisions(initCollisions.survivors);
466
+ const collisions = resolveNamespaceCollisions(lifecycleCollisions.survivors);
467
+ const configKeys = rejectDuplicateConfigKeys(collisions.installed);
468
+ failures.push(...initCollisions.failures, ...lifecycleCollisions.failures, ...collisions.failures, ...configKeys.failures);
469
+ // `plugins` is derived from `installed` here, at the single point both are
470
+ // built, so no later change can leave the two disagreeing about which
471
+ // plugins survived the collision passes.
472
+ const installed = configKeys.survivors;
473
+ return { plugins: installed.map((entry) => entry.plugin), installed, failures };
474
+ }
475
+ /**
476
+ * Resolve the config block ONE plugin owns into the value the dispatcher puts
477
+ * on `ctx.pluginConfig` - `runPlugin`'s single call, made for the plugin
478
+ * being DISPATCHED and no other (see the module comment's task-19 DECISION
479
+ * for why the scope is one plugin rather than every discovered one).
480
+ *
481
+ * The block is read off the RAW config document (`OpsContext.configDocument`,
482
+ * `context.ts`), never off `OpsConfig`, which has no index signature to reach
483
+ * a plugin's key through. `pluginBlock` returning `unknown` and the plugin's
484
+ * own `validateConfig` narrowing it is the sanctioned boundary: the very next
485
+ * step after the read validates it.
486
+ *
487
+ * The validator IS called when the plugin's key is ABSENT from the document,
488
+ * with `undefined`. That is the whole point of it: a validator is the only
489
+ * thing that can turn an absent block into the plugin's own defaults, and a
490
+ * repo that installs a plugin without writing its block is a valid,
491
+ * documented configuration. Handing `{}` straight through instead would put a
492
+ * block on `ctx.pluginConfig` that never went through the plugin's own
493
+ * defaulting - typed as total, `undefined` at runtime in every defaulted
494
+ * field - and nothing downstream could catch it, because the dispatcher
495
+ * erases `TConfig` (`Plugin<unknown>`, `PluginContext<unknown>`).
496
+ *
497
+ * `{}` is returned ONLY where there is no validator to call: a plugin that
498
+ * declares no `configKey` (a `Plugin<never>`, which cannot read
499
+ * `pluginConfig` at all) or no `validateConfig` - probed with `typeof ===
500
+ * 'function'`, the way this module and `plugin-commands.ts` both probe the
501
+ * `init` contributor, because core's `validatePlugin` type-checks neither
502
+ * member. `pluginConfig` is a required member, so `undefined` is not an
503
+ * option there - DEVELOPMENT.md's no-null rule.
504
+ *
505
+ * A validator's own rejection is re-raised with the plugin's name and the key
506
+ * in front of it and the plugin's message VERBATIM behind it, so an operator
507
+ * reading `blogwright analytics bootstrap`'s failure learns which plugin
508
+ * refused which key without the plugin having to name itself in every message
509
+ * it writes. It propagates - never swallowed, never downgraded to a warning -
510
+ * and exits non-zero through `bin.ts`'s error path.
511
+ */
512
+ export function resolvePluginConfig(plugin, configDocument) {
513
+ const { configKey } = plugin;
514
+ if (configKey === undefined || typeof plugin.validateConfig !== 'function')
515
+ return {};
516
+ const block = pluginBlock(configDocument, configKey);
517
+ try {
518
+ return plugin.validateConfig(block);
519
+ }
520
+ catch (err) {
521
+ throw new Error(`plugin "${plugin.name}" rejected the "${configKey}" config block: ${err.message}`, { cause: err });
522
+ }
523
+ }
package/dist/ports.d.ts CHANGED
@@ -11,15 +11,103 @@ export interface Vcs {
11
11
  /** List repository files as `cwd`-relative paths, honoring the VCS ignore rules. */
12
12
  listFiles(cwd: string): Promise<string[]>;
13
13
  }
14
+ /** Package managers `PackageManager.detect` can identify, from the lockfile each writes. */
15
+ export type PackageManagerName = 'pnpm' | 'npm' | 'yarn' | 'bun';
16
+ /**
17
+ * How to install a package, in the repo's own vocabulary - never a package
18
+ * manager's flag spelling (`--save-dev`, `-D`, ...).
19
+ */
20
+ export interface AddPackageOptions {
21
+ /** Install into devDependencies rather than dependencies. */
22
+ dev?: boolean;
23
+ /** Pin the exact resolved version rather than a semver range. */
24
+ exact?: boolean;
25
+ }
26
+ /**
27
+ * Installing and removing packages in the consuming repo. The real adapter
28
+ * detects which manager governs the repo from its lockfile and shells out to
29
+ * it; `add`/`remove` resolve that repo and manager themselves, so callers
30
+ * never pass a directory.
31
+ *
32
+ * Deliberately NOT a member of {@link Ports}. Its only two callers -
33
+ * `blogwright plugin add` and `plugin remove` - dispatch BEFORE any
34
+ * `OpsContext` exists, and must: `createContext` calls `sts.getAccountId()`,
35
+ * while installing a plugin is what an operator does on a repo that has no
36
+ * config and no credentials yet. A member of `Ports` is therefore unreachable
37
+ * from the only code that wants this port, and every `deploy`, `status` and
38
+ * `bootstrap` would construct an adapter none of them ever call. It is wired
39
+ * instead through `cli.ts`'s `PackageManagerFactory`, from `bin.ts`, which is
40
+ * still the composition root - so nothing about the port discipline is
41
+ * relaxed by its absence here.
42
+ */
43
+ export interface PackageManager {
44
+ /** Identify which manager governs `repoRoot`, from the lockfile it wrote there. */
45
+ detect(repoRoot: string): Promise<PackageManagerName>;
46
+ /** Install `spec` (a package name, optionally `name@version`) into the repo. */
47
+ add(spec: string, opts?: AddPackageOptions): Promise<void>;
48
+ /** Uninstall the named package from the repo. */
49
+ remove(name: string): Promise<void>;
50
+ }
14
51
  /**
15
52
  * Best-effort wake-up ping to a builder MicroVM's proxy endpoint. Implementations
16
- * never throw the connection attempt, not the response, is the point.
53
+ * never throw - the connection attempt, not the response, is the point.
17
54
  */
18
55
  export type PingBuilder = (endpoint: string, token: string) => Promise<void>;
56
+ /**
57
+ * The outcome of resolving a module specifier or a package.json path: never
58
+ * `string | null` for "not found" - absence is a variant of the type, not a
59
+ * sentinel value a caller could forget to check.
60
+ */
61
+ export type ModuleResolution = {
62
+ found: true;
63
+ path: string;
64
+ } | {
65
+ found: false;
66
+ };
67
+ /**
68
+ * Resolves and imports plugin packages - the only route from plugin discovery
69
+ * and dispatch to Node's module system. `fromDir` is a per-call argument
70
+ * rather than state fixed at construction, because discovery resolves the
71
+ * consumer's plugins from the repo root and the CLI's own bundled plugins
72
+ * from the CLI's package directory in the same run.
73
+ */
74
+ export interface ModuleLoader {
75
+ /** Resolve the bare specifier `specifier` to its entry-point file, as seen from `fromDir`. */
76
+ resolve(specifier: string, fromDir: string): Promise<ModuleResolution>;
77
+ /**
78
+ * Resolve `specifier`'s nearest `package.json` by resolving the bare
79
+ * specifier and walking up from the resolved entry file - **never** by
80
+ * resolving `<specifier>/package.json` directly. `require.resolve` of that
81
+ * subpath throws `ERR_PACKAGE_PATH_NOT_EXPORTED` for every published
82
+ * package in this repo, because their `exports` maps do not list
83
+ * `./package.json` (verified 2026-07-26 against `blogwright-pds`, whose
84
+ * `exports` map lists only `.` and `./rkey`). See
85
+ * `adapters/node-module-loader.ts` for the walk-up implementation and a
86
+ * side-by-side of both resolution strategies.
87
+ *
88
+ * The walk stops at the nearest `package.json` **that carries a `name`**,
89
+ * not merely the nearest one on disk: a package published with the
90
+ * standard dual-package layout (`exports: {".": "./dist/index.js"}` plus a
91
+ * `dist/package.json` of `{"type": "module"}`) has a nearer, name-less stub
92
+ * that would otherwise be mistaken for the manifest, making a valid plugin
93
+ * silently invisible to discovery.
94
+ *
95
+ * Limit: `blogwright` itself cannot be reached this way either - its own
96
+ * `exports` map has no `.` entry (only `./rkey`; the CLI is consumed
97
+ * through its `bin`, not imported), so resolving the bare specifier
98
+ * `blogwright` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` too. That is why the
99
+ * CLI locates its own package directory from `import.meta.url` rather than
100
+ * through this port.
101
+ */
102
+ packageJsonPathFor(specifier: string, fromDir: string): Promise<ModuleResolution>;
103
+ /** Import the module at `path`. The caller validates the result's shape at the boundary. */
104
+ load(path: string): Promise<unknown>;
105
+ }
19
106
  /** The ports domain code reaches side effects through; adapters are wired in createContext. */
20
107
  export interface Ports {
21
108
  fs: FileSystem;
22
109
  vcs: Vcs;
23
110
  terminal: Terminal;
24
111
  ping: PingBuilder;
112
+ loader: ModuleLoader;
25
113
  }
package/dist/ports.js CHANGED
@@ -4,4 +4,3 @@
4
4
  * constructed only at the composition root (context.ts).
5
5
  */
6
6
  export {};
7
- //# sourceMappingURL=ports.js.map