blogwright 0.3.2 → 0.4.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/agent/agent-manifest.json +1 -1
- package/agent/server.js +37 -19
- package/dist/adapters/fetch-ping.d.ts +1 -1
- package/dist/adapters/fetch-ping.js +3 -4
- package/dist/adapters/node-module-loader.d.ts +11 -0
- package/dist/adapters/node-module-loader.js +146 -0
- package/dist/adapters/process-package-manager.d.ts +41 -0
- package/dist/adapters/process-package-manager.js +116 -0
- package/dist/adapters/process-vcs.d.ts +5 -4
- package/dist/adapters/process-vcs.js +6 -6
- package/dist/agent-package.d.ts +1 -1
- package/dist/agent-package.js +4 -4
- package/dist/bin.js +13 -3
- package/dist/cli.d.ts +68 -1
- package/dist/cli.js +270 -89
- package/dist/commands.d.ts +70 -3
- package/dist/commands.js +180 -32
- package/dist/config-block.d.ts +34 -0
- package/dist/config-block.js +262 -0
- package/dist/context.d.ts +76 -6
- package/dist/context.js +98 -19
- package/dist/deploy.d.ts +2 -2
- package/dist/deploy.js +14 -14
- package/dist/graph.d.ts +27 -16
- package/dist/graph.js +1 -2
- package/dist/init.d.ts +37 -3
- package/dist/init.js +146 -23
- package/dist/known-commands.d.ts +63 -0
- package/dist/known-commands.js +78 -0
- package/dist/logger.js +0 -1
- package/dist/microvms.d.ts +2 -2
- package/dist/microvms.js +3 -4
- package/dist/nodes.d.ts +5 -3
- package/dist/nodes.js +97 -31
- package/dist/plugin-commands.d.ts +298 -0
- package/dist/plugin-commands.js +990 -0
- package/dist/plugins.d.ts +194 -0
- package/dist/plugins.js +523 -0
- package/dist/ports.d.ts +89 -1
- package/dist/ports.js +0 -1
- package/dist/render.d.ts +55 -0
- package/dist/render.js +89 -2
- package/dist/repo.d.ts +3 -3
- package/dist/repo.js +8 -9
- package/dist/rkey.js +0 -1
- package/dist/seo.d.ts +1 -1
- package/dist/seo.js +1 -2
- package/package.json +6 -6
- package/dist/adapters/fetch-ping.js.map +0 -1
- package/dist/adapters/process-vcs.js.map +0 -1
- package/dist/agent-package.js.map +0 -1
- package/dist/bin.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/commands.js.map +0 -1
- package/dist/context.js.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/graph.js.map +0 -1
- package/dist/init.js.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/microvms.js.map +0 -1
- package/dist/nodes.js.map +0 -1
- package/dist/ports.js.map +0 -1
- package/dist/render.js.map +0 -1
- package/dist/repo.js.map +0 -1
- package/dist/rkey.js.map +0 -1
- package/dist/seo.js.map +0 -1
- package/dist/test-support.d.ts +0 -45
- package/dist/test-support.js +0 -126
- package/dist/test-support.js.map +0 -1
|
@@ -0,0 +1,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
|
|
16
|
-
* and agent-manifest.json
|
|
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
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
return
|
|
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}"
|
|
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
|
|
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
|
|
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 =
|
|
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)
|
|
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
|
|
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
|
|
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 &&
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|