blogwright 0.3.2 → 0.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +11 -11
  2. package/agent/agent-manifest.json +1 -1
  3. package/agent/server.js +37 -19
  4. package/dist/adapters/fetch-ping.d.ts +1 -1
  5. package/dist/adapters/fetch-ping.js +3 -4
  6. package/dist/adapters/node-module-loader.d.ts +11 -0
  7. package/dist/adapters/node-module-loader.js +146 -0
  8. package/dist/adapters/process-package-manager.d.ts +41 -0
  9. package/dist/adapters/process-package-manager.js +116 -0
  10. package/dist/adapters/process-vcs.d.ts +5 -4
  11. package/dist/adapters/process-vcs.js +6 -6
  12. package/dist/agent-package.d.ts +1 -1
  13. package/dist/agent-package.js +4 -4
  14. package/dist/bin.js +13 -3
  15. package/dist/cli.d.ts +68 -1
  16. package/dist/cli.js +270 -89
  17. package/dist/commands.d.ts +70 -3
  18. package/dist/commands.js +180 -32
  19. package/dist/config-block.d.ts +34 -0
  20. package/dist/config-block.js +262 -0
  21. package/dist/context.d.ts +76 -6
  22. package/dist/context.js +98 -19
  23. package/dist/deploy.d.ts +2 -2
  24. package/dist/deploy.js +14 -14
  25. package/dist/graph.d.ts +27 -16
  26. package/dist/graph.js +1 -2
  27. package/dist/init.d.ts +37 -3
  28. package/dist/init.js +146 -23
  29. package/dist/known-commands.d.ts +63 -0
  30. package/dist/known-commands.js +78 -0
  31. package/dist/logger.js +0 -1
  32. package/dist/microvms.d.ts +2 -2
  33. package/dist/microvms.js +3 -4
  34. package/dist/nodes.d.ts +5 -3
  35. package/dist/nodes.js +97 -31
  36. package/dist/plugin-commands.d.ts +298 -0
  37. package/dist/plugin-commands.js +990 -0
  38. package/dist/plugins.d.ts +194 -0
  39. package/dist/plugins.js +523 -0
  40. package/dist/ports.d.ts +89 -1
  41. package/dist/ports.js +0 -1
  42. package/dist/render.d.ts +55 -0
  43. package/dist/render.js +89 -2
  44. package/dist/repo.d.ts +3 -3
  45. package/dist/repo.js +8 -9
  46. package/dist/rkey.js +0 -1
  47. package/dist/seo.d.ts +1 -1
  48. package/dist/seo.js +1 -2
  49. package/package.json +6 -6
  50. package/dist/adapters/fetch-ping.js.map +0 -1
  51. package/dist/adapters/process-vcs.js.map +0 -1
  52. package/dist/agent-package.js.map +0 -1
  53. package/dist/bin.js.map +0 -1
  54. package/dist/cli.js.map +0 -1
  55. package/dist/commands.js.map +0 -1
  56. package/dist/context.js.map +0 -1
  57. package/dist/deploy.js.map +0 -1
  58. package/dist/graph.js.map +0 -1
  59. package/dist/init.js.map +0 -1
  60. package/dist/logger.js.map +0 -1
  61. package/dist/microvms.js.map +0 -1
  62. package/dist/nodes.js.map +0 -1
  63. package/dist/ports.js.map +0 -1
  64. package/dist/render.js.map +0 -1
  65. package/dist/repo.js.map +0 -1
  66. package/dist/rkey.js.map +0 -1
  67. package/dist/seo.js.map +0 -1
  68. package/dist/test-support.d.ts +0 -45
  69. package/dist/test-support.js +0 -126
  70. package/dist/test-support.js.map +0 -1
@@ -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;
@@ -205,7 +349,9 @@ export async function history(ctx) {
205
349
  ctx.logger.warn(`skipping unreadable manifest ${obj.key}`);
206
350
  }
207
351
  }
208
- manifests.sort((a, b) => b.finishedAt.localeCompare(a.finishedAt));
352
+ // Newest first. Codepoint sort, not localeCompare: collation must not depend on host locale/ICU
353
+ // (finishedAt is ISO-8601, which orders correctly by codepoint).
354
+ manifests.sort((a, b) => a.finishedAt > b.finishedAt ? -1 : a.finishedAt < b.finishedAt ? 1 : 0);
209
355
  if (ctx.ports.terminal.isInteractive) {
210
356
  for (const line of renderHistoryTable(manifests, Date.now()))
211
357
  ctx.logger.info(line);
@@ -228,7 +374,7 @@ export async function logs(ctx, hash) {
228
374
  manifest = text ? JSON.parse(text) : undefined;
229
375
  }
230
376
  catch {
231
- 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`);
232
378
  }
233
379
  // Filter to the build's time window (± a minute) from the manifest.
234
380
  const startTime = manifest ? Date.parse(manifest.startedAt) - 60_000 : undefined;
@@ -245,38 +391,40 @@ export async function logs(ctx, hash) {
245
391
  ctx.logger.info(`${colors.dim(new Date(e.timestamp).toISOString())} ${e.message.trimEnd()}`);
246
392
  }
247
393
  }
248
- /** Show the planned graph against live state (drift view). */
249
- export async function status(ctx) {
250
- ctx.logger.info(colors.bold(`Status for "${ctx.env}" (bucket ${ctx.names.bucket})`));
251
- 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) {
252
403
  const entries = [];
253
- for (const node of buildNodes(ctx)) {
404
+ for (const node of nodes) {
254
405
  let exists = false;
255
406
  try {
256
407
  exists = await node.read(ctx);
257
408
  }
258
409
  catch (err) {
259
- if (pretty) {
260
- entries.push({ title: node.title, state: 'error', detail: err.message });
261
- }
262
- else {
263
- ctx.logger.warn(`${node.title}: read failed (${err.message})`);
264
- }
410
+ entries.push({ title: node.title, state: 'error', detail: err.message });
265
411
  continue;
266
412
  }
267
413
  const outputs = ctx.state.resources[node.id];
268
414
  const detail = outputs ? JSON.stringify(outputs) : undefined;
269
- if (pretty) {
270
- entries.push({ title: node.title, state: exists ? 'present' : 'missing', detail });
271
- continue;
272
- }
273
- // The plain form is the stable contract for CI logs and agents.
274
- const mark = exists ? colors.green('present') : colors.yellow('missing');
275
- ctx.logger.info(` ${mark} ${node.title} ${detail ? colors.dim(detail) : ''}`);
276
- }
277
- if (pretty) {
278
- for (const line of renderStatusTree(entries))
279
- ctx.logger.info(line);
415
+ entries.push({ title: node.title, state: exists ? 'present' : 'missing', detail });
280
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);
281
430
  }
282
- //# sourceMappingURL=commands.js.map
@@ -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;