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
@@ -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
+ }
package/dist/context.d.ts CHANGED
@@ -7,13 +7,26 @@ export interface OpsContext {
7
7
  /** True for the shared preview stack (host-routed, per-PR prefixes). */
8
8
  preview: boolean;
9
9
  config: OpsConfig;
10
+ /**
11
+ * The environment's config file exactly as parsed, before `OpsConfig`'s
12
+ * merge and validation - every top-level key the document carries, a
13
+ * plugin's own block included. `OpsConfig` has no index signature, so this
14
+ * is the only typed route to `config[plugin.configKey]` (see
15
+ * `blogwright-core`'s `parseConfigDocument`).
16
+ *
17
+ * CLI-side ONLY, and deliberately absent from `PluginContext`: dispatch
18
+ * reads the DISPATCHED plugin's block off this and hands that one block to
19
+ * that one plugin (`resolvePluginConfig`, `plugins.ts`), so no plugin ever
20
+ * sees another plugin's config.
21
+ */
22
+ configDocument: Readonly<Record<string, unknown>>;
10
23
  names: Names;
11
24
  accountId: string;
12
25
  clients: AwsClients;
13
26
  ports: Ports;
14
27
  /**
15
- * Directory holding the build-agent artifacts Dockerfile, bundled server.js,
16
- * and agent-manifest.json copied into this package by its build
28
+ * Directory holding the build-agent artifacts - Dockerfile, bundled server.js,
29
+ * and agent-manifest.json - copied into this package by its build
17
30
  * (scripts/copy-agent.mjs). Resolved at the composition root; tests inject one.
18
31
  */
19
32
  agentDir: string;
@@ -30,7 +43,7 @@ export interface OpsContext {
30
43
  }
31
44
  /**
32
45
  * The `app` tag value, by precedence: the explicit `config.app`, else the
33
- * site's domain, else the repo directory name always something a human can
46
+ * site's domain, else the repo directory name - always something a human can
34
47
  * trace back to the project from a billing or resource listing.
35
48
  */
36
49
  export declare function deriveAppTag(config: Pick<OpsConfig, 'app'>, domain: string | undefined, repoRoot: string): string;
@@ -43,6 +56,42 @@ export interface ContextOptions {
43
56
  /** Adapter overrides; anything omitted defaults to the real (node) adapter. */
44
57
  ports?: Partial<Ports> | undefined;
45
58
  }
59
+ /**
60
+ * The directory holding the CLI's own `package.json` - `blogwright`'s package
61
+ * root. Located from `import.meta.url` the same way {@link OpsContext.agentDir}
62
+ * is (below): `packages/cli/package.json` declares an `exports` map with a
63
+ * `./rkey` entry and no `.` entry (the CLI is consumed through its `bin`, not
64
+ * imported), so neither `blogwright` nor `blogwright/package.json` can be
65
+ * resolved through the `ModuleLoader` port - see that port's doc comment.
66
+ * Self-location is therefore a composition-root concern, not something
67
+ * `discover` (`plugins.ts`) can derive itself.
68
+ *
69
+ * A standalone function, not folded into {@link createContext}: `blogwright
70
+ * plugin list` dispatches before a context exists and still needs this
71
+ * value, and it is the one supplier every discovery-running path (plugin
72
+ * dispatch, `blogwright --help`, the init wizard, `plugin list`) passes as
73
+ * `discover`'s second argument.
74
+ */
75
+ export declare function cliPackageDir(): string;
76
+ /**
77
+ * The running CLI's own declared version - the value `blogwright plugin add`
78
+ * (`plugin-commands.ts`) pins into the install spec, so a plugin and the CLI
79
+ * that dispatches it can never silently drift apart across two developers'
80
+ * checkouts.
81
+ *
82
+ * Read HERE, at the composition root, for exactly the reason
83
+ * {@link cliPackageDir} is resolved here: `blogwright`'s own `exports` map has
84
+ * no `.` entry, so neither the package nor its `package.json` can be reached
85
+ * through the `ModuleLoader` port, and this module is one of the few the
86
+ * `no-restricted-imports` rule lets touch `node:fs` at all. `plugin-commands.ts`
87
+ * receives the resolved string as DATA and never walks the filesystem for it -
88
+ * the same division `agentDir` already makes.
89
+ *
90
+ * Read on demand rather than at module load: `blogwright plugin list` and every
91
+ * built-in command share this module and none of them needs the value, so only
92
+ * the one command that pins a version pays for the read.
93
+ */
94
+ export declare function cliVersion(): Promise<string>;
46
95
  export interface ConfigSource {
47
96
  env: string;
48
97
  /** Repo root the default config candidates resolve against. */
@@ -50,12 +99,33 @@ export interface ConfigSource {
50
99
  /** Explicit config file; when set it is the only candidate. */
51
100
  configPath?: string | undefined;
52
101
  }
53
- /** Load and parse the first config candidate that exists. Exported for tests. */
54
- export declare function loadConfig(fs: FileSystem, source: ConfigSource): Promise<OpsConfig>;
102
+ /**
103
+ * Resolve the first config candidate that exists, in `configCandidates`'
104
+ * precedence. Throws, naming every candidate it looked for, when none does -
105
+ * the same message `loadConfig` has always raised on this path.
106
+ *
107
+ * Exported so `blogwright <plugin> init` (`plugin-commands.ts`) writes its
108
+ * spliced block into exactly the file `loadConfig` would read, rather than
109
+ * re-deriving the candidate list a second time.
110
+ */
111
+ export declare function resolveConfigPath(fs: FileSystem, source: ConfigSource): Promise<string>;
112
+ /**
113
+ * Load and parse the first config candidate that exists, returning BOTH
114
+ * halves `parseConfigDocument` produces: the validated `config` every
115
+ * built-in command reads, and the `raw` document the dispatch path reads a
116
+ * plugin's own block out of. The candidate list and its precedence are
117
+ * unchanged - only the return type widens, so `createContext` can keep the
118
+ * raw half on {@link OpsContext.configDocument} instead of the file having to
119
+ * be read and parsed a second time at dispatch. Exported for tests.
120
+ */
121
+ export declare function loadConfig(fs: FileSystem, source: ConfigSource): Promise<{
122
+ config: OpsConfig;
123
+ raw: Readonly<Record<string, unknown>>;
124
+ }>;
55
125
  /**
56
126
  * Build the runtime context: load config, resolve the account id, derive names, create
57
127
  * clients, and load topology state from S3. The state bucket name is deterministic, which
58
- * resolves the bootstrap chicken-and-egg. This is the composition root the only place
128
+ * resolves the bootstrap chicken-and-egg. This is the composition root - the only place
59
129
  * real adapters are constructed and wired.
60
130
  */
61
131
  export declare function createContext(opts: ContextOptions): Promise<OpsContext>;
package/dist/context.js CHANGED
@@ -1,53 +1,132 @@
1
- import { basename, resolve } from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, join, resolve } from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
- import { createClients, createNodeFileSystem, createNodeTerminal, deriveNames, FileNotFoundError, findRepoRoot, parseConfig, StateStore, } from 'blogwright-core';
4
+ import { createClients, createNodeFileSystem, createNodeTerminal, deriveNames, findRepoRoot, parseConfigDocument, StateStore, } from 'blogwright-core';
4
5
  import { createFetchPing } from './adapters/fetch-ping.js';
6
+ import { createNodeModuleLoader } from './adapters/node-module-loader.js';
5
7
  import { createProcessVcs } from './adapters/process-vcs.js';
6
8
  import { createLogger } from './logger.js';
7
9
  /**
8
10
  * The `app` tag value, by precedence: the explicit `config.app`, else the
9
- * site's domain, else the repo directory name always something a human can
11
+ * site's domain, else the repo directory name - always something a human can
10
12
  * trace back to the project from a billing or resource listing.
11
13
  */
12
14
  export function deriveAppTag(config, domain, repoRoot) {
13
15
  return config.app ?? domain ?? basename(repoRoot);
14
16
  }
15
- /** Load and parse the first config candidate that exists. Exported for tests. */
16
- export async function loadConfig(fs, source) {
17
- const candidates = source.configPath
17
+ /**
18
+ * The directory holding the CLI's own `package.json` - `blogwright`'s package
19
+ * root. Located from `import.meta.url` the same way {@link OpsContext.agentDir}
20
+ * is (below): `packages/cli/package.json` declares an `exports` map with a
21
+ * `./rkey` entry and no `.` entry (the CLI is consumed through its `bin`, not
22
+ * imported), so neither `blogwright` nor `blogwright/package.json` can be
23
+ * resolved through the `ModuleLoader` port - see that port's doc comment.
24
+ * Self-location is therefore a composition-root concern, not something
25
+ * `discover` (`plugins.ts`) can derive itself.
26
+ *
27
+ * A standalone function, not folded into {@link createContext}: `blogwright
28
+ * plugin list` dispatches before a context exists and still needs this
29
+ * value, and it is the one supplier every discovery-running path (plugin
30
+ * dispatch, `blogwright --help`, the init wizard, `plugin list`) passes as
31
+ * `discover`'s second argument.
32
+ */
33
+ export function cliPackageDir() {
34
+ // `new URL('..', …)` yields a trailing separator, unlike every other directory
35
+ // value in the CLI. join() and createRequire() tolerate it, but a caller
36
+ // writing `${cliPackageDir()}/x` would get a doubled separator - in the path
37
+ // and in any error message built from it. Normalise here so no caller has to
38
+ // remember. Four discovery-running paths (tasks 10, 11, 14, 17) consume this.
39
+ return resolve(fileURLToPath(new URL('..', import.meta.url)));
40
+ }
41
+ /** Narrow parsed JSON to an object before reading a field off it - no cast, no `any`. */
42
+ function isRecord(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
44
+ }
45
+ /**
46
+ * The running CLI's own declared version - the value `blogwright plugin add`
47
+ * (`plugin-commands.ts`) pins into the install spec, so a plugin and the CLI
48
+ * that dispatches it can never silently drift apart across two developers'
49
+ * checkouts.
50
+ *
51
+ * Read HERE, at the composition root, for exactly the reason
52
+ * {@link cliPackageDir} is resolved here: `blogwright`'s own `exports` map has
53
+ * no `.` entry, so neither the package nor its `package.json` can be reached
54
+ * through the `ModuleLoader` port, and this module is one of the few the
55
+ * `no-restricted-imports` rule lets touch `node:fs` at all. `plugin-commands.ts`
56
+ * receives the resolved string as DATA and never walks the filesystem for it -
57
+ * the same division `agentDir` already makes.
58
+ *
59
+ * Read on demand rather than at module load: `blogwright plugin list` and every
60
+ * built-in command share this module and none of them needs the value, so only
61
+ * the one command that pins a version pays for the read.
62
+ */
63
+ export async function cliVersion() {
64
+ const path = join(cliPackageDir(), 'package.json');
65
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
66
+ const version = isRecord(parsed) ? parsed.version : undefined;
67
+ if (typeof version !== 'string' || version.length === 0) {
68
+ throw new Error(`${path} declares no "version" - \`blogwright plugin add\` pins the installed plugin to ` +
69
+ "the running CLI's own version and has nothing to pin to");
70
+ }
71
+ return version;
72
+ }
73
+ /** Candidate config paths, in the precedence `loadConfig`/`resolveConfigPath` read them: an explicit `--config`, or `config/<env>.jsonc` then `ops.config.jsonc`. */
74
+ function configCandidates(source) {
75
+ return source.configPath
18
76
  ? [source.configPath]
19
77
  : [
20
78
  resolve(source.root, `config/${source.env}.jsonc`),
21
79
  resolve(source.root, 'ops.config.jsonc'),
22
80
  ];
81
+ }
82
+ /**
83
+ * Resolve the first config candidate that exists, in `configCandidates`'
84
+ * precedence. Throws, naming every candidate it looked for, when none does -
85
+ * the same message `loadConfig` has always raised on this path.
86
+ *
87
+ * Exported so `blogwright <plugin> init` (`plugin-commands.ts`) writes its
88
+ * spliced block into exactly the file `loadConfig` would read, rather than
89
+ * re-deriving the candidate list a second time.
90
+ */
91
+ export async function resolveConfigPath(fs, source) {
92
+ const candidates = configCandidates(source);
23
93
  for (const path of candidates) {
24
- try {
25
- return parseConfig(await fs.readText(path));
26
- }
27
- catch (err) {
28
- if (!(err instanceof FileNotFoundError))
29
- throw err;
30
- }
94
+ if (await fs.exists(path))
95
+ return path;
31
96
  }
32
- throw new Error(`no config found for environment "${source.env}" looked for ${candidates.join(', ')}`);
97
+ throw new Error(`no config found for environment "${source.env}" - looked for ${candidates.join(', ')}`);
98
+ }
99
+ /**
100
+ * Load and parse the first config candidate that exists, returning BOTH
101
+ * halves `parseConfigDocument` produces: the validated `config` every
102
+ * built-in command reads, and the `raw` document the dispatch path reads a
103
+ * plugin's own block out of. The candidate list and its precedence are
104
+ * unchanged - only the return type widens, so `createContext` can keep the
105
+ * raw half on {@link OpsContext.configDocument} instead of the file having to
106
+ * be read and parsed a second time at dispatch. Exported for tests.
107
+ */
108
+ export async function loadConfig(fs, source) {
109
+ return parseConfigDocument(await fs.readText(await resolveConfigPath(fs, source)));
33
110
  }
34
111
  /**
35
112
  * Build the runtime context: load config, resolve the account id, derive names, create
36
113
  * clients, and load topology state from S3. The state bucket name is deterministic, which
37
- * resolves the bootstrap chicken-and-egg. This is the composition root the only place
114
+ * resolves the bootstrap chicken-and-egg. This is the composition root - the only place
38
115
  * real adapters are constructed and wired.
39
116
  */
40
117
  export async function createContext(opts) {
118
+ const fs = opts.ports?.fs ?? createNodeFileSystem();
41
119
  const ports = {
42
- fs: opts.ports?.fs ?? createNodeFileSystem(),
120
+ fs,
43
121
  vcs: opts.ports?.vcs ?? createProcessVcs(),
44
122
  terminal: opts.ports?.terminal ?? createNodeTerminal(),
45
123
  ping: opts.ports?.ping ?? createFetchPing(),
124
+ loader: opts.ports?.loader ?? createNodeModuleLoader(),
46
125
  };
47
126
  const logger = createLogger(ports.terminal);
48
- const agentDir = fileURLToPath(new URL('../agent', import.meta.url));
127
+ const agentDir = join(cliPackageDir(), 'agent');
49
128
  const root = await findRepoRoot(ports.fs);
50
- const config = await loadConfig(ports.fs, {
129
+ const { config, raw: configDocument } = await loadConfig(ports.fs, {
51
130
  env: opts.env,
52
131
  root,
53
132
  configPath: opts.configPath,
@@ -68,6 +147,7 @@ export async function createContext(opts) {
68
147
  domain,
69
148
  preview: opts.preview ?? false,
70
149
  config,
150
+ configDocument,
71
151
  names,
72
152
  accountId,
73
153
  clients,
@@ -83,4 +163,3 @@ export async function createContext(opts) {
83
163
  };
84
164
  return ctx;
85
165
  }
86
- //# sourceMappingURL=context.js.map
package/dist/deploy.d.ts CHANGED
@@ -19,7 +19,7 @@ declare function manifestKey(hash: string): string;
19
19
  /**
20
20
  * Launch the builder MicroVM, retrying gateway errors (502/503/504) with a
21
21
  * bounded backoff. The control plane can answer 502 for a short window right
22
- * after the builder image was updated (fresh agent hash) an
22
+ * after the builder image was updated (fresh agent hash) - an
23
23
  * eventual-consistency gap that would otherwise fail every consumer's first
24
24
  * deploy after a blogwright upgrade. Retrying is safe: the input's client
25
25
  * token makes the launch idempotent. Exported for tests.
@@ -29,7 +29,7 @@ export declare function runMicrovmWithRetry(ctx: OpsContext, input: RunMicrovmIn
29
29
  * Resolve the MicroVM build log group from bootstrapped state rather than re-deriving it.
30
30
  * The image and the build-role IAM policy bake this name in at bootstrap, so if the derived
31
31
  * name (`ctx.names.microvmLogGroup`) is renamed in code later, a deploy must still target
32
- * the group the running stack actually logs to otherwise the VM's logs land in (or are
32
+ * the group the running stack actually logs to - otherwise the VM's logs land in (or are
33
33
  * denied to) a different group and `pollBuild` waits on an empty one.
34
34
  */
35
35
  export declare function microvmLogGroup(ctx: OpsContext): string;
package/dist/deploy.js CHANGED
@@ -7,8 +7,8 @@ function manifestKey(hash) {
7
7
  }
8
8
  /**
9
9
  * Nudge the MicroVM's endpoint to wake its event loop. A Firecracker-resumed process
10
- * can sit idle with its poll timer pending until some I/O arrives; a connection here
11
- * even one the agent's HTTP/1 server can't fully parse wakes the loop so the timer
10
+ * can sit idle with its poll timer pending until some I/O arrives; a connection here -
11
+ * even one the agent's HTTP/1 server can't fully parse - wakes the loop so the timer
12
12
  * fires and the build starts. The wake-up, not the response, is the point: a missing
13
13
  * endpoint or token means nothing to nudge, and a rejecting ping never fails the poll.
14
14
  */
@@ -20,12 +20,13 @@ async function nudge(ctx, endpoint, token) {
20
20
  /** Backoff between MicroVM-launch retries; the total window (~2m) bounds the wait. */
21
21
  const RUN_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000, 30_000];
22
22
  function isGatewayError(err) {
23
- return err instanceof AwsError && (err.statusCode === 502 || err.statusCode === 503 || err.statusCode === 504);
23
+ return (err instanceof AwsError &&
24
+ (err.statusCode === 502 || err.statusCode === 503 || err.statusCode === 504));
24
25
  }
25
26
  /**
26
27
  * Launch the builder MicroVM, retrying gateway errors (502/503/504) with a
27
28
  * bounded backoff. The control plane can answer 502 for a short window right
28
- * after the builder image was updated (fresh agent hash) an
29
+ * after the builder image was updated (fresh agent hash) - an
29
30
  * eventual-consistency gap that would otherwise fail every consumer's first
30
31
  * deploy after a blogwright upgrade. Retrying is safe: the input's client
31
32
  * token makes the launch idempotent. Exported for tests.
@@ -39,7 +40,7 @@ export async function runMicrovmWithRetry(ctx, input, delaysMs = RUN_RETRY_DELAY
39
40
  const delay = delaysMs[attempt];
40
41
  if (!isGatewayError(err) || delay === undefined)
41
42
  throw err;
42
- ctx.logger.warn(`MicroVM launch returned HTTP ${err.statusCode} ` +
43
+ ctx.logger.warn(`MicroVM launch returned HTTP ${err.statusCode} - ` +
43
44
  `retrying in ${Math.round(delay / 1000)}s (a just-updated builder image can lag)`);
44
45
  await sleep(delay);
45
46
  }
@@ -49,7 +50,7 @@ export async function runMicrovmWithRetry(ctx, input, delaysMs = RUN_RETRY_DELAY
49
50
  * Resolve the MicroVM build log group from bootstrapped state rather than re-deriving it.
50
51
  * The image and the build-role IAM policy bake this name in at bootstrap, so if the derived
51
52
  * name (`ctx.names.microvmLogGroup`) is renamed in code later, a deploy must still target
52
- * the group the running stack actually logs to otherwise the VM's logs land in (or are
53
+ * the group the running stack actually logs to - otherwise the VM's logs land in (or are
53
54
  * denied to) a different group and `pollBuild` waits on an empty one.
54
55
  */
55
56
  export function microvmLogGroup(ctx) {
@@ -73,7 +74,7 @@ export function microvmLogGroup(ctx) {
73
74
  */
74
75
  export async function pollBuild(ctx, hash, startTime, endpoint, token) {
75
76
  const seen = new Set();
76
- // Anchored at VM launch (startTime), like the VM's own maximumDuration
77
+ // Anchored at VM launch (startTime), like the VM's own maximumDuration -
77
78
  // anchoring at poll start would keep polling a VM that is already dead for
78
79
  // however long the RUNNING-wait consumed. One grace minute for log delivery.
79
80
  const deadline = startTime + ctx.config.microvm.maxDurationSeconds * 1000 + 60_000;
@@ -108,7 +109,7 @@ export async function pollBuild(ctx, hash, startTime, endpoint, token) {
108
109
  return result;
109
110
  // Log delivery can lag or drop (e.g. the VM logging to a group the deploy isn't tailing).
110
111
  // The agent writes build/changed/<hash>.json as its final step, so treat that artifact as
111
- // an authoritative completion signal too a successful build then can't hang until the
112
+ // an authoritative completion signal too - a successful build then can't hang until the
112
113
  // deadline just because its logs never reached CloudWatch. runBuild clears any stale copy
113
114
  // before launch, so its presence means *this* build finished.
114
115
  if (await ctx.clients.s3.objectExists(ctx.names.bucket, `build/changed/${hash}.json`)) {
@@ -146,7 +147,7 @@ export async function runBuild(ctx, opts) {
146
147
  ctx.logger.step(`running builder MicroVM for ${opts.hash}`);
147
148
  // Launch first: if runMicrovm throws, no pending.json is left behind (a leaked job
148
149
  // could otherwise be picked up during a later image bake and poison the snapshot).
149
- // A MicroVM is ephemeral compute, so the client token is unique per launch keying it
150
+ // A MicroVM is ephemeral compute, so the client token is unique per launch - keying it
150
151
  // on the hash would make a re-deploy (or workflow re-run) of the same source idempotently
151
152
  // return the ALREADY-TERMINATED original VM instead of launching a fresh one. Generated
152
153
  // once here so a network retry of this single call still dedupes.
@@ -194,7 +195,7 @@ export async function runBuild(ctx, opts) {
194
195
  finally {
195
196
  // The pending-job cleanup must survive a terminate failure: a leaked
196
197
  // pending.json is the poison-the-next-image-bake hazard the launch
197
- // ordering above exists to avoid. A failed terminate is only logged
198
+ // ordering above exists to avoid. A failed terminate is only logged -
198
199
  // the VM self-terminates at maxDuration, and the build outcome (already
199
200
  // determined) must not be masked by a cleanup error.
200
201
  await ctx.clients.microvms.terminateMicrovm(run.microvmId).catch((err) => {
@@ -250,17 +251,17 @@ export async function invalidateChanged(ctx, hash) {
250
251
  }
251
252
  }
252
253
  if (!paths) {
253
- ctx.logger.warn('no changed-paths manifest invalidating everything (/*)');
254
+ ctx.logger.warn('no changed-paths manifest - invalidating everything (/*)');
254
255
  await invalidateCloudFront(ctx, ['/*']);
255
256
  return { mode: 'all', count: 0 };
256
257
  }
257
258
  let summary;
258
259
  if (paths.length === 0) {
259
- ctx.logger.ok('no content changed skipping CloudFront invalidation');
260
+ ctx.logger.ok('no content changed - skipping CloudFront invalidation');
260
261
  summary = { mode: 'none', count: 0 };
261
262
  }
262
263
  else if (paths.length > ctx.config.invalidationMaxPaths) {
263
- ctx.logger.step(`${paths.length} paths changed (> cap) invalidating everything (/*)`);
264
+ ctx.logger.step(`${paths.length} paths changed (> cap) - invalidating everything (/*)`);
264
265
  await invalidateCloudFront(ctx, ['/*']);
265
266
  summary = { mode: 'all', count: paths.length };
266
267
  }
@@ -274,4 +275,3 @@ export async function invalidateChanged(ctx, hash) {
274
275
  return summary;
275
276
  }
276
277
  export { manifestKey };
277
- //# sourceMappingURL=deploy.js.map