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.
- package/README.md +11 -11
- package/agent/agent-manifest.json +1 -1
- package/agent/server.js +37 -19
- package/dist/adapters/fetch-ping.d.ts +1 -1
- package/dist/adapters/fetch-ping.js +3 -4
- package/dist/adapters/node-module-loader.d.ts +11 -0
- package/dist/adapters/node-module-loader.js +146 -0
- package/dist/adapters/process-package-manager.d.ts +41 -0
- package/dist/adapters/process-package-manager.js +116 -0
- package/dist/adapters/process-vcs.d.ts +5 -4
- package/dist/adapters/process-vcs.js +6 -6
- package/dist/agent-package.d.ts +1 -1
- package/dist/agent-package.js +4 -4
- package/dist/bin.js +13 -3
- package/dist/cli.d.ts +68 -1
- package/dist/cli.js +270 -89
- package/dist/commands.d.ts +70 -3
- package/dist/commands.js +180 -32
- package/dist/config-block.d.ts +34 -0
- package/dist/config-block.js +262 -0
- package/dist/context.d.ts +76 -6
- package/dist/context.js +98 -19
- package/dist/deploy.d.ts +2 -2
- package/dist/deploy.js +14 -14
- package/dist/graph.d.ts +27 -16
- package/dist/graph.js +1 -2
- package/dist/init.d.ts +37 -3
- package/dist/init.js +146 -23
- package/dist/known-commands.d.ts +63 -0
- package/dist/known-commands.js +78 -0
- package/dist/logger.js +0 -1
- package/dist/microvms.d.ts +2 -2
- package/dist/microvms.js +3 -4
- package/dist/nodes.d.ts +5 -3
- package/dist/nodes.js +97 -31
- package/dist/plugin-commands.d.ts +298 -0
- package/dist/plugin-commands.js +990 -0
- package/dist/plugins.d.ts +194 -0
- package/dist/plugins.js +523 -0
- package/dist/ports.d.ts +89 -1
- package/dist/ports.js +0 -1
- package/dist/render.d.ts +55 -0
- package/dist/render.js +89 -2
- package/dist/repo.d.ts +3 -3
- package/dist/repo.js +8 -9
- package/dist/rkey.js +0 -1
- package/dist/seo.d.ts +1 -1
- package/dist/seo.js +1 -2
- package/package.json +6 -6
- package/dist/adapters/fetch-ping.js.map +0 -1
- package/dist/adapters/process-vcs.js.map +0 -1
- package/dist/agent-package.js.map +0 -1
- package/dist/bin.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/commands.js.map +0 -1
- package/dist/context.js.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/graph.js.map +0 -1
- package/dist/init.js.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/microvms.js.map +0 -1
- package/dist/nodes.js.map +0 -1
- package/dist/ports.js.map +0 -1
- package/dist/render.js.map +0 -1
- package/dist/repo.js.map +0 -1
- package/dist/rkey.js.map +0 -1
- package/dist/seo.js.map +0 -1
- package/dist/test-support.d.ts +0 -45
- package/dist/test-support.js +0 -126
- package/dist/test-support.js.map +0 -1
package/dist/plugins.js
ADDED
|
@@ -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
|
|
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