blogwright 0.3.3 → 0.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +11 -11
  2. package/agent/agent-manifest.json +1 -1
  3. package/agent/server.js +37 -19
  4. package/dist/adapters/fetch-ping.d.ts +1 -1
  5. package/dist/adapters/fetch-ping.js +3 -3
  6. package/dist/adapters/node-module-loader.d.ts +11 -0
  7. package/dist/adapters/node-module-loader.js +146 -0
  8. package/dist/adapters/process-package-manager.d.ts +41 -0
  9. package/dist/adapters/process-package-manager.js +116 -0
  10. package/dist/adapters/process-vcs.d.ts +5 -4
  11. package/dist/adapters/process-vcs.js +6 -5
  12. package/dist/agent-package.d.ts +1 -1
  13. package/dist/agent-package.js +4 -3
  14. package/dist/bin.js +13 -2
  15. package/dist/cli.d.ts +68 -1
  16. package/dist/cli.js +270 -88
  17. package/dist/commands.d.ts +70 -3
  18. package/dist/commands.js +177 -30
  19. package/dist/config-block.d.ts +34 -0
  20. package/dist/config-block.js +262 -0
  21. package/dist/context.d.ts +76 -6
  22. package/dist/context.js +98 -18
  23. package/dist/deploy.d.ts +2 -2
  24. package/dist/deploy.js +12 -12
  25. package/dist/graph.d.ts +27 -16
  26. package/dist/graph.js +1 -1
  27. package/dist/init.d.ts +37 -3
  28. package/dist/init.js +146 -22
  29. package/dist/known-commands.d.ts +63 -0
  30. package/dist/known-commands.js +78 -0
  31. package/dist/microvms.d.ts +2 -2
  32. package/dist/microvms.js +3 -3
  33. package/dist/nodes.d.ts +5 -3
  34. package/dist/nodes.js +97 -30
  35. package/dist/plugin-commands.d.ts +298 -0
  36. package/dist/plugin-commands.js +990 -0
  37. package/dist/plugins.d.ts +194 -0
  38. package/dist/plugins.js +523 -0
  39. package/dist/ports.d.ts +89 -1
  40. package/dist/render.d.ts +55 -0
  41. package/dist/render.js +89 -1
  42. package/dist/repo.d.ts +3 -3
  43. package/dist/repo.js +8 -8
  44. package/dist/seo.d.ts +1 -1
  45. package/dist/seo.js +1 -1
  46. package/package.json +6 -6
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,
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
  */
@@ -26,7 +26,7 @@ function isGatewayError(err) {
26
26
  /**
27
27
  * Launch the builder MicroVM, retrying gateway errors (502/503/504) with a
28
28
  * bounded backoff. The control plane can answer 502 for a short window right
29
- * after the builder image was updated (fresh agent hash) an
29
+ * after the builder image was updated (fresh agent hash) - an
30
30
  * eventual-consistency gap that would otherwise fail every consumer's first
31
31
  * deploy after a blogwright upgrade. Retrying is safe: the input's client
32
32
  * token makes the launch idempotent. Exported for tests.
@@ -40,7 +40,7 @@ export async function runMicrovmWithRetry(ctx, input, delaysMs = RUN_RETRY_DELAY
40
40
  const delay = delaysMs[attempt];
41
41
  if (!isGatewayError(err) || delay === undefined)
42
42
  throw err;
43
- ctx.logger.warn(`MicroVM launch returned HTTP ${err.statusCode} ` +
43
+ ctx.logger.warn(`MicroVM launch returned HTTP ${err.statusCode} - ` +
44
44
  `retrying in ${Math.round(delay / 1000)}s (a just-updated builder image can lag)`);
45
45
  await sleep(delay);
46
46
  }
@@ -50,7 +50,7 @@ export async function runMicrovmWithRetry(ctx, input, delaysMs = RUN_RETRY_DELAY
50
50
  * Resolve the MicroVM build log group from bootstrapped state rather than re-deriving it.
51
51
  * The image and the build-role IAM policy bake this name in at bootstrap, so if the derived
52
52
  * name (`ctx.names.microvmLogGroup`) is renamed in code later, a deploy must still target
53
- * 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
54
54
  * denied to) a different group and `pollBuild` waits on an empty one.
55
55
  */
56
56
  export function microvmLogGroup(ctx) {
@@ -74,7 +74,7 @@ export function microvmLogGroup(ctx) {
74
74
  */
75
75
  export async function pollBuild(ctx, hash, startTime, endpoint, token) {
76
76
  const seen = new Set();
77
- // Anchored at VM launch (startTime), like the VM's own maximumDuration
77
+ // Anchored at VM launch (startTime), like the VM's own maximumDuration -
78
78
  // anchoring at poll start would keep polling a VM that is already dead for
79
79
  // however long the RUNNING-wait consumed. One grace minute for log delivery.
80
80
  const deadline = startTime + ctx.config.microvm.maxDurationSeconds * 1000 + 60_000;
@@ -109,7 +109,7 @@ export async function pollBuild(ctx, hash, startTime, endpoint, token) {
109
109
  return result;
110
110
  // Log delivery can lag or drop (e.g. the VM logging to a group the deploy isn't tailing).
111
111
  // The agent writes build/changed/<hash>.json as its final step, so treat that artifact as
112
- // 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
113
113
  // deadline just because its logs never reached CloudWatch. runBuild clears any stale copy
114
114
  // before launch, so its presence means *this* build finished.
115
115
  if (await ctx.clients.s3.objectExists(ctx.names.bucket, `build/changed/${hash}.json`)) {
@@ -147,7 +147,7 @@ export async function runBuild(ctx, opts) {
147
147
  ctx.logger.step(`running builder MicroVM for ${opts.hash}`);
148
148
  // Launch first: if runMicrovm throws, no pending.json is left behind (a leaked job
149
149
  // could otherwise be picked up during a later image bake and poison the snapshot).
150
- // 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
151
151
  // on the hash would make a re-deploy (or workflow re-run) of the same source idempotently
152
152
  // return the ALREADY-TERMINATED original VM instead of launching a fresh one. Generated
153
153
  // once here so a network retry of this single call still dedupes.
@@ -195,7 +195,7 @@ export async function runBuild(ctx, opts) {
195
195
  finally {
196
196
  // The pending-job cleanup must survive a terminate failure: a leaked
197
197
  // pending.json is the poison-the-next-image-bake hazard the launch
198
- // ordering above exists to avoid. A failed terminate is only logged
198
+ // ordering above exists to avoid. A failed terminate is only logged -
199
199
  // the VM self-terminates at maxDuration, and the build outcome (already
200
200
  // determined) must not be masked by a cleanup error.
201
201
  await ctx.clients.microvms.terminateMicrovm(run.microvmId).catch((err) => {
@@ -251,17 +251,17 @@ export async function invalidateChanged(ctx, hash) {
251
251
  }
252
252
  }
253
253
  if (!paths) {
254
- ctx.logger.warn('no changed-paths manifest invalidating everything (/*)');
254
+ ctx.logger.warn('no changed-paths manifest - invalidating everything (/*)');
255
255
  await invalidateCloudFront(ctx, ['/*']);
256
256
  return { mode: 'all', count: 0 };
257
257
  }
258
258
  let summary;
259
259
  if (paths.length === 0) {
260
- ctx.logger.ok('no content changed skipping CloudFront invalidation');
260
+ ctx.logger.ok('no content changed - skipping CloudFront invalidation');
261
261
  summary = { mode: 'none', count: 0 };
262
262
  }
263
263
  else if (paths.length > ctx.config.invalidationMaxPaths) {
264
- ctx.logger.step(`${paths.length} paths changed (> cap) invalidating everything (/*)`);
264
+ ctx.logger.step(`${paths.length} paths changed (> cap) - invalidating everything (/*)`);
265
265
  await invalidateCloudFront(ctx, ['/*']);
266
266
  summary = { mode: 'all', count: paths.length };
267
267
  }
package/dist/graph.d.ts CHANGED
@@ -1,20 +1,31 @@
1
- import type { OpsContext } from './context.js';
2
- /** A node in the infrastructure dependency graph. */
3
- export interface ResourceNode {
4
- id: string;
5
- dependsOn: string[];
6
- /** Human label for logging. */
7
- title: string;
8
- /** Does the resource already exist? (Also hydrates outputs into ctx.state.) */
9
- read(ctx: OpsContext): Promise<boolean>;
10
- create(ctx: OpsContext): Promise<void>;
11
- /** Reconcile an existing resource (optional). */
12
- update?(ctx: OpsContext): Promise<void>;
13
- delete(ctx: OpsContext): Promise<void>;
1
+ import type { ResourceNode, ResourceOutputs } from 'blogwright-core';
2
+ /**
3
+ * The structural minimum the engine below (`topoSort`, `applyGraph`,
4
+ * `destroyGraph`) actually reads off a node's context: a logger it calls
5
+ * `step`/`ok`/`warn` on, a way to persist state, and the state's resources
6
+ * map (`destroyGraph` deletes an entry from it). Exported so a caller
7
+ * running this engine over a different context - `OpsContext` (`context.ts`)
8
+ * and core's `PluginContext` (`blogwright-core`) both do already - knows
9
+ * exactly what that context must supply, without this module depending on
10
+ * either one. Neither `OpsContext` nor `PluginContext` is named here on
11
+ * purpose: this is the structural minimum both happen to satisfy, not a
12
+ * fixed supertype of them (see the doc comment on core's `ResourceNode` for
13
+ * why no such supertype is worth naming).
14
+ */
15
+ export interface GraphContext {
16
+ logger: {
17
+ step(msg: string): void;
18
+ ok(msg: string): void;
19
+ warn(msg: string): void;
20
+ };
21
+ state: {
22
+ resources: Record<string, ResourceOutputs>;
23
+ };
24
+ save(): Promise<void>;
14
25
  }
15
26
  /** Topologically order nodes so dependencies come before dependents (Kahn's algorithm). */
16
- export declare function topoSort(nodes: ResourceNode[]): ResourceNode[];
27
+ export declare function topoSort<Ctx>(nodes: ResourceNode<Ctx>[]): ResourceNode<Ctx>[];
17
28
  /** Reconcile the graph in dependency order (create missing, update existing). */
18
- export declare function applyGraph(nodes: ResourceNode[], ctx: OpsContext): Promise<void>;
29
+ export declare function applyGraph<Ctx extends GraphContext>(nodes: ResourceNode<Ctx>[], ctx: Ctx): Promise<void>;
19
30
  /** Tear down the graph in reverse dependency order. */
20
- export declare function destroyGraph(nodes: ResourceNode[], ctx: OpsContext): Promise<void>;
31
+ export declare function destroyGraph<Ctx extends GraphContext>(nodes: ResourceNode<Ctx>[], ctx: Ctx): Promise<void>;
package/dist/graph.js CHANGED
@@ -77,7 +77,7 @@ export async function destroyGraph(nodes, ctx) {
77
77
  await node.delete(ctx);
78
78
  delete ctx.state.resources[node.id];
79
79
  // The state lives in the bucket that is itself being deleted, so persisting it may
80
- // fail (NoSuchBucket) once the bucket node is gone never let that abort teardown.
80
+ // fail (NoSuchBucket) once the bucket node is gone - never let that abort teardown.
81
81
  await ctx.save().catch(() => undefined);
82
82
  ctx.logger.ok(`deleted ${node.title}`);
83
83
  }
package/dist/init.d.ts CHANGED
@@ -1,4 +1,38 @@
1
- import { type FileSystem, type Terminal } from 'blogwright-core';
1
+ import { type FileSystem, type Plugin, type Terminal } from 'blogwright-core';
2
2
  import type { Logger } from './logger.js';
3
- /** Run the wizard. Returns a process exit code; never throws for expected refusals. */
4
- export declare function initSite(fs: FileSystem, terminal: Terminal, logger: Logger, root?: string): Promise<number>;
3
+ /**
4
+ * One question, and how to ask it: shown text, a prefilled default, whether
5
+ * an empty answer must re-prompt, and an optional validator. Exported - and
6
+ * `ask` below along with it - because `plugin-commands.ts`'s `io.ask` (the
7
+ * surface an `init?(io)` contributor asks its own questions through) reuses
8
+ * this exact prompt/validate/retry loop rather than writing a second one; no
9
+ * plugin path may reach `node:readline` itself. Structurally the same shape
10
+ * as core's `PluginQuestion` (`blogwright-core`'s `plugin.ts`), so a
11
+ * contributor's question passes straight through with no conversion.
12
+ */
13
+ export interface Question {
14
+ prompt: string;
15
+ defaultValue?: string | undefined;
16
+ required?: boolean | undefined;
17
+ validate?: ((answer: string) => string | undefined) | undefined;
18
+ }
19
+ /**
20
+ * Ask `q.prompt` over `terminal`, retrying up to `MAX_ATTEMPTS` times on a
21
+ * required-but-empty answer or a `validate` failure, and resolving with
22
+ * `undefined` for an unanswered optional question. Throws once every attempt
23
+ * is spent. The one prompt/validate/retry loop every wizard-shaped question -
24
+ * `blogwright init`'s own four and any plugin's `init?(io)` contributor -
25
+ * asks through.
26
+ */
27
+ export declare function ask(terminal: Terminal, logger: Logger, q: Question): Promise<string | undefined>;
28
+ /**
29
+ * Run the wizard. Returns a process exit code; never throws for expected
30
+ * refusals (non-interactive, an existing config file). `plugins` is every
31
+ * plugin the caller has ALREADY discovered (`cli.ts`, over
32
+ * `DiscoveryPortsFactory`) - this function asks their questions and writes
33
+ * their blocks but never runs discovery itself. A plugin's `init(io)`
34
+ * contributor throwing propagates unchanged, rejecting this call before the
35
+ * single `fs.writeText` below ever runs, so the config file stays exactly
36
+ * what it was - absent, on this path - rather than a partial write.
37
+ */
38
+ export declare function initSite(fs: FileSystem, terminal: Terminal, logger: Logger, plugins: readonly Plugin[], root?: string): Promise<number>;