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
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OWNERSHIP: this module and its test file (`plugin-commands.test.ts`) are
|
|
3
|
+
* created here, by task 10, which is their sole author for the whole plan.
|
|
4
|
+
* Tasks 13 (the generic `blogwright <plugin> init` action), 16 (the generic
|
|
5
|
+
* `bootstrap`/`status`/`destroy` lifecycle verbs) and 17 (`blogwright plugin
|
|
6
|
+
* list`) all extend the SAME dispatch surface and the SAME test file rather
|
|
7
|
+
* than re-creating either - on the plan's dependency graph none of the three
|
|
8
|
+
* later tasks depends on either of the other two, so without one named
|
|
9
|
+
* owner here, three tasks would each try to create these two files from
|
|
10
|
+
* scratch and collide. Extend this module; never recreate it.
|
|
11
|
+
*
|
|
12
|
+
* `runPlugin` is generic dispatch for `blogwright <plugin> <action>`: the
|
|
13
|
+
* fall-through `cli.ts` reaches once the first positional is neither a
|
|
14
|
+
* built-in command nor `plugin` itself (`KNOWN_COMMANDS`,
|
|
15
|
+
* `known-commands.ts`). It:
|
|
16
|
+
*
|
|
17
|
+
* 1. Runs `discover` (over the `fs`/`loader` ports the caller supplies -
|
|
18
|
+
* see `runPlugin`'s `ports` parameter) to find the installed plugin
|
|
19
|
+
* claiming `command` as its namespace.
|
|
20
|
+
* 2. Matches the LONGEST declared action against the leading positionals,
|
|
21
|
+
* so a multi-word action such as `secret status` dispatches by
|
|
22
|
+
* declaration - never by hand-rolled positional shifting. `cli.ts`'s
|
|
23
|
+
* `runPds` branch shifted positionals to reach `secret status` and
|
|
24
|
+
* `secret delete`; task 29 deleted it, and this step is now the only
|
|
25
|
+
* thing that resolves a multi-word action anywhere in the CLI.
|
|
26
|
+
* 3. Resolves the environment exactly the way every built-in command
|
|
27
|
+
* already does: the first positional left over once the action is
|
|
28
|
+
* consumed, overridden by `--env`, defaulting to `production`.
|
|
29
|
+
* 4. Builds the ONE `OpsContext` this dispatch needs, now that the real
|
|
30
|
+
* environment is confirmed, validates the matched plugin's OWN config
|
|
31
|
+
* block off that context's raw `configDocument` (`resolvePluginConfig`,
|
|
32
|
+
* `plugins.ts` - the dispatched plugin's block and no other), adapts
|
|
33
|
+
* the context into the narrow `PluginContext` the SPI promises
|
|
34
|
+
* (`toPluginContext`, below) with the validated block on
|
|
35
|
+
* `pluginConfig`, and runs the matched command, mapping a normal return
|
|
36
|
+
* to exit code 0.
|
|
37
|
+
*
|
|
38
|
+
* Steps 1-3 run BEFORE any `OpsContext` is built - see `runPlugin`'s own
|
|
39
|
+
* doc comment for why an earlier, provisional-context version of this
|
|
40
|
+
* function was wrong, not merely wasteful.
|
|
41
|
+
*
|
|
42
|
+
* TASK 13 - the generic `init` action, and PRECEDENCE. Step 2 above matches
|
|
43
|
+
* a plugin's own `commands` FIRST; the generic `init` action (`runGenericInit`,
|
|
44
|
+
* below) is only ever reached once that match has already failed AND the
|
|
45
|
+
* leading word is exactly `init`. This is the whole of §CLI → `blogwright
|
|
46
|
+
* <plugin> init`'s precedence rule: a plugin declaring its own `init`
|
|
47
|
+
* command owns whatever `blogwright <plugin> init` does, full stop - pds's
|
|
48
|
+
* `init` creates the standard.site publication record
|
|
49
|
+
* (`packages/pds/src/commands.ts:118`) and writes no config block at all,
|
|
50
|
+
* and it must never be shadowed by a generic config writer. Nothing here
|
|
51
|
+
* requires a declared `init` command to write config; the generic action
|
|
52
|
+
* applies only where NO `init` command is declared. The other half of the
|
|
53
|
+
* rule - a plugin may not declare BOTH an `init` command and an `init?(io)`
|
|
54
|
+
* contributor, because the contributor would then never run - is a
|
|
55
|
+
* discovery-time rejection in `plugins.ts`'s collision pass, not something
|
|
56
|
+
* this dispatcher has to account for: by the time a plugin reaches here it
|
|
57
|
+
* has at most one of the two.
|
|
58
|
+
*
|
|
59
|
+
* The generic action itself needs none of the AWS-reaching machinery
|
|
60
|
+
* `makeContext` builds (accountId, clients, state) - only the two ports a
|
|
61
|
+
* plugin's `init?(io)` contributor is typed against (`fs`, `terminal`) and
|
|
62
|
+
* the resolved environment/repo root `runPlugin` already has before any
|
|
63
|
+
* `OpsContext` exists. Building a real context just to splice a text file
|
|
64
|
+
* would additionally require a runnable AWS session before an operator has
|
|
65
|
+
* even finished being asked their plugin's questions - so it deliberately
|
|
66
|
+
* does not.
|
|
67
|
+
*
|
|
68
|
+
* TASK 16 - the generic `bootstrap`/`status`/`destroy` lifecycle verbs, and
|
|
69
|
+
* PRECEDENCE. Like `init`, these three are only ever reached once step 2's
|
|
70
|
+
* `matchAction` has already failed to match the plugin's own `commands` -
|
|
71
|
+
* but the precedence differs by verb:
|
|
72
|
+
*
|
|
73
|
+
* - `bootstrap` and `destroy` are ALWAYS the generic verbs. A plugin may
|
|
74
|
+
* not import the CLI (§CLI → Plugin dispatch), and so cannot run the
|
|
75
|
+
* engine (`applyGraph`/`destroyGraph`, `graph.ts`) itself - there is no
|
|
76
|
+
* way for a plugin's own `bootstrap`/`destroy` command to do what these
|
|
77
|
+
* verbs need to do. A plugin declaring either as one of its own
|
|
78
|
+
* `commands` is therefore rejected at discovery, naming the plugin and
|
|
79
|
+
* the colliding action - `plugins.ts`'s `rejectDeclaredLifecycleCollisions`,
|
|
80
|
+
* beside `rejectDeclaredInitCollisions` in the same collision pass (see
|
|
81
|
+
* that module's DECISION note) - so `matchAction` never has a real
|
|
82
|
+
* `bootstrap`/`destroy` command to match against in the first place.
|
|
83
|
+
* - `status` is the generic verb ONLY UNLESS the plugin declares its own -
|
|
84
|
+
* `read()` lives on the plugin's own nodes, no engine call is needed,
|
|
85
|
+
* so there is nothing stopping a plugin from implementing `status`
|
|
86
|
+
* itself (pds's `secret status` is a precedent for a plugin owning its
|
|
87
|
+
* own status reporting). A declared `status` command is therefore left
|
|
88
|
+
* alone: `matchAction` already matches it in step 2, and the generic
|
|
89
|
+
* verb below is never reached for that plugin.
|
|
90
|
+
*
|
|
91
|
+
* All three are further gated on `plugin.nodes` being declared at all
|
|
92
|
+
* (`genericLifecycleCommand`, below): a plugin with no `nodes` contributor
|
|
93
|
+
* gains none of the three, and asking for one falls through to the same
|
|
94
|
+
* unknown-action refusal every other unmatched action gets - it is not a
|
|
95
|
+
* special case, because `genericLifecycleCommand` returns `undefined` for
|
|
96
|
+
* exactly that plugin, the same way an undeclared `init` contributor leaves
|
|
97
|
+
* `runGenericInit` unreached above.
|
|
98
|
+
*
|
|
99
|
+
* Each of the three runs the CLI's own engine - `applyGraph`, `destroyGraph`
|
|
100
|
+
* (`graph.ts`) and `readNodeStatus` (`commands.ts`) - over `plugin.nodes(ctx)`
|
|
101
|
+
* against a context built by `toPluginContext` (below), which by this task
|
|
102
|
+
* re-points `store`/`state`/`save()` at a `StateStore` scoped to the
|
|
103
|
+
* plugin's own name (`state/<env>.<plugin>.json`) rather than the site's.
|
|
104
|
+
* `destroy` additionally refuses without `--yes`, mirroring the site verb's
|
|
105
|
+
* own contract (`commands.ts`'s `destroy`), and deletes the scoped state
|
|
106
|
+
* object itself once `destroyGraph` has torn down every node - mirroring
|
|
107
|
+
* `commands.ts`'s own `destroy`/`previewTeardown`, both of which call
|
|
108
|
+
* `ctx.store.delete()` right after `destroyGraph`.
|
|
109
|
+
*
|
|
110
|
+
* TASK 17 - the built-in `plugin` namespace (`runPluginNamespace`, at the
|
|
111
|
+
* foot of this module) is a SECOND entry point, not an action of the generic
|
|
112
|
+
* dispatch above: `plugin` is reserved (`known-commands.ts`), so no installed
|
|
113
|
+
* plugin can claim it and `runPlugin` never sees it. It lives here because it
|
|
114
|
+
* is the same surface - the actions an operator types after `blogwright` -
|
|
115
|
+
* and shares this module's discovery call and its unknown-action refusal
|
|
116
|
+
* shape. See its own section comment for why `cli.ts` must dispatch it before
|
|
117
|
+
* `createContext`.
|
|
118
|
+
*/
|
|
119
|
+
import { join } from 'node:path';
|
|
120
|
+
import { colors, findRepoRoot, parseConfig, StateStore, validatePlugin, } from 'blogwright-core';
|
|
121
|
+
import { readNodeStatus } from './commands.js';
|
|
122
|
+
import { renderConfigBlock, spliceConfigBlock } from './config-block.js';
|
|
123
|
+
import { cliPackageDir, resolveConfigPath, } from './context.js';
|
|
124
|
+
import { applyGraph, destroyGraph } from './graph.js';
|
|
125
|
+
import { ask } from './init.js';
|
|
126
|
+
import { confirm } from './logger.js';
|
|
127
|
+
import { discover, resolvePluginConfig } from './plugins.js';
|
|
128
|
+
import { logStatusEntries, renderPluginList } from './render.js';
|
|
129
|
+
/** The default environment every built-in command falls back to. */
|
|
130
|
+
// Also the default `--env` in `cli.ts`'s option table; kept here because the
|
|
131
|
+
// dispatcher is the only place that resolves an environment from positionals.
|
|
132
|
+
const DEFAULT_ENV = 'production';
|
|
133
|
+
/**
|
|
134
|
+
* Flags forwarded into a plugin action's `args`, in this fixed order so the
|
|
135
|
+
* rendered array is deterministic regardless of property-iteration order.
|
|
136
|
+
* `env` is excluded - it is consumed as the environment override, never
|
|
137
|
+
* forwarded as a data flag; `plain` and `help` are session-level concerns of
|
|
138
|
+
* `main` itself, never a plugin action's business.
|
|
139
|
+
*/
|
|
140
|
+
const FORWARDED_FLAGS = [
|
|
141
|
+
'domain',
|
|
142
|
+
'config',
|
|
143
|
+
'endpoint',
|
|
144
|
+
'hash',
|
|
145
|
+
'id',
|
|
146
|
+
'identifier',
|
|
147
|
+
'refresh',
|
|
148
|
+
'yes',
|
|
149
|
+
];
|
|
150
|
+
/**
|
|
151
|
+
* Render the flags a plugin action should see as plain string tokens - the
|
|
152
|
+
* same shape `args` already carries positionals in - so a plugin's own
|
|
153
|
+
* `run(ctx, args)` reads `--identifier alice.example` or `--yes` out of one
|
|
154
|
+
* flat array rather than a second, bespoke channel. A boolean flag renders
|
|
155
|
+
* as its bare `--name` only when true; a string flag renders as `--name
|
|
156
|
+
* value` only when set. Nothing is rendered for a flag left at its default.
|
|
157
|
+
*/
|
|
158
|
+
function serialiseFlags(values) {
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const flag of FORWARDED_FLAGS) {
|
|
161
|
+
const value = values[flag];
|
|
162
|
+
if (value === undefined || value === false)
|
|
163
|
+
continue;
|
|
164
|
+
out.push(`--${flag}`);
|
|
165
|
+
if (typeof value === 'string')
|
|
166
|
+
out.push(value);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Match the LONGEST declared action against the leading words of `rest`, so
|
|
172
|
+
* a multi-word action such as `secret status` is matched as one unit rather
|
|
173
|
+
* than by shifting a fixed number of positionals (the approach this
|
|
174
|
+
* function replaces: the hand-rolled `secret` shift in `cli.ts`'s `runPds`
|
|
175
|
+
* branch, deleted by task 29). Two commands sharing a declared action name
|
|
176
|
+
* is a `validatePlugin` violation, so no plugin ever reaches here with a
|
|
177
|
+
* genuine tie; the first strictly-longer match found wins regardless of
|
|
178
|
+
* declaration order, which is what makes `secret status` win over a bare
|
|
179
|
+
* `secret` when a plugin declares both.
|
|
180
|
+
*/
|
|
181
|
+
function matchAction(commands, rest) {
|
|
182
|
+
let best;
|
|
183
|
+
for (const command of commands) {
|
|
184
|
+
const words = command.action.split(' ');
|
|
185
|
+
if (best && words.length <= best.wordCount)
|
|
186
|
+
continue;
|
|
187
|
+
if (words.length > rest.length)
|
|
188
|
+
continue;
|
|
189
|
+
if (words.every((word, i) => rest[i] === word)) {
|
|
190
|
+
best = { command, wordCount: words.length };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return best;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Render a plugin's available actions, one per line, for an unknown-action
|
|
197
|
+
* refusal.
|
|
198
|
+
*
|
|
199
|
+
* Includes the generic `init` when the plugin contributes one, and the
|
|
200
|
+
* generic `bootstrap`/`status`/`destroy` lifecycle verbs when it
|
|
201
|
+
* contributes `nodes` ({@link genericLifecycleActions}), because a plugin
|
|
202
|
+
* can declare NO commands at all and still answer four of them: listing
|
|
203
|
+
* `plugin.commands` alone printed `"demo" actions:` and then nothing
|
|
204
|
+
* whatsoever for a nodes-only plugin, while `blogwright demo bootstrap`
|
|
205
|
+
* worked perfectly well. A refusal that tells an operator the plugin has no
|
|
206
|
+
* actions, when it has some, is worse than no refusal.
|
|
207
|
+
*/
|
|
208
|
+
function renderActions(plugin) {
|
|
209
|
+
const declared = plugin.commands.map((command) => ` ${command.action} - ${command.summary}`);
|
|
210
|
+
const init = typeof plugin.init === 'function'
|
|
211
|
+
? [
|
|
212
|
+
` ${GENERIC_INIT_ACTION} - write this plugin's config block into the environment's config file`,
|
|
213
|
+
]
|
|
214
|
+
: [];
|
|
215
|
+
const lifecycle = genericLifecycleActions(plugin).map((command) => ` ${command.action} - ${command.summary}`);
|
|
216
|
+
return [`"${plugin.name}" actions:`, ...declared, ...init, ...lifecycle].join('\n');
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Adapt an `OpsContext` into the narrow `PluginContext` a plugin command
|
|
220
|
+
* runs against. This is an ADAPTATION, not an assignment: an `OpsContext`
|
|
221
|
+
* carries thirteen of `PluginContext`'s sixteen members and none of
|
|
222
|
+
* `pluginConfig`, `siteState` or `record`, so a bare assignment is
|
|
223
|
+
* `TS2739`. This function supplies exactly those three - plus the
|
|
224
|
+
* two-member `ports` `PluginPorts` narrows the CLI's six-member `Ports`
|
|
225
|
+
* to, and the plugin's own scoped `store`/`state`/`save()` (below) - and
|
|
226
|
+
* passes every other member through unchanged. No cast, no `any`, anywhere
|
|
227
|
+
* in it.
|
|
228
|
+
*
|
|
229
|
+
* `pluginConfig` is supplied by the CALLER rather than read here, because
|
|
230
|
+
* this function is handed a plugin's NAME and not the plugin: `runPlugin`
|
|
231
|
+
* calls `resolvePluginConfig(plugin, ctx.configDocument)` (`plugins.ts`)
|
|
232
|
+
* first, so an invalid block fails BEFORE the scoped `store.load()` below
|
|
233
|
+
* makes the dispatch's first AWS call and long before the command does any
|
|
234
|
+
* work. The parameter is required, with no `{}` default: the dispatcher
|
|
235
|
+
* erases `TConfig` (it dispatches `Plugin<unknown>` and returns
|
|
236
|
+
* `PluginContext<unknown>`), so a forgotten argument could not be caught
|
|
237
|
+
* anywhere downstream - `pnpm typecheck` catching it here is the only
|
|
238
|
+
* check there is.
|
|
239
|
+
*
|
|
240
|
+
* `siteState` is `ops.state` passed through as the read-only view the SPI
|
|
241
|
+
* promises - a plugin reads the site's own recorded outputs through it (the
|
|
242
|
+
* analytics log-delivery node reads the site's CloudFront distribution
|
|
243
|
+
* through it), but never writes it. It is deliberately NOT the scoped load
|
|
244
|
+
* below: overwriting it would leave a plugin unable to see the site's own
|
|
245
|
+
* outputs at all.
|
|
246
|
+
*
|
|
247
|
+
* `store`, `state` and `save()` are the ONE thing this function gets that a
|
|
248
|
+
* bare assignment from `OpsContext` would not: a `StateStore` scoped to
|
|
249
|
+
* `pluginName` (`state/<env>.<pluginName>.json`, `StateStore`'s fourth
|
|
250
|
+
* constructor argument - `packages/core/src/state.ts`), its own freshly
|
|
251
|
+
* loaded `OpsState`, and a `save()` that persists THAT state through THAT
|
|
252
|
+
* store - never the site's own `state/<env>.json`. This is why the function
|
|
253
|
+
* is `async` where a straight field-for-field adaptation would not need to
|
|
254
|
+
* be: building the plugin's own `state` requires awaiting the scoped
|
|
255
|
+
* store's `load()`. Before this existed (tasks 10-15), `OpsContext`'s
|
|
256
|
+
* `store`/`state`/`save()` typechecked straight through as `PluginContext`'s
|
|
257
|
+
* of the same names with no error - the TYPES lined up even though the
|
|
258
|
+
* STORAGE did not - which is why nothing before this task may call a
|
|
259
|
+
* plugin's `nodes(ctx)`: doing so would have silently recorded a plugin's
|
|
260
|
+
* resources into the site's own state document instead of its own, and
|
|
261
|
+
* `record`, below, closes exactly that gap by writing into the scoped
|
|
262
|
+
* `state.resources` rather than the site's.
|
|
263
|
+
*/
|
|
264
|
+
export async function toPluginContext(ops, pluginName, pluginConfig) {
|
|
265
|
+
const store = new StateStore(ops.clients.s3, ops.names.bucket, ops.env, pluginName);
|
|
266
|
+
const state = await store.load();
|
|
267
|
+
return {
|
|
268
|
+
env: ops.env,
|
|
269
|
+
domain: ops.domain,
|
|
270
|
+
preview: ops.preview,
|
|
271
|
+
config: ops.config,
|
|
272
|
+
pluginConfig,
|
|
273
|
+
names: ops.names,
|
|
274
|
+
accountId: ops.accountId,
|
|
275
|
+
clients: ops.clients,
|
|
276
|
+
ports: { fs: ops.ports.fs, terminal: ops.ports.terminal },
|
|
277
|
+
tags: ops.tags,
|
|
278
|
+
logger: ops.logger,
|
|
279
|
+
store,
|
|
280
|
+
state,
|
|
281
|
+
siteState: ops.state,
|
|
282
|
+
record: (nodeId, outputs) => {
|
|
283
|
+
state.resources[nodeId] = outputs;
|
|
284
|
+
},
|
|
285
|
+
save: async () => {
|
|
286
|
+
await store.save(state);
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The one action name `matchAction` failing to match against a plugin's own
|
|
292
|
+
* `commands` falls through to the generic writer for - see `runGenericInit`
|
|
293
|
+
* and the PRECEDENCE section of this module's own doc comment. Kept as a
|
|
294
|
+
* named constant, mirrored (not imported - see that module's own DECISION
|
|
295
|
+
* note) by `plugins.ts`'s discovery-time collision check, so both reads of
|
|
296
|
+
* "the generic init action" spell it the same way without a cross-module
|
|
297
|
+
* dependency neither side needs otherwise.
|
|
298
|
+
*/
|
|
299
|
+
const GENERIC_INIT_ACTION = 'init';
|
|
300
|
+
/**
|
|
301
|
+
* Build the `io` an `init?(io)` contributor asks its own questions through,
|
|
302
|
+
* entirely over the `Terminal` port - never `node:readline` directly, which
|
|
303
|
+
* `.oxlintrc.json`'s `no-restricted-imports` enforces for this file (it
|
|
304
|
+
* carries no override, unlike `init.ts`/`bin.ts`/the adapters). Every prompt
|
|
305
|
+
* crosses `init.ts`'s exported `ask`, the SAME prompt/validate/retry loop
|
|
306
|
+
* `blogwright init`'s own four questions use, so a plugin's contributor
|
|
307
|
+
* never has to write its own. `ask` resolves `undefined` for an unanswered
|
|
308
|
+
* optional question; `PluginInitIo.ask` promises a `string` always, per the
|
|
309
|
+
* no-null rule, so the empty string stands in for "declined" here.
|
|
310
|
+
*/
|
|
311
|
+
function buildInitIo(terminal, logger) {
|
|
312
|
+
return {
|
|
313
|
+
isInteractive: terminal.isInteractive,
|
|
314
|
+
logger,
|
|
315
|
+
ask: async (question) => (await ask(terminal, logger, question)) ?? '',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Run the generic `blogwright <plugin> init` action: ask `contributor`'s
|
|
320
|
+
* questions over the `Terminal` port, render what it returns, and splice it
|
|
321
|
+
* into exactly the file `loadConfig` (`context.ts`) would read for `env` -
|
|
322
|
+
* `resolveConfigPath` is the SAME candidate resolution `loadConfig` calls,
|
|
323
|
+
* not a second string built here. Reached only once `matchAction` has
|
|
324
|
+
* already failed to match the plugin's own `commands` and the leading word
|
|
325
|
+
* is `init` - see this module's PRECEDENCE documentation above.
|
|
326
|
+
*
|
|
327
|
+
* The splice's own errors (an existing key, a document that is not a single
|
|
328
|
+
* top-level object) propagate unchanged - this function adds nothing to
|
|
329
|
+
* them - and `fs.writeText` runs only once the splice has already returned,
|
|
330
|
+
* so a refused splice leaves the file byte-for-byte what it was.
|
|
331
|
+
*/
|
|
332
|
+
async function runGenericInit(plugin, contributor, repoRoot, afterAction, values, terminal, logger, fs) {
|
|
333
|
+
const configKey = plugin.configKey;
|
|
334
|
+
if (!configKey) {
|
|
335
|
+
// A plugin authoring bug, not an operator refusal: `init?(io)` exists to
|
|
336
|
+
// fill in a `configKey`'s block, so a contributor with nowhere to file
|
|
337
|
+
// its answers is unsatisfiable in the same way a declared `init` command
|
|
338
|
+
// paired with a contributor is - just not one `plugins.ts`'s discovery
|
|
339
|
+
// pass can catch ahead of time, since `configKey` says nothing about
|
|
340
|
+
// whether `init` is also declared.
|
|
341
|
+
throw new Error(`plugin "${plugin.name}" declares an init(io) contributor but no configKey - there is ` +
|
|
342
|
+
'nothing to file its answered block under');
|
|
343
|
+
}
|
|
344
|
+
const env = values.env ?? afterAction[0] ?? DEFAULT_ENV;
|
|
345
|
+
const path = await resolveConfigPath(fs, { env, root: repoRoot, configPath: values.config });
|
|
346
|
+
const entries = await contributor(buildInitIo(terminal, logger));
|
|
347
|
+
if (entries.length === 0) {
|
|
348
|
+
logger.info(`${plugin.name}: no questions answered - nothing written to ${path}`);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
const text = await fs.readText(path);
|
|
352
|
+
const rendered = renderConfigBlock(configKey, entries.map((entry) => ({ prop: entry.property, comment: entry.comment })));
|
|
353
|
+
const spliced = spliceConfigBlock({ path, text }, { key: configKey, rendered });
|
|
354
|
+
// Re-parsed before it is trusted onto disk: a bug in the splice itself
|
|
355
|
+
// must never reach the operator as an unloadable config file, which is the
|
|
356
|
+
// one thing this whole feature promises never to do.
|
|
357
|
+
parseConfig(spliced);
|
|
358
|
+
await fs.writeText(path, spliced);
|
|
359
|
+
logger.ok(`wrote "${configKey}" into ${path}`);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The three action names that are always generic UNLESS gated out - see
|
|
364
|
+
* this module's TASK 16 PRECEDENCE section. Kept as a named constant, the
|
|
365
|
+
* same way `GENERIC_INIT_ACTION` is, though nothing outside this module
|
|
366
|
+
* needs to spell any of the three: `plugins.ts`'s `rejectDeclaredLifecycleCollisions`
|
|
367
|
+
* (which cares about two of them, never `status`) keeps its own literal set
|
|
368
|
+
* rather than importing this one, for the same reason `GENERIC_INIT_ACTION`
|
|
369
|
+
* is mirrored rather than shared - see that module's own DECISION note.
|
|
370
|
+
*
|
|
371
|
+
* The map carries each verb's SUMMARY beside its name because two places
|
|
372
|
+
* need it and they must not drift: `genericLifecycleCommand` (below) hands
|
|
373
|
+
* it to the synthetic `PluginCommand` it dispatches, and
|
|
374
|
+
* {@link genericLifecycleActions} hands the same string to both listings
|
|
375
|
+
* that advertise the verb (`renderActions` here, `renderPluginSection` in
|
|
376
|
+
* `cli.ts`). A verb whose listed summary disagreed with the one it
|
|
377
|
+
* dispatches under would be its own small lie.
|
|
378
|
+
*/
|
|
379
|
+
const GENERIC_LIFECYCLE_ACTIONS = new Map([
|
|
380
|
+
['bootstrap', "reconcile this plugin's resources"],
|
|
381
|
+
['status', "show this plugin's resource status"],
|
|
382
|
+
['destroy', "tear down this plugin's resources"],
|
|
383
|
+
]);
|
|
384
|
+
/** `--yes` rendered by `serialiseFlags`, above - what `genericLifecycleCommand`'s `destroy` reads back out of `args` to decide whether to refuse. */
|
|
385
|
+
const YES_FLAG = '--yes';
|
|
386
|
+
/**
|
|
387
|
+
* Reconcile `plugin.nodes(ctx)` with the CLI's own engine - `applyGraph`
|
|
388
|
+
* (`graph.ts`) - against the plugin's own scoped state. Mirrors
|
|
389
|
+
* `commands.ts`'s own `bootstrap`, one context type narrower.
|
|
390
|
+
*/
|
|
391
|
+
async function runGenericBootstrap(plugin, nodesOf, ctx) {
|
|
392
|
+
ctx.logger.info(colors.bold(`Bootstrapping "${plugin.name}" for "${ctx.env}"`));
|
|
393
|
+
await applyGraph(nodesOf(ctx), ctx);
|
|
394
|
+
ctx.logger.ok(`bootstrap complete for "${plugin.name}" in "${ctx.env}"`);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Read `plugin.nodes(ctx)`'s live status via `commands.ts`'s `readNodeStatus`
|
|
398
|
+
* - the same read loop the CLI's own `status` command runs - and render it
|
|
399
|
+
* through the same interactive/plain branch (`render.ts`'s `logStatusEntries`,
|
|
400
|
+
* shared with `commands.ts`'s `status` so neither carries its own copy of
|
|
401
|
+
* that branch).
|
|
402
|
+
*/
|
|
403
|
+
async function runGenericStatus(plugin, nodesOf, ctx) {
|
|
404
|
+
ctx.logger.info(colors.bold(`Status for "${plugin.name}" in "${ctx.env}"`));
|
|
405
|
+
const entries = await readNodeStatus(nodesOf(ctx), ctx);
|
|
406
|
+
logStatusEntries(entries, ctx.ports.terminal.isInteractive, ctx.logger);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Tear down `plugin.nodes(ctx)` via the CLI's own engine - `destroyGraph`
|
|
410
|
+
* (`graph.ts`) - then delete the plugin's own scoped state object, mirroring
|
|
411
|
+
* `commands.ts`'s own `destroy`/`previewTeardown` (both call
|
|
412
|
+
* `ctx.store.delete()` right after `destroyGraph`). Refuses without `--yes`,
|
|
413
|
+
* the same contract `commands.ts`'s own `destroy` raises
|
|
414
|
+
* (`refusing to destroy "<env>" without --yes`), naming the plugin too so
|
|
415
|
+
* the refusal is unambiguous about which teardown was refused.
|
|
416
|
+
*/
|
|
417
|
+
async function runGenericDestroy(plugin, nodesOf, ctx, yes) {
|
|
418
|
+
if (!yes) {
|
|
419
|
+
throw new Error(`refusing to destroy "${plugin.name}" in "${ctx.env}" without --yes`);
|
|
420
|
+
}
|
|
421
|
+
ctx.logger.info(colors.bold(`Destroying "${plugin.name}" in "${ctx.env}"`));
|
|
422
|
+
await destroyGraph(nodesOf(ctx), ctx);
|
|
423
|
+
await ctx.store.delete();
|
|
424
|
+
ctx.logger.ok(`destroyed "${plugin.name}" in "${ctx.env}"`);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Build a synthetic `PluginCommand` for one of the three generic lifecycle
|
|
428
|
+
* actions, so `runPlugin` can hand it to the exact same dispatch plumbing
|
|
429
|
+
* (env resolution, context build, `command.run(ctx, args)`) a plugin's own
|
|
430
|
+
* declared commands go through, rather than duplicating that plumbing for a
|
|
431
|
+
* second time here. `undefined` when `action` is not one of the three
|
|
432
|
+
* ({@link GENERIC_LIFECYCLE_ACTIONS}), or when `plugin` declares no `nodes`
|
|
433
|
+
* contributor at all - the single gate all three verbs share, per this
|
|
434
|
+
* module's TASK 16 PRECEDENCE section - so `runPlugin` needs no separate
|
|
435
|
+
* check for either case: both fall straight through to the ordinary
|
|
436
|
+
* unknown-action refusal.
|
|
437
|
+
*/
|
|
438
|
+
function genericLifecycleCommand(plugin, action) {
|
|
439
|
+
const nodesOf = plugin.nodes;
|
|
440
|
+
if (!nodesOf || action === undefined)
|
|
441
|
+
return undefined;
|
|
442
|
+
const summary = GENERIC_LIFECYCLE_ACTIONS.get(action);
|
|
443
|
+
if (summary === undefined)
|
|
444
|
+
return undefined;
|
|
445
|
+
switch (action) {
|
|
446
|
+
case 'bootstrap':
|
|
447
|
+
return { action, summary, run: async (ctx) => runGenericBootstrap(plugin, nodesOf, ctx) };
|
|
448
|
+
case 'status':
|
|
449
|
+
return { action, summary, run: async (ctx) => runGenericStatus(plugin, nodesOf, ctx) };
|
|
450
|
+
default: // 'destroy' - GENERIC_LIFECYCLE_ACTIONS has exactly these three members.
|
|
451
|
+
return {
|
|
452
|
+
action,
|
|
453
|
+
summary,
|
|
454
|
+
run: async (ctx, args) => runGenericDestroy(plugin, nodesOf, ctx, args.includes(YES_FLAG)),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The generic lifecycle verbs `plugin` actually answers, as `{ action,
|
|
460
|
+
* summary }` pairs, for the two places that LIST a plugin's actions: the
|
|
461
|
+
* unknown-action refusal ({@link renderActions}, below) and `--help`
|
|
462
|
+
* (`cli.ts`'s `renderPluginSection`). Exported for the second of those;
|
|
463
|
+
* both must list exactly what {@link genericLifecycleCommand} would
|
|
464
|
+
* dispatch, or the listing advertises a verb the dispatcher refuses (or,
|
|
465
|
+
* worse, hides one that works - the state this function was added to fix,
|
|
466
|
+
* where a nodes-only plugin's refusal printed a heading and nothing at
|
|
467
|
+
* all).
|
|
468
|
+
*
|
|
469
|
+
* Gated on `plugin.nodes` exactly as `genericLifecycleCommand` is, so a
|
|
470
|
+
* plugin with no `nodes` contributor advertises none of the three. A verb
|
|
471
|
+
* the plugin declares ITSELF is omitted here rather than listed twice: only
|
|
472
|
+
* `status` can be declared (`bootstrap`/`destroy` are rejected at
|
|
473
|
+
* discovery, `plugins.ts`'s `rejectDeclaredLifecycleCollisions`), and its
|
|
474
|
+
* own command already appears in the caller's declared-command lines -
|
|
475
|
+
* where `matchAction`'s precedence means that is the one that actually
|
|
476
|
+
* runs.
|
|
477
|
+
*/
|
|
478
|
+
export function genericLifecycleActions(plugin) {
|
|
479
|
+
if (!plugin.nodes)
|
|
480
|
+
return [];
|
|
481
|
+
const declared = new Set(plugin.commands.map((command) => command.action));
|
|
482
|
+
return [...GENERIC_LIFECYCLE_ACTIONS]
|
|
483
|
+
.filter(([action]) => !declared.has(action))
|
|
484
|
+
.map(([action, summary]) => ({ action, summary }));
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Handle `blogwright <command> <action> [env] [args]` once `command` has
|
|
488
|
+
* failed the `KNOWN_COMMANDS` membership test - i.e. it is either an
|
|
489
|
+
* installed plugin's namespace or entirely unknown.
|
|
490
|
+
*
|
|
491
|
+
* `ports` - the `fs`/`loader` pair `discover` needs - is supplied by the
|
|
492
|
+
* caller (`cli.ts`, from a small factory `bin.ts` wires to the real
|
|
493
|
+
* adapters) rather than read off an `OpsContext` built here. An EARLIER
|
|
494
|
+
* version of this function built a throwaway `OpsContext` first (via
|
|
495
|
+
* `makeContext`, guessing `production` or `--env`'s value) purely to reach
|
|
496
|
+
* its `ports.fs`/`ports.loader` for discovery, then rebuilt a second
|
|
497
|
+
* context once the real environment was known. That guess was not merely
|
|
498
|
+
* wasteful - it was WRONG on a repo whose only config file is for a
|
|
499
|
+
* non-default environment: `blogwright <plugin> <action> staging` on a repo
|
|
500
|
+
* with `config/staging.jsonc` and neither `config/production.jsonc` nor
|
|
501
|
+
* `ops.config.jsonc` made the throwaway build's `loadConfig` call
|
|
502
|
+
* (`context.ts`) throw `no config found for environment "production"` -
|
|
503
|
+
* naming an environment the operator never asked for, before the real one
|
|
504
|
+
* (`staging`) was ever read off the positionals. That is worse than the
|
|
505
|
+
* silent fallback-to-production this dispatcher exists to avoid, because
|
|
506
|
+
* the message actively misleads.
|
|
507
|
+
*
|
|
508
|
+
* `discover` only ever needed `Pick<Ports, 'fs' | 'loader'>` - both of which
|
|
509
|
+
* `createContext` builds BEFORE it loads any config (`context.ts`), and
|
|
510
|
+
* `cli.ts`'s `init` branch already constructs a `FileSystem` directly with
|
|
511
|
+
* no context at all - so threading the same two ports in from the caller
|
|
512
|
+
* removes the guess completely: the environment is resolved from
|
|
513
|
+
* `command`'s matched action BEFORE any `OpsContext` - throwaway or real -
|
|
514
|
+
* is built, and exactly one `makeContext` call happens, with the confirmed
|
|
515
|
+
* environment, reusing the SAME `fs`/`loader` discovery already used rather
|
|
516
|
+
* than letting a second call default fresh ones.
|
|
517
|
+
*
|
|
518
|
+
* EXIT CODES, and a deliberate deviation tasks 13/16/17 must not assume away.
|
|
519
|
+
* This task's definition of done asks that "a plugin command's return value
|
|
520
|
+
* maps to the process exit code". It cannot: `PluginCommand.run` is declared
|
|
521
|
+
* `Promise<void>` (`blogwright-core`'s `plugin.ts`), and the change spec names
|
|
522
|
+
* no return-code channel. So there is no value to map. What this dispatcher
|
|
523
|
+
* owns it returns - 0 once `run` resolves, 1 for an unknown plugin and 1 for an
|
|
524
|
+
* unknown action - and a command that genuinely fails signals it by REJECTING,
|
|
525
|
+
* which propagates to `bin.ts`'s error path. Adding actions here (tasks 13, 16,
|
|
526
|
+
* 17) means following that contract: reject to fail, do not invent a numeric
|
|
527
|
+
* return the SPI has nowhere to carry.
|
|
528
|
+
*/
|
|
529
|
+
export async function runPlugin(command, rest, values, terminal, logger, makeContext, ports) {
|
|
530
|
+
const repoRoot = await findRepoRoot(ports.fs);
|
|
531
|
+
const { plugins } = await discover(repoRoot, cliPackageDir(), ports);
|
|
532
|
+
const found = plugins.find((candidate) => candidate.name === command);
|
|
533
|
+
if (!found) {
|
|
534
|
+
logger.error(`no built-in command or installed plugin claims "${command}" - run ` +
|
|
535
|
+
'`blogwright plugin list` to see what is installed');
|
|
536
|
+
return 1;
|
|
537
|
+
}
|
|
538
|
+
// Widened from `Plugin` (i.e. `Plugin<never>`) to `Plugin<unknown>` by this
|
|
539
|
+
// annotation alone - no cast. `PluginCommand.run` is declared with method
|
|
540
|
+
// syntax specifically so this widening typechecks bivariantly (see
|
|
541
|
+
// `blogwright-core`'s `plugin.ts` doc comment on `Plugin`); the host must
|
|
542
|
+
// never construct a `PluginContext<never>`, because nothing inhabits
|
|
543
|
+
// `never` and reaching one would need the `as` cast DEVELOPMENT.md §Code
|
|
544
|
+
// style bans.
|
|
545
|
+
const plugin = found;
|
|
546
|
+
let match = matchAction(plugin.commands, rest);
|
|
547
|
+
if (!match) {
|
|
548
|
+
// The generic `init` action - only reached because no declared command
|
|
549
|
+
// matched. A plugin with its own `init` command never gets here for
|
|
550
|
+
// that action (matchAction already returned it above); a plugin with
|
|
551
|
+
// neither a command nor a contributor falls through toward the generic
|
|
552
|
+
// lifecycle check below, and from there to the unknown-action refusal.
|
|
553
|
+
if (rest[0] === GENERIC_INIT_ACTION && typeof plugin.init === 'function') {
|
|
554
|
+
return runGenericInit(plugin, plugin.init, repoRoot, rest.slice(1), values, terminal, logger, ports.fs);
|
|
555
|
+
}
|
|
556
|
+
// The generic `bootstrap`/`status`/`destroy` lifecycle verbs - see this
|
|
557
|
+
// module's TASK 16 PRECEDENCE section. Wrapped as a synthetic
|
|
558
|
+
// single-word `ActionMatch` so it falls through the SAME env
|
|
559
|
+
// resolution/context build/`run(ctx, args)` plumbing every declared
|
|
560
|
+
// command already uses below, rather than a second copy of it here.
|
|
561
|
+
const generic = genericLifecycleCommand(plugin, rest[0]);
|
|
562
|
+
if (!generic) {
|
|
563
|
+
logger.error(`unknown ${plugin.name} action: ${rest[0] ?? '(none)'}`);
|
|
564
|
+
logger.info(renderActions(plugin));
|
|
565
|
+
return 1;
|
|
566
|
+
}
|
|
567
|
+
match = { command: generic, wordCount: 1 };
|
|
568
|
+
}
|
|
569
|
+
const afterAction = rest.slice(match.wordCount);
|
|
570
|
+
const envPositional = afterAction[0];
|
|
571
|
+
const args = [...afterAction.slice(1), ...serialiseFlags(values)];
|
|
572
|
+
const env = values.env ?? envPositional ?? DEFAULT_ENV;
|
|
573
|
+
const ctx = await makeContext({
|
|
574
|
+
env,
|
|
575
|
+
configPath: values.config,
|
|
576
|
+
domain: values.domain,
|
|
577
|
+
endpointOverride: values.endpoint,
|
|
578
|
+
ports: { terminal, fs: ports.fs, loader: ports.loader },
|
|
579
|
+
});
|
|
580
|
+
// Validated BEFORE `toPluginContext`, which loads the plugin's own scoped
|
|
581
|
+
// state object: a plugin that refuses its own config block must fail
|
|
582
|
+
// before the dispatch makes an AWS call on its behalf, and certainly
|
|
583
|
+
// before its command runs. This is the one call site - the DISPATCHED
|
|
584
|
+
// plugin's block and no other (`plugins.ts`'s task-19 DECISION note).
|
|
585
|
+
const pluginConfig = resolvePluginConfig(plugin, ctx.configDocument);
|
|
586
|
+
await match.command.run(await toPluginContext(ctx, plugin.name, pluginConfig), args);
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
/*
|
|
590
|
+
* TASK 17 - the built-in `plugin` namespace.
|
|
591
|
+
*
|
|
592
|
+
* `runPluginNamespace` is NOT reached through `runPlugin` above: `plugin` is
|
|
593
|
+
* a member of `KNOWN_COMMANDS` (`known-commands.ts`), so no installed plugin
|
|
594
|
+
* can ever claim the name, and `cli.ts` dispatches it directly. It is
|
|
595
|
+
* dispatched BEFORE `createContext` - beside the `init` branch, not from the
|
|
596
|
+
* built-in `switch` - because `createContext` loads the environment's config
|
|
597
|
+
* and calls `sts.getAccountId()`, neither of which holds on the repo this
|
|
598
|
+
* namespace exists to serve: `blogwright plugin list` on a checkout with no
|
|
599
|
+
* `config/<env>.jsonc` and no AWS credentials would otherwise fail with `no
|
|
600
|
+
* config found for environment "production"` instead of printing the
|
|
601
|
+
* empty-state line that names `blogwright plugin add` - the very command an
|
|
602
|
+
* operator runs BEFORE the repo is configured. Nothing this namespace does
|
|
603
|
+
* needs an environment at all, which is why it takes ports rather than a
|
|
604
|
+
* `ContextFactory`.
|
|
605
|
+
*/
|
|
606
|
+
/**
|
|
607
|
+
* The `plugin` namespace's own actions, `action -> summary`, in the same
|
|
608
|
+
* shape {@link GENERIC_LIFECYCLE_ACTIONS} uses - so the refusal listing
|
|
609
|
+
* ({@link renderPluginNamespaceActions}) is built from the same table the
|
|
610
|
+
* dispatcher matches against and cannot advertise an action that does not
|
|
611
|
+
* run, or hide one that does. Tasks 18 and 19 add `add` and `remove` here.
|
|
612
|
+
*/
|
|
613
|
+
const PLUGIN_NAMESPACE_ACTIONS = new Map([
|
|
614
|
+
['add', "install a plugin package, pinned to the CLI's own version"],
|
|
615
|
+
['list', 'show installed plugins, their versions and the config key each owns'],
|
|
616
|
+
['remove', 'uninstall a plugin package, asking first about its teardown'],
|
|
617
|
+
]);
|
|
618
|
+
/**
|
|
619
|
+
* Printed when a repo has no plugins installed. Names the command that
|
|
620
|
+
* installs one, because an empty listing with nothing else on it reads as a
|
|
621
|
+
* broken command rather than an accurate report.
|
|
622
|
+
*/
|
|
623
|
+
const NO_PLUGINS_INSTALLED = 'no plugins installed - run `blogwright plugin add <name>` to install one';
|
|
624
|
+
/**
|
|
625
|
+
* List the `plugin` namespace's actions for a refusal, in the same shape
|
|
626
|
+
* {@link renderActions} renders an installed plugin's actions in, so the two
|
|
627
|
+
* refusals an operator can hit read identically.
|
|
628
|
+
*/
|
|
629
|
+
function renderPluginNamespaceActions() {
|
|
630
|
+
return [
|
|
631
|
+
'"plugin" actions:',
|
|
632
|
+
...Array.from(PLUGIN_NAMESPACE_ACTIONS, ([action, summary]) => ` ${action} - ${summary}`),
|
|
633
|
+
].join('\n');
|
|
634
|
+
}
|
|
635
|
+
/** Narrow parsed JSON to an object before reading a field off it - no cast, no `any`. */
|
|
636
|
+
function isRecord(value) {
|
|
637
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Read one plugin package's own declared `version` through the `FileSystem`
|
|
641
|
+
* port - never a table in this module, never a registry lookup, so the
|
|
642
|
+
* listing reports what is actually installed on this machine and works with
|
|
643
|
+
* no network.
|
|
644
|
+
*
|
|
645
|
+
* `packageJsonPath` is what `ModuleLoader.packageJsonPathFor` resolved during
|
|
646
|
+
* discovery (`plugins.ts`'s `InstalledPlugin`), not a path this module
|
|
647
|
+
* builds from `ModuleLoader.resolve`'s entry file: for a package published
|
|
648
|
+
* with the standard dual-package layout, the directory holding the entry
|
|
649
|
+
* point carries a name-less `{"type":"module"}` stub rather than the
|
|
650
|
+
* manifest.
|
|
651
|
+
*
|
|
652
|
+
* The file is re-read here rather than carried out of discovery already
|
|
653
|
+
* parsed, so that `--help` and plugin dispatch - which run the same discovery
|
|
654
|
+
* but never show a version - keep paying nothing for this field.
|
|
655
|
+
*
|
|
656
|
+
* `undefined`, rendered as an explicit marker by `render.ts`, for a manifest
|
|
657
|
+
* that declares no `version` (a private workspace package, an unpublished
|
|
658
|
+
* plugin under development). An unreadable or unparseable manifest is a
|
|
659
|
+
* different thing entirely - the file discovery itself read moments ago,
|
|
660
|
+
* broken underneath us - and propagates, exactly as the same distinction is
|
|
661
|
+
* drawn between a MISSING and a MALFORMED `package.json` in `cli.ts`'s
|
|
662
|
+
* `isMissingPackageJsonError`.
|
|
663
|
+
*/
|
|
664
|
+
async function readPackageVersion(fs, packageJsonPath) {
|
|
665
|
+
const parsed = JSON.parse(await fs.readText(packageJsonPath));
|
|
666
|
+
if (!isRecord(parsed))
|
|
667
|
+
return undefined;
|
|
668
|
+
const version = parsed.version;
|
|
669
|
+
return typeof version === 'string' && version.length > 0 ? version : undefined;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Run `blogwright plugin list`: one row per installed plugin - namespace,
|
|
673
|
+
* package, version and the config key it owns - plus one line per plugin
|
|
674
|
+
* that failed to load, with the reason `discover` produced.
|
|
675
|
+
*
|
|
676
|
+
* Always returns 0. This is a REPORT: its exit code says the listing was
|
|
677
|
+
* produced, not that every plugin in it is healthy - the same contract
|
|
678
|
+
* `blogwright --help` already has (it lists load failures and exits 0) and
|
|
679
|
+
* `status` has for drift. A failed plugin is data in the listing, and an
|
|
680
|
+
* empty listing is never an error.
|
|
681
|
+
*
|
|
682
|
+
* Rows are ordered by namespace, never by `discover`'s own array order: the
|
|
683
|
+
* candidate set is built from two `dependencies`/`devDependencies` maps
|
|
684
|
+
* (`plugins.ts`'s `collectCandidates`) whose key order is an implementation
|
|
685
|
+
* detail of those manifests, not something a CI-consumed listing should vary
|
|
686
|
+
* with. The same reason - and the same plain string comparison rather than
|
|
687
|
+
* `localeCompare` - as `cli.ts`'s `buildHelp`.
|
|
688
|
+
*/
|
|
689
|
+
async function runPluginList(ports, terminal, logger) {
|
|
690
|
+
const repoRoot = await findRepoRoot(ports.fs);
|
|
691
|
+
const discovered = await discover(repoRoot, cliPackageDir(), ports);
|
|
692
|
+
const rows = [];
|
|
693
|
+
for (const entry of discovered.installed) {
|
|
694
|
+
rows.push({
|
|
695
|
+
namespace: entry.plugin.name,
|
|
696
|
+
packageName: entry.packageName,
|
|
697
|
+
version: await readPackageVersion(ports.fs, entry.packageJsonPath),
|
|
698
|
+
configKey: entry.plugin.configKey,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
rows.sort((a, b) => (a.namespace < b.namespace ? -1 : a.namespace > b.namespace ? 1 : 0));
|
|
702
|
+
// Printed whenever nothing LOADED, even when a failure line follows it: a
|
|
703
|
+
// repo whose only plugin is broken has no usable plugin installed, and the
|
|
704
|
+
// failure below says which one and why.
|
|
705
|
+
if (rows.length === 0)
|
|
706
|
+
logger.info(NO_PLUGINS_INSTALLED);
|
|
707
|
+
const listing = { rows, failures: discovered.failures };
|
|
708
|
+
for (const line of renderPluginList(listing, terminal.isInteractive))
|
|
709
|
+
logger.info(line);
|
|
710
|
+
return 0;
|
|
711
|
+
}
|
|
712
|
+
/** The prefix a short plugin name is expanded with - `analytics` -> `blogwright-analytics`. */
|
|
713
|
+
const PLUGIN_PACKAGE_PREFIX = 'blogwright-';
|
|
714
|
+
/**
|
|
715
|
+
* Expand a short plugin name into the package name to install or uninstall,
|
|
716
|
+
* per §CLI → `blogwright plugin`: `analytics` becomes `blogwright-analytics`,
|
|
717
|
+
* while a name containing `/` (a scoped package, `@scope/thing`) or already
|
|
718
|
+
* starting with `blogwright-` is a literal package name and is returned
|
|
719
|
+
* unchanged.
|
|
720
|
+
*
|
|
721
|
+
* Pure, and deliberately total: it never throws and never rejects. What is
|
|
722
|
+
* ACCEPTABLE as a package name at all is a separate question, asked of the
|
|
723
|
+
* RESULT by {@link PACKAGE_NAME_PATTERN} below - because both halves of this
|
|
724
|
+
* function can produce something that is not a package name (`./evil` passes
|
|
725
|
+
* through the `/` branch; `analytics@9.9.9` becomes
|
|
726
|
+
* `blogwright-analytics@9.9.9`), and one gate over the result catches both.
|
|
727
|
+
*/
|
|
728
|
+
function resolvePluginPackage(name) {
|
|
729
|
+
return name.includes('/') || name.startsWith(PLUGIN_PACKAGE_PREFIX)
|
|
730
|
+
? name
|
|
731
|
+
: `${PLUGIN_PACKAGE_PREFIX}${name}`;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* The package names these two verbs will hand to the `PackageManager` port:
|
|
735
|
+
* npm's own grammar - an optional `@scope/` followed by a name of lowercase
|
|
736
|
+
* ASCII alphanumerics, `-`, `.`, `_` and `~`.
|
|
737
|
+
*
|
|
738
|
+
* Two deliberate narrowings of npm's published pattern, both because the
|
|
739
|
+
* value becomes an ARGUMENT VECTOR element for `pnpm add`/`npm uninstall`
|
|
740
|
+
* (`adapters/process-package-manager.ts`):
|
|
741
|
+
*
|
|
742
|
+
* - No leading `-`, so no input can arrive at the package manager as a
|
|
743
|
+
* FLAG. (`blogwright plugin add --force` never gets that far anyway - it
|
|
744
|
+
* is parsed as a flag by `main` - but `resolvePluginPackage` is not the
|
|
745
|
+
* place to rely on that.)
|
|
746
|
+
* - No leading `.`, so `./local-thing` and `../..` are refused rather than
|
|
747
|
+
* installed as a filesystem path. npm's own pattern already excludes
|
|
748
|
+
* these; it is stated here because it is the point.
|
|
749
|
+
*
|
|
750
|
+
* The `@` character is absent from the name body, which is what refuses
|
|
751
|
+
* `analytics@9.9.9`: a caller must not be able to smuggle a version past
|
|
752
|
+
* `add`'s pin by writing one into the name, because the resulting spec would
|
|
753
|
+
* be `blogwright-analytics@9.9.9@0.3.3` - a request no manager can satisfy,
|
|
754
|
+
* and, worse, a shape the version pin exists to make impossible.
|
|
755
|
+
*/
|
|
756
|
+
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9~][a-z0-9\-._~]*\/)?[a-z0-9~][a-z0-9\-._~]*$/;
|
|
757
|
+
/**
|
|
758
|
+
* True when `packageName` is declared in the consuming repo's own
|
|
759
|
+
* `package.json` - the same two maps discovery builds its candidate set from
|
|
760
|
+
* (`plugins.ts`'s `collectCandidates`), read through the `FileSystem` port.
|
|
761
|
+
*
|
|
762
|
+
* This, not module resolution, is what "installed" means for both verbs, and
|
|
763
|
+
* for the same reason in each direction: `add` must not re-install a plugin
|
|
764
|
+
* the manifest already pins (even one whose `node_modules` copy is missing -
|
|
765
|
+
* that is `pnpm install`'s job, not this command's), and `remove` must be
|
|
766
|
+
* able to uninstall a plugin whose module no longer resolves at all, which is
|
|
767
|
+
* precisely the state a half-broken install leaves behind.
|
|
768
|
+
*
|
|
769
|
+
* A missing or unparseable manifest propagates rather than being read as "no
|
|
770
|
+
* dependencies": a repo with no `package.json` has no package manager to run
|
|
771
|
+
* either, and silently treating that as an empty dependency set would make
|
|
772
|
+
* `remove` report a plugin absent when nothing was actually checked.
|
|
773
|
+
*/
|
|
774
|
+
async function isDeclaredDependency(fs, repoRoot, packageName) {
|
|
775
|
+
const parsed = JSON.parse(await fs.readText(join(repoRoot, 'package.json')));
|
|
776
|
+
if (!isRecord(parsed))
|
|
777
|
+
return false;
|
|
778
|
+
for (const field of ['dependencies', 'devDependencies']) {
|
|
779
|
+
const declared = parsed[field];
|
|
780
|
+
if (isRecord(declared) && Object.hasOwn(declared, packageName))
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Install one plugin package, pinned to the running CLI's own version.
|
|
787
|
+
*
|
|
788
|
+
* The order of the three steps is the contract: the manifest check comes
|
|
789
|
+
* FIRST, so an already-installed plugin reports that and returns 0 having
|
|
790
|
+
* touched neither the version read nor the `PackageManager` port; only then
|
|
791
|
+
* is the version resolved and the port built and called.
|
|
792
|
+
*
|
|
793
|
+
* `exact: true` is what makes the pin survive. The spec string alone
|
|
794
|
+
* (`blogwright-analytics@0.3.3`) tells the manager WHICH version to fetch,
|
|
795
|
+
* but every supported manager would then write a `^0.3.3` RANGE into
|
|
796
|
+
* `package.json` - so the next `install` on a colleague's checkout could
|
|
797
|
+
* resolve something else entirely, `blogwright plugin list`'s version column
|
|
798
|
+
* would stop meaning "the version this repo pins", and the CLI and its plugin
|
|
799
|
+
* could drift apart across two developers' machines without either of them
|
|
800
|
+
* changing anything.
|
|
801
|
+
*/
|
|
802
|
+
async function runPluginAdd(packageName, ports, logger, deps) {
|
|
803
|
+
const repoRoot = await findRepoRoot(ports.fs);
|
|
804
|
+
if (await isDeclaredDependency(ports.fs, repoRoot, packageName)) {
|
|
805
|
+
logger.info(`${packageName} is already installed - nothing to do`);
|
|
806
|
+
return 0;
|
|
807
|
+
}
|
|
808
|
+
const spec = `${packageName}@${await deps.cliVersion()}`;
|
|
809
|
+
await deps.makePackages().add(spec, { exact: true });
|
|
810
|
+
logger.ok(`installed ${spec} - run \`blogwright plugin list\` to see the namespace it claims`);
|
|
811
|
+
return 0;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Resolve and load the ONE plugin `remove` is about to uninstall, so the
|
|
815
|
+
* command knows whether it has resources to ask about.
|
|
816
|
+
*
|
|
817
|
+
* A single `resolve` + `load` pair through the `ModuleLoader` port - never
|
|
818
|
+
* `discover` - so §Plugin discovery's laziness rule is untouched: removing
|
|
819
|
+
* one plugin does not resolve, import or validate every other plugin in the
|
|
820
|
+
* repo.
|
|
821
|
+
*
|
|
822
|
+
* `undefined` for every way that can fail - unresolvable, unimportable, or
|
|
823
|
+
* rejected by `validatePlugin` - because all of them mean the same thing to
|
|
824
|
+
* this command: there is no teardown to ask about. A broken plugin could not
|
|
825
|
+
* run its `destroy` if we asked, so asking would be a question with one
|
|
826
|
+
* honest answer. It is reported no further than that (no warning line): the
|
|
827
|
+
* package is on its way out, `blogwright plugin list` is the command that
|
|
828
|
+
* names load failures, and a refusal-shaped message about a package the
|
|
829
|
+
* operator has just asked to delete would be noise.
|
|
830
|
+
*/
|
|
831
|
+
async function loadPluginForRemoval(packageName, repoRoot, ports) {
|
|
832
|
+
try {
|
|
833
|
+
const entry = await ports.loader.resolve(packageName, repoRoot);
|
|
834
|
+
if (!entry.found)
|
|
835
|
+
return undefined;
|
|
836
|
+
return validatePlugin(await ports.loader.load(entry.path), packageName);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return undefined;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Run the plugin's own generic `destroy` before it is uninstalled - the exact
|
|
844
|
+
* tail `runPlugin` runs for `blogwright <plugin> destroy --yes`: one
|
|
845
|
+
* `makeContext` for the resolved environment, the plugin's OWN config block
|
|
846
|
+
* validated off that context's raw document (`resolvePluginConfig`), the
|
|
847
|
+
* narrow `PluginContext` with its scoped state store (`toPluginContext`), and
|
|
848
|
+
* task 16's `runGenericDestroy` over `plugin.nodes(ctx)`.
|
|
849
|
+
*
|
|
850
|
+
* Reached only from an answered "yes", which is why `yes` is passed as `true`
|
|
851
|
+
* here: the operator has just answered the question `runGenericDestroy`'s own
|
|
852
|
+
* `--yes` refusal exists to force.
|
|
853
|
+
*
|
|
854
|
+
* A rejection propagates and the uninstall never runs - the caller awaits
|
|
855
|
+
* this before touching the `PackageManager` port. That ordering is the whole
|
|
856
|
+
* point: a teardown that failed must leave the package installed, so the
|
|
857
|
+
* operator can fix the cause and run `blogwright <plugin> destroy --yes`
|
|
858
|
+
* again.
|
|
859
|
+
*/
|
|
860
|
+
async function destroyBeforeRemoval(plugin, nodesOf, env, ports, terminal, deps) {
|
|
861
|
+
const ops = await deps.makeContext({
|
|
862
|
+
env,
|
|
863
|
+
configPath: deps.values.config,
|
|
864
|
+
domain: deps.values.domain,
|
|
865
|
+
endpointOverride: deps.values.endpoint,
|
|
866
|
+
ports: { terminal, fs: ports.fs, loader: ports.loader },
|
|
867
|
+
});
|
|
868
|
+
const pluginConfig = resolvePluginConfig(plugin, ops.configDocument);
|
|
869
|
+
await runGenericDestroy(plugin, nodesOf, await toPluginContext(ops, plugin.name, pluginConfig), true);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Uninstall one plugin package, asking first whether its resources should be
|
|
873
|
+
* torn down - §CLI → `blogwright plugin`, and the settled decision
|
|
874
|
+
* *`plugin remove` asks about teardown; a session that cannot ask is refused,
|
|
875
|
+
* not defaulted*.
|
|
876
|
+
*
|
|
877
|
+
* The question is load-bearing because removal FORECLOSES ITS OWN REMEDY: the
|
|
878
|
+
* generic `blogwright <plugin> destroy` verb exists only while the package is
|
|
879
|
+
* installed, so an operator who uninstalls first has to reinstall before they
|
|
880
|
+
* can tear anything down.
|
|
881
|
+
*
|
|
882
|
+
* That is also why the non-interactive path REFUSES rather than taking a
|
|
883
|
+
* default. `confirm` (`logger.ts`) answers with its default when no TTY is
|
|
884
|
+
* attached, which is right wherever one answer is safe; here neither is -
|
|
885
|
+
* running a teardown nobody asked for is destructive, and skipping it strands
|
|
886
|
+
* AWS resources behind a reinstall. So this follows `initSite`'s refusal
|
|
887
|
+
* shape instead, naming BOTH ways forward, and `--yes` is the scripted
|
|
888
|
+
* "uninstall, keep the resources" answer. `--plain` needs no separate test
|
|
889
|
+
* here: `createNodeTerminal` builds a non-interactive terminal for it
|
|
890
|
+
* (`plain` forces `isInteractive` false), so it arrives as the same
|
|
891
|
+
* non-interactive session, and `terminal.isInteractive` stays the single
|
|
892
|
+
* source of truth for "can this session be asked a question".
|
|
893
|
+
*/
|
|
894
|
+
async function runPluginRemove(name, packageName, afterName, ports, terminal, logger, deps) {
|
|
895
|
+
const repoRoot = await findRepoRoot(ports.fs);
|
|
896
|
+
if (!(await isDeclaredDependency(ports.fs, repoRoot, packageName))) {
|
|
897
|
+
logger.error(`${packageName} is not a dependency of ${repoRoot} - nothing to remove; run ` +
|
|
898
|
+
'`blogwright plugin list` to see what is installed');
|
|
899
|
+
return 1;
|
|
900
|
+
}
|
|
901
|
+
const plugin = await loadPluginForRemoval(packageName, repoRoot, ports);
|
|
902
|
+
const nodesOf = plugin?.nodes;
|
|
903
|
+
// The NAMESPACE `blogwright <namespace> destroy` actually dispatches on -
|
|
904
|
+
// the plugin's own declared name, which need not match the package it ships
|
|
905
|
+
// in (`blogwright-metrics` may claim `widget`) or the short name typed here.
|
|
906
|
+
// Falls back to what was typed for a plugin that did not load, which is the
|
|
907
|
+
// best guess available and the only one an operator could act on anyway.
|
|
908
|
+
const namespace = plugin?.name ?? name;
|
|
909
|
+
// The usual positional/`--env` rule every built-in command follows, applied
|
|
910
|
+
// to `blogwright plugin remove <name> [env]`.
|
|
911
|
+
const env = deps.values.env ?? afterName[0] ?? DEFAULT_ENV;
|
|
912
|
+
let tornDown = false;
|
|
913
|
+
if (plugin !== undefined && nodesOf !== undefined && !deps.values.yes) {
|
|
914
|
+
if (!terminal.isInteractive) {
|
|
915
|
+
logger.error(`removing ${packageName} would strand the resources "${namespace}" provisioned in ` +
|
|
916
|
+
`"${env}", and this session cannot be asked about them: run \`blogwright ${namespace} ` +
|
|
917
|
+
'destroy --yes` first to tear them down, or re-run `blogwright plugin remove ' +
|
|
918
|
+
`${name} --yes\` to uninstall and keep them`);
|
|
919
|
+
return 1;
|
|
920
|
+
}
|
|
921
|
+
tornDown = await confirm(terminal, `tear down "${namespace}"'s resources in "${env}" before removing ${packageName}?`, { defaultYes: false });
|
|
922
|
+
if (tornDown)
|
|
923
|
+
await destroyBeforeRemoval(plugin, nodesOf, env, ports, terminal, deps);
|
|
924
|
+
}
|
|
925
|
+
await deps.makePackages().remove(packageName);
|
|
926
|
+
logger.ok(tornDown
|
|
927
|
+
? `removed ${packageName} - its "${env}" resources were torn down first, and its ` +
|
|
928
|
+
'configuration is untouched'
|
|
929
|
+
: `removed ${packageName} - configuration and provisioned resources are untouched; ` +
|
|
930
|
+
`\`blogwright ${namespace} destroy\` tears them down and needs ${packageName} ` +
|
|
931
|
+
'reinstalled to run');
|
|
932
|
+
return 0;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Handle `blogwright plugin <action>`. Dispatched by `cli.ts` ahead of any
|
|
936
|
+
* `OpsContext` - see this section's own comment above for why that placement
|
|
937
|
+
* is load-bearing rather than merely tidy.
|
|
938
|
+
*
|
|
939
|
+
* An absent or unrecognised action lists the namespace's actions and returns
|
|
940
|
+
* 1, the same shape `runPlugin` refuses an unknown action of an installed
|
|
941
|
+
* plugin in. `add` and `remove` share this function's name resolution and
|
|
942
|
+
* validation - one gate, so the two verbs cannot disagree about what
|
|
943
|
+
* `analytics` means or about what is a package name at all.
|
|
944
|
+
*/
|
|
945
|
+
export async function runPluginNamespace(rest, terminal, logger, ports, deps) {
|
|
946
|
+
const action = rest[0];
|
|
947
|
+
if (action === undefined || !PLUGIN_NAMESPACE_ACTIONS.has(action)) {
|
|
948
|
+
logger.error(`unknown plugin action: ${action ?? '(none)'}`);
|
|
949
|
+
logger.info(renderPluginNamespaceActions());
|
|
950
|
+
return 1;
|
|
951
|
+
}
|
|
952
|
+
if (action === 'list')
|
|
953
|
+
return runPluginList(ports, terminal, logger);
|
|
954
|
+
const name = rest[1];
|
|
955
|
+
// An EMPTY name is a missing one, not a package name: `blogwright plugin
|
|
956
|
+
// add ""` resolves to the bare prefix `blogwright-`, which the pattern
|
|
957
|
+
// below accepts, so without this the operator gets the package manager's
|
|
958
|
+
// 404 rather than the message that says what to type.
|
|
959
|
+
if (name === undefined || name === '') {
|
|
960
|
+
logger.error(`\`blogwright plugin ${action}\` needs a plugin name - e.g. \`blogwright plugin ${action} analytics\``);
|
|
961
|
+
return 1;
|
|
962
|
+
}
|
|
963
|
+
const packageName = resolvePluginPackage(name);
|
|
964
|
+
// The pattern is asked of the RAW name as well as the resolved one, because
|
|
965
|
+
// the `blogwright-` expansion hides a leading `.`: `..` resolves to
|
|
966
|
+
// `blogwright-..`, which npm's grammar accepts, so the gate over the
|
|
967
|
+
// resolved name alone would pass a path-shaped input to the package manager.
|
|
968
|
+
// The raw test is NOT redundant with the resolved one and must not be
|
|
969
|
+
// folded back into it. For a scoped name or an already-prefixed one the two
|
|
970
|
+
// test the same string; everywhere else the raw test is the only thing that
|
|
971
|
+
// sees what the prefix covered up. What it costs, over the resolved test
|
|
972
|
+
// alone, is exactly the names the prefix would have made respectable: a
|
|
973
|
+
// name of otherwise-legal package characters that OPENS with `-`, `.` or
|
|
974
|
+
// `_` (`.`, `..`, `.npmrc`, `-rf`). Any other rejected opening character -
|
|
975
|
+
// `A`, `$`, a space - fails the resolved test too, so the raw test adds
|
|
976
|
+
// nothing there. And it strands nothing: whoever genuinely wants
|
|
977
|
+
// `blogwright-.x` spells that package name out, which already starts with
|
|
978
|
+
// the prefix, so it resolves to itself and clears both tests.
|
|
979
|
+
if (!PACKAGE_NAME_PATTERN.test(name) || !PACKAGE_NAME_PATTERN.test(packageName)) {
|
|
980
|
+
logger.error(`"${name}" is not a plugin package name - \`blogwright plugin ${action}\` takes a short ` +
|
|
981
|
+
'name (`analytics`, installed as `blogwright-analytics`), a `blogwright-` package name, ' +
|
|
982
|
+
'or a scoped package name (`@scope/thing`)');
|
|
983
|
+
return 1;
|
|
984
|
+
}
|
|
985
|
+
// `PLUGIN_NAMESPACE_ACTIONS` has exactly three members and `list` returned
|
|
986
|
+
// above; the membership test at the top is what keeps this exhaustive.
|
|
987
|
+
return action === 'add'
|
|
988
|
+
? runPluginAdd(packageName, ports, logger, deps)
|
|
989
|
+
: runPluginRemove(name, packageName, rest.slice(2), ports, terminal, logger, deps);
|
|
990
|
+
}
|