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.
- 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 -3
- 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 -5
- package/dist/agent-package.d.ts +1 -1
- package/dist/agent-package.js +4 -3
- package/dist/bin.js +13 -2
- package/dist/cli.d.ts +68 -1
- package/dist/cli.js +270 -88
- package/dist/commands.d.ts +70 -3
- package/dist/commands.js +177 -30
- 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 -18
- package/dist/deploy.d.ts +2 -2
- package/dist/deploy.js +12 -12
- package/dist/graph.d.ts +27 -16
- package/dist/graph.js +1 -1
- package/dist/init.d.ts +37 -3
- package/dist/init.js +146 -22
- package/dist/known-commands.d.ts +63 -0
- package/dist/known-commands.js +78 -0
- package/dist/microvms.d.ts +2 -2
- package/dist/microvms.js +3 -3
- package/dist/nodes.d.ts +5 -3
- package/dist/nodes.js +97 -30
- 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/render.d.ts +55 -0
- package/dist/render.js +89 -1
- package/dist/repo.d.ts +3 -3
- package/dist/repo.js +8 -8
- package/dist/seo.d.ts +1 -1
- package/dist/seo.js +1 -1
- package/package.json +6 -6
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
37
|
-
|
|
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,
|
|
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
|
|
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 (/*)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|