blogwright 0.3.3 → 0.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +11 -11
  2. package/agent/agent-manifest.json +1 -1
  3. package/agent/server.js +37 -19
  4. package/dist/adapters/fetch-ping.d.ts +1 -1
  5. package/dist/adapters/fetch-ping.js +3 -3
  6. package/dist/adapters/node-module-loader.d.ts +11 -0
  7. package/dist/adapters/node-module-loader.js +146 -0
  8. package/dist/adapters/process-package-manager.d.ts +41 -0
  9. package/dist/adapters/process-package-manager.js +116 -0
  10. package/dist/adapters/process-vcs.d.ts +5 -4
  11. package/dist/adapters/process-vcs.js +6 -5
  12. package/dist/agent-package.d.ts +1 -1
  13. package/dist/agent-package.js +4 -3
  14. package/dist/bin.js +13 -2
  15. package/dist/cli.d.ts +68 -1
  16. package/dist/cli.js +270 -88
  17. package/dist/commands.d.ts +70 -3
  18. package/dist/commands.js +177 -30
  19. package/dist/config-block.d.ts +34 -0
  20. package/dist/config-block.js +262 -0
  21. package/dist/context.d.ts +76 -6
  22. package/dist/context.js +98 -18
  23. package/dist/deploy.d.ts +2 -2
  24. package/dist/deploy.js +12 -12
  25. package/dist/graph.d.ts +27 -16
  26. package/dist/graph.js +1 -1
  27. package/dist/init.d.ts +37 -3
  28. package/dist/init.js +146 -22
  29. package/dist/known-commands.d.ts +63 -0
  30. package/dist/known-commands.js +78 -0
  31. package/dist/microvms.d.ts +2 -2
  32. package/dist/microvms.js +3 -3
  33. package/dist/nodes.d.ts +5 -3
  34. package/dist/nodes.js +97 -30
  35. package/dist/plugin-commands.d.ts +298 -0
  36. package/dist/plugin-commands.js +990 -0
  37. package/dist/plugins.d.ts +194 -0
  38. package/dist/plugins.js +523 -0
  39. package/dist/ports.d.ts +89 -1
  40. package/dist/render.d.ts +55 -0
  41. package/dist/render.js +89 -1
  42. package/dist/repo.d.ts +3 -3
  43. package/dist/repo.js +8 -8
  44. package/dist/seo.d.ts +1 -1
  45. package/dist/seo.js +1 -1
  46. package/package.json +6 -6
@@ -1,6 +1,58 @@
1
+ import { type ResourceNode } from 'blogwright-core';
1
2
  import type { OpsContext } from './context.js';
3
+ import { type GraphContext } from './graph.js';
4
+ import { type StatusEntry } from './render.js';
2
5
  /** Create the full infrastructure graph. */
3
6
  export declare function bootstrap(ctx: OpsContext): Promise<void>;
7
+ /**
8
+ * Refuse to destroy the site while any plugin's own state object still
9
+ * exists in the bucket - §State → Scoped state stores' "`blogwright
10
+ * destroy` therefore refuses while any `state/<env>.<plugin>.json` exists".
11
+ *
12
+ * A scope changes the state object's KEY, not the bucket it lives in
13
+ * (`StateStore`, `packages/core/src/state.ts`) - a scoped and an unscoped
14
+ * store for the same environment are constructed over the very same
15
+ * `names.bucket` - and the site's own bucket node empties every prefix
16
+ * before deleting the bucket (`deletePrefix(ctx.names.bucket, '')`,
17
+ * `nodes.ts`'s `bucketNode().delete()`). Without this guard, a site
18
+ * teardown deletes `state/<env>.<scope>.json` while every resource it
19
+ * records lives on: the plugin's next `destroy` then loads empty state,
20
+ * every node's `read()` returns false, and nothing is removed - the
21
+ * plugin's resources are silently orphaned.
22
+ *
23
+ * Reads the bucket, not the plugin registry, so the refusal holds even for
24
+ * a plugin that has since been uninstalled. Runs inside the teardown verbs
25
+ * themselves, not `createContext` - so no other command pays for the extra
26
+ * `listObjects` call and plugin discovery stays lazy - and ahead of
27
+ * `clearRunningMicrovms`/`destroyGraph` in each, so a refusal has zero side
28
+ * effects: nothing is terminated, nothing is deleted.
29
+ *
30
+ * BOTH teardown verbs call it: `destroy` (below) and `previewTeardown`,
31
+ * which runs the very same `destroyGraph(buildNodes(ctx), ctx)` over the
32
+ * very same `bucketNode().delete()` and so empties the preview stack's
33
+ * bucket - including a `state/<env>.<scope>.json` a plugin bootstrapped
34
+ * against that environment - in exactly the same way. Nothing about the
35
+ * orphaning above is specific to the site's own environment, so nothing
36
+ * about the guard is either.
37
+ *
38
+ * A MISSING BUCKET IS TREATED AS CLEAR, not as an error. `listObjects` does
39
+ * not swallow a 404 the way `getObjectText` does, so an environment whose
40
+ * bucket a previous (interrupted) teardown already deleted would otherwise
41
+ * fail here with a raw `NoSuchBucket` before a single non-bucket resource -
42
+ * roles, log groups, the CloudFront function, the delivery trio - had been
43
+ * cleaned up, on precisely the recovery path an operator reaches for after
44
+ * an interrupted teardown. A bucket that is gone holds no state object at
45
+ * all, scoped or otherwise, so there is nothing to protect and every reason
46
+ * to let the teardown finish: refusing here would CAUSE the orphaning this
47
+ * guard exists to prevent. Only `NoSuchBucket` is treated this way - every
48
+ * other failure (denied, throttled, a network fault) still propagates,
49
+ * because those say nothing about whether scoped state exists.
50
+ *
51
+ * Exported - like `readNodeStatus` above - so a test can pin its outcomes
52
+ * directly against a recording S3 client, rather than only through a full
53
+ * `destroy()` run over the entire production graph.
54
+ */
55
+ export declare function assertNoScopedState(ctx: OpsContext): Promise<void>;
4
56
  /** Destroy the full infrastructure graph. */
5
57
  export declare function destroy(ctx: OpsContext, opts: {
6
58
  yes: boolean;
@@ -19,7 +71,7 @@ export declare function previewBootstrap(ctx: OpsContext): Promise<void>;
19
71
  export declare function previewDeploy(ctx: OpsContext, id: string, opts?: {
20
72
  refresh?: boolean;
21
73
  }): Promise<string>;
22
- /** Remove one PR's preview (delete its prefix). No invalidation previews aren't cached. */
74
+ /** Remove one PR's preview (delete its prefix). No invalidation - previews aren't cached. */
23
75
  export declare function previewDestroy(ctx: OpsContext, id: string): Promise<void>;
24
76
  /** List active previews (by prefix). */
25
77
  export declare function previewList(ctx: OpsContext): Promise<void>;
@@ -33,5 +85,20 @@ export declare function deleteSite(ctx: OpsContext): Promise<void>;
33
85
  export declare function history(ctx: OpsContext): Promise<void>;
34
86
  /** Show CloudWatch build logs for a given hash. */
35
87
  export declare function logs(ctx: OpsContext, hash: string): Promise<void>;
36
- /** Show the planned graph against live state (drift view). */
37
- export declare function status(ctx: OpsContext): Promise<void>;
88
+ /**
89
+ * Read each node's live status against `ctx.state`: present/missing from
90
+ * `node.read(ctx)`, or an `error` entry carrying the message if it throws.
91
+ * A query, not a command - it never writes to the logger, so a caller (the
92
+ * CLI's own `status` below, and a plugin's `status` verb) decides how to
93
+ * report each entry. Iterates `nodes` in the order given (no `topoSort` -
94
+ * status is a read, not a reconcile, so dependency order doesn't matter).
95
+ */
96
+ export declare function readNodeStatus<Ctx extends GraphContext>(nodes: ResourceNode<Ctx>[], ctx: Ctx): Promise<StatusEntry[]>;
97
+ /**
98
+ * Show the planned graph against live state (drift view). `nodes` defaults to
99
+ * the production graph (`buildNodes(ctx)`) - every real call site is
100
+ * unchanged - but is a parameter, not reached-for, so a test (or a future
101
+ * caller) can hand this the same loop over a different node set without
102
+ * patching a module.
103
+ */
104
+ export declare function status(ctx: OpsContext, nodes?: ResourceNode<OpsContext>[]): Promise<void>;
package/dist/commands.js CHANGED
@@ -1,18 +1,42 @@
1
- import { colors, findRepoRoot } from 'blogwright-core';
1
+ import { AwsError, colors, findRepoRoot } from 'blogwright-core';
2
+ /*
3
+ * A STATIC, NON-OPTIONAL IMPORT, and deliberately the last one left in the
4
+ * CLI. Task 29 deleted every other reference to `blogwright-pds` from the
5
+ * dispatcher (`cli.ts` now knows no namespace by name; the package is
6
+ * discovered and dispatched as an ordinary plugin), and this one still
7
+ * stays, for two reasons:
8
+ *
9
+ * - THE SPI HAS NO LIFECYCLE HOOKS. `Plugin` (`blogwright-core`'s
10
+ * `plugin.ts`) declares `commands`, `nodes`, `configKey`,
11
+ * `validateConfig` and `init` - nothing a plugin could register to be
12
+ * called after a successful deploy. `deploy` below therefore reaches
13
+ * this function by name, exactly as it always has; routing it through
14
+ * plugin dispatch would mean inventing a hook the change spec does not
15
+ * describe.
16
+ * - THE PACKAGE SHIPS BY DEFAULT. `blogwright-pds` is a non-optional
17
+ * `dependencies` entry of `packages/cli/package.json` - that is how the
18
+ * bundled plugin reaches a consuming repo that depends on `blogwright`
19
+ * alone - so the import can never fail to resolve, and no optional-
20
+ * dependency guard is needed around it.
21
+ *
22
+ * `syncAfterDeploy` no-ops for any environment but `production` and for a
23
+ * site with no `pds` config block, so an unconfigured repo pays nothing for
24
+ * it. Both halves are asserted in `commands.test.ts`.
25
+ */
2
26
  import { syncAfterDeploy } from 'blogwright-pds';
3
27
  import { invalidateChanged, invalidateCloudFront, manifestKey, microvmLogGroup, runBuild, } from './deploy.js';
4
28
  import { applyGraph, destroyGraph } from './graph.js';
5
29
  import { clearRunningMicrovms } from './microvms.js';
6
30
  import { buildNodes, reconcileBuilderImage } from './nodes.js';
7
- import { formatDuration, renderHistoryTable, renderStatusTree, renderSummary, } from './render.js';
31
+ import { formatDuration, logStatusEntries, renderHistoryTable, renderSummary, } from './render.js';
8
32
  import { buildRepoZip, COMMIT_FILE, listRepoFiles } from './repo.js';
9
33
  /** One line per invalidation outcome, shared by the summary card and logs. */
10
34
  function describeInvalidation(inv) {
11
35
  if (inv.mode === 'none')
12
- return 'nothing changed skipped';
36
+ return 'nothing changed - skipped';
13
37
  if (inv.mode === 'paths')
14
38
  return `${inv.count} changed path${inv.count === 1 ? '' : 's'}`;
15
- return inv.count > 0 ? `everything (/*) ${inv.count} paths over cap` : 'everything (/*)';
39
+ return inv.count > 0 ? `everything (/*) - ${inv.count} paths over cap` : 'everything (/*)';
16
40
  }
17
41
  /**
18
42
  * Canonical origin the live site is served from: the custom domain if configured,
@@ -34,11 +58,125 @@ export async function bootstrap(ctx) {
34
58
  if (typeof domain === 'string')
35
59
  ctx.logger.info(`Site will be served at https://${domain}`);
36
60
  }
61
+ /**
62
+ * The prefix every state object - scoped or not - is filed under
63
+ * (`StateStore`, `packages/core/src/state.ts`). Not exported from core, so
64
+ * mirrored here rather than reached for; the guard below only ever reads
65
+ * this prefix, never constructs a key to write.
66
+ */
67
+ const STATE_PREFIX = 'state/';
68
+ /** The site's own unscoped state key - the one object under `STATE_PREFIX` the guard below must never treat as a plugin's. */
69
+ function siteStateKey(env) {
70
+ return `${STATE_PREFIX}${env}.json`;
71
+ }
72
+ /**
73
+ * Every plugin scope with a `state/<env>.<scope>.json` object present under
74
+ * `STATE_PREFIX`, derived from a listing rather than the plugin registry -
75
+ * so {@link assertNoScopedState} holds even for a plugin that has since
76
+ * been uninstalled. Sorted, so the guard's message is deterministic
77
+ * regardless of the order S3 lists objects in.
78
+ */
79
+ function scopedStateScopes(env, objects) {
80
+ const prefix = `${STATE_PREFIX}${env}.`;
81
+ const site = siteStateKey(env);
82
+ const scopes = objects
83
+ .map((o) => o.key)
84
+ .filter((key) => key !== site && key.startsWith(prefix) && key.endsWith('.json'))
85
+ .map((key) => key.slice(prefix.length, -'.json'.length));
86
+ return [...new Set(scopes)].sort();
87
+ }
88
+ /**
89
+ * Refuse to destroy the site while any plugin's own state object still
90
+ * exists in the bucket - §State → Scoped state stores' "`blogwright
91
+ * destroy` therefore refuses while any `state/<env>.<plugin>.json` exists".
92
+ *
93
+ * A scope changes the state object's KEY, not the bucket it lives in
94
+ * (`StateStore`, `packages/core/src/state.ts`) - a scoped and an unscoped
95
+ * store for the same environment are constructed over the very same
96
+ * `names.bucket` - and the site's own bucket node empties every prefix
97
+ * before deleting the bucket (`deletePrefix(ctx.names.bucket, '')`,
98
+ * `nodes.ts`'s `bucketNode().delete()`). Without this guard, a site
99
+ * teardown deletes `state/<env>.<scope>.json` while every resource it
100
+ * records lives on: the plugin's next `destroy` then loads empty state,
101
+ * every node's `read()` returns false, and nothing is removed - the
102
+ * plugin's resources are silently orphaned.
103
+ *
104
+ * Reads the bucket, not the plugin registry, so the refusal holds even for
105
+ * a plugin that has since been uninstalled. Runs inside the teardown verbs
106
+ * themselves, not `createContext` - so no other command pays for the extra
107
+ * `listObjects` call and plugin discovery stays lazy - and ahead of
108
+ * `clearRunningMicrovms`/`destroyGraph` in each, so a refusal has zero side
109
+ * effects: nothing is terminated, nothing is deleted.
110
+ *
111
+ * BOTH teardown verbs call it: `destroy` (below) and `previewTeardown`,
112
+ * which runs the very same `destroyGraph(buildNodes(ctx), ctx)` over the
113
+ * very same `bucketNode().delete()` and so empties the preview stack's
114
+ * bucket - including a `state/<env>.<scope>.json` a plugin bootstrapped
115
+ * against that environment - in exactly the same way. Nothing about the
116
+ * orphaning above is specific to the site's own environment, so nothing
117
+ * about the guard is either.
118
+ *
119
+ * A MISSING BUCKET IS TREATED AS CLEAR, not as an error. `listObjects` does
120
+ * not swallow a 404 the way `getObjectText` does, so an environment whose
121
+ * bucket a previous (interrupted) teardown already deleted would otherwise
122
+ * fail here with a raw `NoSuchBucket` before a single non-bucket resource -
123
+ * roles, log groups, the CloudFront function, the delivery trio - had been
124
+ * cleaned up, on precisely the recovery path an operator reaches for after
125
+ * an interrupted teardown. A bucket that is gone holds no state object at
126
+ * all, scoped or otherwise, so there is nothing to protect and every reason
127
+ * to let the teardown finish: refusing here would CAUSE the orphaning this
128
+ * guard exists to prevent. Only `NoSuchBucket` is treated this way - every
129
+ * other failure (denied, throttled, a network fault) still propagates,
130
+ * because those say nothing about whether scoped state exists.
131
+ *
132
+ * Exported - like `readNodeStatus` above - so a test can pin its outcomes
133
+ * directly against a recording S3 client, rather than only through a full
134
+ * `destroy()` run over the entire production graph.
135
+ */
136
+ export async function assertNoScopedState(ctx) {
137
+ let objects;
138
+ try {
139
+ objects = await ctx.clients.s3.listObjects(ctx.names.bucket, STATE_PREFIX);
140
+ }
141
+ catch (err) {
142
+ // No bucket, no state objects - see this function's doc comment. Matched
143
+ // on the bucket's own error code rather than the broad `isNotFound`,
144
+ // which is equally true of `NoSuchKey` and of ANY 404 (`AwsError`,
145
+ // `packages/core/src/aws/errors.ts`): a spurious 404 from a non-AWS,
146
+ // S3-compatible endpoint (`--endpoint`) would otherwise read as "no
147
+ // scoped state" and let the teardown empty the bucket over a plugin's
148
+ // live state object - the exact orphaning this guard exists to prevent.
149
+ // Every other failure propagates, which ends the teardown safely rather
150
+ // than deleting past the guard.
151
+ if (err instanceof AwsError && err.code === 'NoSuchBucket')
152
+ return;
153
+ throw err;
154
+ }
155
+ const scopes = scopedStateScopes(ctx.env, objects);
156
+ if (scopes.length === 0)
157
+ return;
158
+ // The remedy MUST name the environment. `runPlugin` resolves a plugin
159
+ // command's environment as `values.env ?? envPositional ?? DEFAULT_ENV`
160
+ // with `DEFAULT_ENV = 'production'` (`plugin-commands.ts`), so an env-less
161
+ // `blogwright <scope> destroy --yes` silently targets production - always
162
+ // the wrong environment when this guard fires from `previewTeardown`,
163
+ // which builds `env: 'preview'` unconditionally (`cli.ts`'s `runPreview`).
164
+ // Printed against a preview refusal, the env-less form at best loads empty
165
+ // state and removes nothing, leaving the operator stuck in this same
166
+ // refusal, and at worst tears down the live production stack. `deriveNames`
167
+ // keys off `env` alone, so the positional below is the whole fix - and it
168
+ // covers both callers, because each passes the very environment it is
169
+ // tearing down.
170
+ const lines = scopes.map((scope) => ` - ${scope}: run \`blogwright ${scope} destroy ${ctx.env} --yes\` first`);
171
+ throw new Error(`refusing to destroy "${ctx.env}": ${scopes.length} plugin state object(s) still exist in ` +
172
+ `s3://${ctx.names.bucket}/${STATE_PREFIX}\n${lines.join('\n')}`);
173
+ }
37
174
  /** Destroy the full infrastructure graph. */
38
175
  export async function destroy(ctx, opts) {
39
176
  if (!opts.yes) {
40
177
  throw new Error(`refusing to destroy "${ctx.env}" without --yes`);
41
178
  }
179
+ await assertNoScopedState(ctx);
42
180
  ctx.logger.info(colors.bold(`Destroying "${ctx.env}"`));
43
181
  // Running builder MicroVMs pin the image and make its deletion fail; clear them first
44
182
  // (or let the operator cancel and wait for in-flight builds to finish).
@@ -70,7 +208,7 @@ export async function deploy(ctx, opts = {}) {
70
208
  ...(opts.refresh ? { refresh: true } : {}),
71
209
  });
72
210
  const invalidation = await invalidateChanged(ctx, hash);
73
- // Production content changed mirror it to the PDS (non-fatal; see syncAfterDeploy).
211
+ // Production content changed - mirror it to the PDS (non-fatal; see syncAfterDeploy).
74
212
  await syncAfterDeploy(ctx);
75
213
  const url = siteBaseUrl(ctx);
76
214
  const rows = [
@@ -102,7 +240,7 @@ export async function rollback(ctx, hash, opts = {}) {
102
240
  });
103
241
  await invalidateChanged(ctx, hash);
104
242
  // A rollback changes production content too, but the PDS mirrors the *working tree*
105
- // content, which a rollback does not restore so only warn about the divergence.
243
+ // content, which a rollback does not restore - so only warn about the divergence.
106
244
  if (ctx.env === 'production' && ctx.config.pds) {
107
245
  ctx.logger.warn('rollback does not sync the PDS (records mirror the current repo content); ' +
108
246
  'check out the rolled-back revision and run `blogwright pds sync` if needed');
@@ -151,7 +289,7 @@ export async function previewDeploy(ctx, id, opts = {}) {
151
289
  ctx.logger.ok(`preview ready in ${formatDuration(Date.now() - startedAt)}: ${url}`);
152
290
  return url;
153
291
  }
154
- /** Remove one PR's preview (delete its prefix). No invalidation previews aren't cached. */
292
+ /** Remove one PR's preview (delete its prefix). No invalidation - previews aren't cached. */
155
293
  export async function previewDestroy(ctx, id) {
156
294
  assertPreviewId(id);
157
295
  const count = await ctx.clients.s3.deletePrefix(ctx.names.bucket, `previews/${id}/`);
@@ -172,6 +310,12 @@ export async function previewList(ctx) {
172
310
  export async function previewTeardown(ctx, opts) {
173
311
  if (!opts.yes)
174
312
  throw new Error('refusing to tear down the preview stack without --yes');
313
+ // The preview stack's teardown is the SITE teardown's graph over the
314
+ // preview environment's own bucket - same `destroyGraph(buildNodes(ctx))`,
315
+ // same `bucketNode().delete()` emptying every prefix - so a plugin
316
+ // bootstrapped against this environment is orphaned here in exactly the
317
+ // way `destroy` above is guarded against. See `assertNoScopedState`.
318
+ await assertNoScopedState(ctx);
175
319
  ctx.logger.info(colors.bold('Tearing down preview stack'));
176
320
  if (!(await clearRunningMicrovms(ctx)))
177
321
  return;
@@ -230,7 +374,7 @@ export async function logs(ctx, hash) {
230
374
  manifest = text ? JSON.parse(text) : undefined;
231
375
  }
232
376
  catch {
233
- ctx.logger.warn(`manifest for ${hash} is unreadable showing the unfiltered log window`);
377
+ ctx.logger.warn(`manifest for ${hash} is unreadable - showing the unfiltered log window`);
234
378
  }
235
379
  // Filter to the build's time window (± a minute) from the manifest.
236
380
  const startTime = manifest ? Date.parse(manifest.startedAt) - 60_000 : undefined;
@@ -247,37 +391,40 @@ export async function logs(ctx, hash) {
247
391
  ctx.logger.info(`${colors.dim(new Date(e.timestamp).toISOString())} ${e.message.trimEnd()}`);
248
392
  }
249
393
  }
250
- /** Show the planned graph against live state (drift view). */
251
- export async function status(ctx) {
252
- ctx.logger.info(colors.bold(`Status for "${ctx.env}" (bucket ${ctx.names.bucket})`));
253
- const pretty = ctx.ports.terminal.isInteractive;
394
+ /**
395
+ * Read each node's live status against `ctx.state`: present/missing from
396
+ * `node.read(ctx)`, or an `error` entry carrying the message if it throws.
397
+ * A query, not a command - it never writes to the logger, so a caller (the
398
+ * CLI's own `status` below, and a plugin's `status` verb) decides how to
399
+ * report each entry. Iterates `nodes` in the order given (no `topoSort` -
400
+ * status is a read, not a reconcile, so dependency order doesn't matter).
401
+ */
402
+ export async function readNodeStatus(nodes, ctx) {
254
403
  const entries = [];
255
- for (const node of buildNodes(ctx)) {
404
+ for (const node of nodes) {
256
405
  let exists = false;
257
406
  try {
258
407
  exists = await node.read(ctx);
259
408
  }
260
409
  catch (err) {
261
- if (pretty) {
262
- entries.push({ title: node.title, state: 'error', detail: err.message });
263
- }
264
- else {
265
- ctx.logger.warn(`${node.title}: read failed (${err.message})`);
266
- }
410
+ entries.push({ title: node.title, state: 'error', detail: err.message });
267
411
  continue;
268
412
  }
269
413
  const outputs = ctx.state.resources[node.id];
270
414
  const detail = outputs ? JSON.stringify(outputs) : undefined;
271
- if (pretty) {
272
- entries.push({ title: node.title, state: exists ? 'present' : 'missing', detail });
273
- continue;
274
- }
275
- // The plain form is the stable contract for CI logs and agents.
276
- const mark = exists ? colors.green('present') : colors.yellow('missing');
277
- ctx.logger.info(` ${mark} ${node.title} ${detail ? colors.dim(detail) : ''}`);
278
- }
279
- if (pretty) {
280
- for (const line of renderStatusTree(entries))
281
- ctx.logger.info(line);
415
+ entries.push({ title: node.title, state: exists ? 'present' : 'missing', detail });
282
416
  }
417
+ return entries;
418
+ }
419
+ /**
420
+ * Show the planned graph against live state (drift view). `nodes` defaults to
421
+ * the production graph (`buildNodes(ctx)`) - every real call site is
422
+ * unchanged - but is a parameter, not reached-for, so a test (or a future
423
+ * caller) can hand this the same loop over a different node set without
424
+ * patching a module.
425
+ */
426
+ export async function status(ctx, nodes = buildNodes(ctx)) {
427
+ ctx.logger.info(colors.bold(`Status for "${ctx.env}" (bucket ${ctx.names.bucket})`));
428
+ const entries = await readNodeStatus(nodes, ctx);
429
+ logStatusEntries(entries, ctx.ports.terminal.isInteractive, ctx.logger);
283
430
  }
@@ -0,0 +1,34 @@
1
+ /** The file a block is spliced into. */
2
+ export interface ConfigSource {
3
+ readonly path: string;
4
+ readonly text: string;
5
+ }
6
+ /** A rendered block ready to splice in, and the key it is filed under. */
7
+ export interface ConfigBlock {
8
+ readonly key: string;
9
+ readonly rendered: string;
10
+ }
11
+ /** One entry of a rendered block, matching `renderConfig`'s own entry shape. */
12
+ export interface ConfigBlockEntry {
13
+ readonly prop: string;
14
+ readonly comment?: string | undefined;
15
+ }
16
+ /**
17
+ * Render `entries` as a `"key": { ... }` block in `renderConfig`'s style
18
+ * (`init.ts:42-69`): two-space indent per nesting level, an optional
19
+ * `// comment` suffix per entry, and a comma between entries but never after
20
+ * the last one. The result is meant to be handed to `spliceConfigBlock` as
21
+ * `block.rendered`, already indented as it will appear once inserted as a
22
+ * property of the top-level object.
23
+ */
24
+ export declare function renderConfigBlock(key: string, entries: readonly ConfigBlockEntry[]): string;
25
+ /**
26
+ * Splice `block.rendered` into `source.text` as a new property of the
27
+ * document's single top-level object, immediately before its closing brace.
28
+ * The document is scanned, never reparsed: every byte outside the inserted
29
+ * region - comments, indentation, trailing commas - comes back unchanged.
30
+ *
31
+ * Refuses rather than guessing when `block.key` is already present on the
32
+ * object, or when the document is not shaped as a single top-level object.
33
+ */
34
+ export declare function spliceConfigBlock(source: ConfigSource, block: ConfigBlock): string;
@@ -0,0 +1,262 @@
1
+ /*
2
+ * Textual splice of a plugin's config block into an existing JSONC document.
3
+ * Config files carry meaningful comments written by the wizard (`init.ts:42`
4
+ * `renderConfig`), so the document is never round-tripped through a parse
5
+ * and a re-stringify here - doing that would discard every one of them.
6
+ * Instead this module scans the raw text with the same string- and
7
+ * comment-aware discipline as `stripJsonComments`
8
+ * (`packages/core/src/config.ts:153`) to find exactly where a new top-level
9
+ * key belongs, and splices the rendered block in around it, leaving every
10
+ * other byte of the file untouched.
11
+ */
12
+ /**
13
+ * Render `entries` as a `"key": { ... }` block in `renderConfig`'s style
14
+ * (`init.ts:42-69`): two-space indent per nesting level, an optional
15
+ * `// comment` suffix per entry, and a comma between entries but never after
16
+ * the last one. The result is meant to be handed to `spliceConfigBlock` as
17
+ * `block.rendered`, already indented as it will appear once inserted as a
18
+ * property of the top-level object.
19
+ */
20
+ export function renderConfigBlock(key, entries) {
21
+ if (entries.length === 0)
22
+ return ` "${key}": {}`;
23
+ const body = entries.map((entry, i) => {
24
+ const comma = i < entries.length - 1 ? ',' : '';
25
+ const comment = entry.comment ? ` // ${entry.comment}` : '';
26
+ return ` ${entry.prop}${comma}${comment}`;
27
+ });
28
+ return [` "${key}": {`, ...body, ' }'].join('\n');
29
+ }
30
+ /** Decode a JSON string literal's escapes by hand, without parsing it as JSON. */
31
+ function decodeStringLiteral(raw) {
32
+ let out = '';
33
+ for (let i = 0; i < raw.length; i++) {
34
+ const ch = raw[i];
35
+ if (ch !== '\\') {
36
+ out += ch;
37
+ continue;
38
+ }
39
+ const esc = raw[++i];
40
+ switch (esc) {
41
+ case 'n':
42
+ out += '\n';
43
+ break;
44
+ case 't':
45
+ out += '\t';
46
+ break;
47
+ case 'r':
48
+ out += '\r';
49
+ break;
50
+ case 'b':
51
+ out += '\b';
52
+ break;
53
+ case 'f':
54
+ out += '\f';
55
+ break;
56
+ case 'u':
57
+ out += String.fromCharCode(Number.parseInt(raw.slice(i + 1, i + 5), 16));
58
+ i += 4;
59
+ break;
60
+ default:
61
+ out += esc ?? '';
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function shapeError(path, found) {
67
+ return new Error(`${path}: expected a single top-level JSON object, found ${found} - insert the block by hand instead`);
68
+ }
69
+ /**
70
+ * Scan `source.text` once, string- and comment-aware like `stripJsonComments`,
71
+ * to find the single top-level object's opening and closing brace, the last
72
+ * significant (non-whitespace, non-comment) character before that closing
73
+ * brace, and the set of keys already declared directly on that object. Raises
74
+ * rather than guessing when the document is not shaped that way.
75
+ *
76
+ * Key-position tracking (`expectKey`) is deliberately generic: opening any
77
+ * object, or a comma inside one, expects a key at *that* depth, not only at
78
+ * the top - the same test that decides whether a value is a key at all also
79
+ * runs for every nested object. `depth === 1` is the only thing that then
80
+ * scopes a found key into `topLevelKeys`, so a `"pds"` nested three objects
81
+ * down is read as a key (of its own object) and correctly not recorded as
82
+ * one of the document's own.
83
+ */
84
+ function scanTopLevelObject(source) {
85
+ const { path, text } = source;
86
+ let inString = false;
87
+ let inLine = false;
88
+ let inBlock = false;
89
+ let depth = 0;
90
+ let openIndex = -1;
91
+ let closeIndex = -1;
92
+ let lastSignificantIndex = -1;
93
+ let stringStart = -1;
94
+ let expectKey = false;
95
+ const containers = [];
96
+ const topLevelKeys = new Set();
97
+ for (let i = 0; i < text.length; i++) {
98
+ const ch = text[i];
99
+ const next = text[i + 1];
100
+ if (inLine) {
101
+ if (ch === '\n')
102
+ inLine = false;
103
+ continue;
104
+ }
105
+ if (inBlock) {
106
+ if (ch === '*' && next === '/') {
107
+ inBlock = false;
108
+ i++;
109
+ }
110
+ continue;
111
+ }
112
+ if (inString) {
113
+ if (ch === '\\') {
114
+ i++;
115
+ continue;
116
+ }
117
+ if (ch === '"') {
118
+ inString = false;
119
+ lastSignificantIndex = i;
120
+ if (expectKey) {
121
+ if (depth === 1)
122
+ topLevelKeys.add(decodeStringLiteral(text.slice(stringStart + 1, i)));
123
+ expectKey = false;
124
+ }
125
+ }
126
+ continue;
127
+ }
128
+ if (ch === '/' && next === '/') {
129
+ inLine = true;
130
+ i++;
131
+ continue;
132
+ }
133
+ if (ch === '/' && next === '*') {
134
+ inBlock = true;
135
+ i++;
136
+ continue;
137
+ }
138
+ if (/\s/.test(ch))
139
+ continue;
140
+ // `ch` is now a significant character outside any string or comment.
141
+ if (openIndex === -1) {
142
+ if (ch !== '{')
143
+ throw shapeError(path, ch === '[' ? 'an array' : 'a bare value');
144
+ openIndex = i;
145
+ lastSignificantIndex = i;
146
+ depth = 1;
147
+ containers.push('object');
148
+ expectKey = true;
149
+ continue;
150
+ }
151
+ if (closeIndex !== -1)
152
+ throw shapeError(path, 'a second top-level value');
153
+ if (ch === '"') {
154
+ inString = true;
155
+ stringStart = i;
156
+ continue;
157
+ }
158
+ if (ch === '{' || ch === '[') {
159
+ depth++;
160
+ lastSignificantIndex = i;
161
+ containers.push(ch === '{' ? 'object' : 'array');
162
+ expectKey = ch === '{';
163
+ continue;
164
+ }
165
+ if (ch === '}' || ch === ']') {
166
+ depth--;
167
+ containers.pop();
168
+ if (depth === 0) {
169
+ closeIndex = i;
170
+ }
171
+ else {
172
+ lastSignificantIndex = i;
173
+ }
174
+ continue;
175
+ }
176
+ if (ch === ',') {
177
+ lastSignificantIndex = i;
178
+ expectKey = containers[containers.length - 1] === 'object';
179
+ continue;
180
+ }
181
+ lastSignificantIndex = i;
182
+ }
183
+ if (openIndex === -1)
184
+ throw shapeError(path, 'an empty document');
185
+ if (closeIndex === -1) {
186
+ throw shapeError(path, inString ? 'an unterminated string inside the object' : 'an unterminated object');
187
+ }
188
+ return { openIndex, closeIndex, lastSignificantIndex, topLevelKeys };
189
+ }
190
+ /**
191
+ * Starting at `from` (just past the last entry's value or its trailing
192
+ * comma), skip past a comment that trails on the *same line* - `"x" //
193
+ * note` or `"x" /* note *\/` - so that comment stays attached to the entry
194
+ * it documents instead of being displaced by whatever gets spliced in
195
+ * after it. Stops at the first line break, at `limit` (the object's closing
196
+ * brace), or at the first character that is neither trailing whitespace nor
197
+ * the start of such a comment.
198
+ */
199
+ function skipTrailingComment(text, from, limit) {
200
+ let i = from;
201
+ for (;;) {
202
+ while (i < limit && (text[i] === ' ' || text[i] === '\t'))
203
+ i++;
204
+ if (i >= limit || text[i] === '\n' || text[i] === '\r')
205
+ return i;
206
+ if (text[i] === '/' && text[i + 1] === '/') {
207
+ i += 2;
208
+ while (i < limit && text[i] !== '\n' && text[i] !== '\r')
209
+ i++;
210
+ return i;
211
+ }
212
+ if (text[i] === '/' && text[i + 1] === '*') {
213
+ const end = text.indexOf('*/', i + 2);
214
+ i = end === -1 || end + 2 > limit ? limit : end + 2;
215
+ continue;
216
+ }
217
+ return i;
218
+ }
219
+ }
220
+ /** True when `text` has a line break (LF or CRLF) starting at `at`. */
221
+ function startsWithNewline(text, at) {
222
+ return text[at] === '\n' || (text[at] === '\r' && text[at + 1] === '\n');
223
+ }
224
+ /**
225
+ * Splice `block.rendered` into `source.text` as a new property of the
226
+ * document's single top-level object, immediately before its closing brace.
227
+ * The document is scanned, never reparsed: every byte outside the inserted
228
+ * region - comments, indentation, trailing commas - comes back unchanged.
229
+ *
230
+ * Refuses rather than guessing when `block.key` is already present on the
231
+ * object, or when the document is not shaped as a single top-level object.
232
+ */
233
+ export function spliceConfigBlock(source, block) {
234
+ const { text, path } = source;
235
+ const scan = scanTopLevelObject(source);
236
+ if (scan.topLevelKeys.has(block.key)) {
237
+ throw new Error(`${path} already declares a "${block.key}" key - edit the file directly instead of ` +
238
+ 'writing a new block for it');
239
+ }
240
+ // The comma belongs immediately after the last entry's own value, but the
241
+ // new block belongs after that entry's trailing comment (if any) - an
242
+ // operator's `// comment` on the last line documents that entry, not
243
+ // whatever gets spliced in next, and must not be pushed onto its own line
244
+ // in between.
245
+ const commaIndex = scan.lastSignificantIndex + 1;
246
+ const hasEntries = scan.lastSignificantIndex > scan.openIndex;
247
+ const hasTrailingComma = hasEntries && text[scan.lastSignificantIndex] === ',';
248
+ const commaPrefix = hasEntries && !hasTrailingComma ? ',' : '';
249
+ const blockIndex = skipTrailingComment(text, commaIndex, scan.closeIndex);
250
+ // Match the document's own line-ending convention rather than always
251
+ // injecting a bare `\n`, so a CRLF file does not end up with a mixed-ending
252
+ // splice or a spurious blank line before the closing brace.
253
+ const newline = text.includes('\r\n') ? '\r\n' : '\n';
254
+ const rendered = newline === '\n' ? block.rendered : block.rendered.replaceAll('\n', newline);
255
+ const needsTrailingNewline = !startsWithNewline(text, blockIndex);
256
+ const insertion = `${newline}${rendered}${needsTrailingNewline ? newline : ''}`;
257
+ return (text.slice(0, commaIndex) +
258
+ commaPrefix +
259
+ text.slice(commaIndex, blockIndex) +
260
+ insertion +
261
+ text.slice(blockIndex));
262
+ }