blogwright-core 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 (77) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/memory-fs.js +0 -1
  3. package/dist/adapters/node-fs.js +0 -1
  4. package/dist/adapters/node-terminal.d.ts +2 -2
  5. package/dist/adapters/node-terminal.js +1 -2
  6. package/dist/adapters/script-terminal.js +0 -1
  7. package/dist/aws/acm.js +0 -1
  8. package/dist/aws/cloudfront.d.ts +4 -4
  9. package/dist/aws/cloudfront.js +7 -10
  10. package/dist/aws/credentials.d.ts +1 -1
  11. package/dist/aws/credentials.js +1 -2
  12. package/dist/aws/endpoint.d.ts +22 -2
  13. package/dist/aws/endpoint.js +21 -3
  14. package/dist/aws/errors.js +1 -2
  15. package/dist/aws/form.js +0 -1
  16. package/dist/aws/iam.d.ts +1 -1
  17. package/dist/aws/iam.js +1 -2
  18. package/dist/aws/logs.d.ts +24 -4
  19. package/dist/aws/logs.js +19 -10
  20. package/dist/aws/microvms.d.ts +1 -1
  21. package/dist/aws/microvms.js +2 -3
  22. package/dist/aws/route53.d.ts +1 -1
  23. package/dist/aws/route53.js +2 -3
  24. package/dist/aws/s3.js +4 -2
  25. package/dist/aws/secretsmanager.js +0 -1
  26. package/dist/aws/signer.d.ts +2 -2
  27. package/dist/aws/signer.js +7 -7
  28. package/dist/aws/sts.d.ts +1 -1
  29. package/dist/aws/sts.js +1 -2
  30. package/dist/aws/xml.d.ts +2 -2
  31. package/dist/aws/xml.js +2 -3
  32. package/dist/clients.d.ts +11 -1
  33. package/dist/clients.js +3 -2
  34. package/dist/colors.js +1 -2
  35. package/dist/config.d.ts +47 -4
  36. package/dist/config.js +29 -29
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -1
  39. package/dist/plugin.d.ts +360 -0
  40. package/dist/plugin.js +85 -0
  41. package/dist/ports.js +0 -1
  42. package/dist/repo-root.d.ts +3 -0
  43. package/dist/repo-root.js +7 -2
  44. package/dist/state.d.ts +24 -3
  45. package/dist/state.js +38 -12
  46. package/dist/tags.js +0 -1
  47. package/dist/util.d.ts +23 -0
  48. package/dist/util.js +23 -1
  49. package/package.json +1 -1
  50. package/dist/adapters/memory-fs.js.map +0 -1
  51. package/dist/adapters/node-fs.js.map +0 -1
  52. package/dist/adapters/node-terminal.js.map +0 -1
  53. package/dist/adapters/script-terminal.js.map +0 -1
  54. package/dist/aws/acm.js.map +0 -1
  55. package/dist/aws/cloudfront.js.map +0 -1
  56. package/dist/aws/credentials.js.map +0 -1
  57. package/dist/aws/endpoint.js.map +0 -1
  58. package/dist/aws/errors.js.map +0 -1
  59. package/dist/aws/form.js.map +0 -1
  60. package/dist/aws/iam.js.map +0 -1
  61. package/dist/aws/logs.js.map +0 -1
  62. package/dist/aws/microvms.js.map +0 -1
  63. package/dist/aws/route53.js.map +0 -1
  64. package/dist/aws/s3.js.map +0 -1
  65. package/dist/aws/secretsmanager.js.map +0 -1
  66. package/dist/aws/signer.js.map +0 -1
  67. package/dist/aws/sts.js.map +0 -1
  68. package/dist/aws/xml.js.map +0 -1
  69. package/dist/clients.js.map +0 -1
  70. package/dist/colors.js.map +0 -1
  71. package/dist/config.js.map +0 -1
  72. package/dist/index.js.map +0 -1
  73. package/dist/ports.js.map +0 -1
  74. package/dist/repo-root.js.map +0 -1
  75. package/dist/state.js.map +0 -1
  76. package/dist/tags.js.map +0 -1
  77. package/dist/util.js.map +0 -1
package/dist/aws/xml.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Minimal XML helpers for the handful of AWS REST-XML / query responses this CLI
3
- * reads (S3, STS, IAM, CloudFront). Not a general XML parser it extracts tag
3
+ * reads (S3, STS, IAM, CloudFront). Not a general XML parser - it extracts tag
4
4
  * contents by name, which is sufficient for the flat response shapes we consume.
5
5
  */
6
6
  const ENTITIES = {
@@ -41,7 +41,7 @@ export function textTag(xml, name) {
41
41
  return inner === undefined ? undefined : decodeEntities(inner.trim());
42
42
  }
43
43
  /**
44
- * Like {@link textTag} but verbatim no trimming. For values where whitespace
44
+ * Like {@link textTag} but verbatim - no trimming. For values where whitespace
45
45
  * is data, like S3 object keys (a trimmed key would target a different object).
46
46
  */
47
47
  export function rawTextTag(xml, name) {
@@ -62,4 +62,3 @@ export function allTags(xml, name) {
62
62
  function escapeName(name) {
63
63
  return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
64
  }
65
- //# sourceMappingURL=xml.js.map
package/dist/clients.d.ts CHANGED
@@ -19,12 +19,22 @@ export interface ClientBundleOptions {
19
19
  export interface AwsClients {
20
20
  region: string;
21
21
  signing: SigningClient;
22
+ /**
23
+ * The us-east-1 signer, exposed so a plugin can build clients for services
24
+ * core does not enumerate over the credentials, endpoint override and
25
+ * transport the host already resolved - a `SigningClient`'s region is fixed
26
+ * at construction and both of those are private, so a hand-built one would
27
+ * re-resolve credentials and ignore a transport a test injected. Core builds
28
+ * no additional client from it: `logsUsEast1`, `acm`, `cloudfront` and
29
+ * `route53` below are the only ones it signs for.
30
+ */
31
+ signingUsEast1: SigningClient;
22
32
  s3: S3Client;
23
33
  sts: StsClient;
24
34
  iam: IamClient;
25
35
  logs: LogsClient;
26
36
  /**
27
- * Logs client pinned to us-east-1 for CloudFront vended log delivery
37
+ * Logs client pinned to us-east-1 for CloudFront vended log delivery -
28
38
  * PutDeliverySource with the CloudFront LogType exists only there, the same
29
39
  * global-service quirk as CloudFront's ACM certificates.
30
40
  */
package/dist/clients.js CHANGED
@@ -19,11 +19,13 @@ export function createClients(opts) {
19
19
  ...(opts.transport ? { transport: opts.transport } : {}),
20
20
  };
21
21
  const signing = new SigningClient({ region: opts.region, ...base });
22
- // ACM for CloudFront must be us-east-1 regardless of the primary region.
22
+ // ACM for CloudFront must be us-east-1 regardless of the primary region; the
23
+ // same signer is handed out as `signingUsEast1` for plugin-supplied services.
23
24
  const usEast1 = new SigningClient({ region: 'us-east-1', ...base });
24
25
  return {
25
26
  region: opts.region,
26
27
  signing,
28
+ signingUsEast1: usEast1,
27
29
  s3: new S3Client(signing),
28
30
  sts: new StsClient(signing),
29
31
  iam: new IamClient(signing),
@@ -36,4 +38,3 @@ export function createClients(opts) {
36
38
  secrets: new SecretsManagerClient(signing),
37
39
  };
38
40
  }
39
- //# sourceMappingURL=clients.js.map
package/dist/colors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * ANSI colour helpers shared by the CLI and feature packages. Pure string
3
- * composition writing (and stripping for non-interactive sessions) is the
3
+ * composition - writing (and stripping for non-interactive sessions) is the
4
4
  * logger's job.
5
5
  */
6
6
  const ESCAPE = '\u001B';
@@ -21,4 +21,3 @@ const COLOR_CODES = new RegExp(`${ESCAPE}\\[[0-9;]*m`, 'g');
21
21
  export function stripColors(text) {
22
22
  return text.replace(COLOR_CODES, '');
23
23
  }
24
- //# sourceMappingURL=colors.js.map
package/dist/config.d.ts CHANGED
@@ -23,7 +23,17 @@ export interface SeoConfig {
23
23
  */
24
24
  sitemap: 'auto' | 'on' | 'off';
25
25
  }
26
- /** AT Protocol / standard.site publishing (see `blogwright pds`). Inert when absent. */
26
+ /**
27
+ * AT Protocol / standard.site publishing (see `blogwright pds`). Inert when
28
+ * absent.
29
+ *
30
+ * A *shape*, not a contract core enforces: `blogwright-pds` owns this key end
31
+ * to end - it validates the block (`validatePdsConfig`) and derives the
32
+ * `<siteName>/atproto` default `secretName` (`resolvePdsSecretName`). Core
33
+ * parses the block through untouched. The type stays here only because
34
+ * `OpsConfig.pds` is still declared, which is what lets the CLI's deploy-role
35
+ * statement read it until that statement moves to the plugin.
36
+ */
27
37
  export interface PdsConfig {
28
38
  /** Publication display name (site.standard.publication `name`). */
29
39
  name: string;
@@ -35,8 +45,12 @@ export interface PdsConfig {
35
45
  * the DID document during OAuth.
36
46
  */
37
47
  handleResolver?: string | undefined;
38
- /** Secrets Manager secret holding the OAuth client key + session. */
39
- secretName: string;
48
+ /**
49
+ * Secrets Manager secret holding the OAuth client key + session. Optional
50
+ * because core no longer defaults it: `blogwright-pds` resolves an absent
51
+ * one to `<siteName>/atproto`.
52
+ */
53
+ secretName?: string | undefined;
40
54
  }
41
55
  /**
42
56
  * Repo-relative paths the pds commands read/write in the consuming site.
@@ -85,7 +99,7 @@ export interface OpsConfig {
85
99
  /** Extra path prefixes excluded from the source zip (on top of .gitignore). */
86
100
  sourceIgnore: string[];
87
101
  /**
88
- * Paths zipped into the deploy source even when gitignored for artifacts a
102
+ * Paths zipped into the deploy source even when gitignored - for artifacts a
89
103
  * pre-deploy step builds outside the MicroVM (a wasm bundle, generated
90
104
  * assets). Each entry must exist and be non-empty at deploy time, so a
91
105
  * forgotten pre-build fails fast instead of shipping a broken site.
@@ -121,8 +135,27 @@ export declare function stripJsonComments(input: string): string;
121
135
  * JSON.parse does not. Run after comment stripping.
122
136
  */
123
137
  export declare function stripTrailingCommas(input: string): string;
138
+ /**
139
+ * Parse a JSONC config document into both its validated `OpsConfig` and the
140
+ * raw parsed document. `raw` carries every top-level property the file has,
141
+ * including a plugin's own key - `OpsConfig` has no index signature to read
142
+ * one off `config`, so a plugin's block survives the parse only here.
143
+ * `parseConfig` keeps its existing signature as this function's `config`
144
+ * half, so no existing caller changes.
145
+ */
146
+ export declare function parseConfigDocument(text: string): {
147
+ config: OpsConfig;
148
+ raw: Readonly<Record<string, unknown>>;
149
+ };
124
150
  /** Parse + validate a JSONC config document, merged over defaults. */
125
151
  export declare function parseConfig(text: string): OpsConfig;
152
+ /**
153
+ * Read a plugin's own block out of a config document's raw form - the
154
+ * `unknown` the CLI hands straight to that plugin's `validateConfig`. Not a
155
+ * function a plugin calls itself; only the CLI's dispatch path reads a
156
+ * block this way, over the `raw` half {@link parseConfigDocument} returns.
157
+ */
158
+ export declare function pluginBlock(raw: Readonly<Record<string, unknown>>, key: string): unknown;
126
159
  export declare function mergeConfig(raw: Partial<OpsConfig>): OpsConfig;
127
160
  export interface Names {
128
161
  env: string;
@@ -130,6 +163,16 @@ export interface Names {
130
163
  prefix: string;
131
164
  buildRole: string;
132
165
  execRole: string;
166
+ /**
167
+ * The GitHub Actions OIDC deploy role. Derived here rather than inside the
168
+ * CLI's own `githubOidcRoleNode` because it has two readers: that node, and
169
+ * `blogwright-pds`, whose resource node attaches its own named inline policy
170
+ * to the same role (`packages/pds/src/nodes.ts`). Only the role's ARN reaches
171
+ * the site's state, so a plugin cannot recover the name from there - and a
172
+ * second, private derivation is exactly the duplication DEVELOPMENT.md
173
+ * §Limits and bounds bans for a derived AWS name.
174
+ */
175
+ githubRole: string;
133
176
  microvmImage: string;
134
177
  microvmLogGroup: string;
135
178
  cloudfrontLogGroup: string;
package/dist/config.js CHANGED
@@ -120,14 +120,35 @@ export function stripTrailingCommas(input) {
120
120
  }
121
121
  return out;
122
122
  }
123
+ /**
124
+ * Parse a JSONC config document into both its validated `OpsConfig` and the
125
+ * raw parsed document. `raw` carries every top-level property the file has,
126
+ * including a plugin's own key - `OpsConfig` has no index signature to read
127
+ * one off `config`, so a plugin's block survives the parse only here.
128
+ * `parseConfig` keeps its existing signature as this function's `config`
129
+ * half, so no existing caller changes.
130
+ */
131
+ export function parseConfigDocument(text) {
132
+ const raw = JSON.parse(stripTrailingCommas(stripJsonComments(text)));
133
+ const config = mergeConfig(raw);
134
+ return { config, raw };
135
+ }
123
136
  /** Parse + validate a JSONC config document, merged over defaults. */
124
137
  export function parseConfig(text) {
125
- const raw = JSON.parse(stripTrailingCommas(stripJsonComments(text)));
126
- return mergeConfig(raw);
138
+ return parseConfigDocument(text).config;
139
+ }
140
+ /**
141
+ * Read a plugin's own block out of a config document's raw form - the
142
+ * `unknown` the CLI hands straight to that plugin's `validateConfig`. Not a
143
+ * function a plugin calls itself; only the CLI's dispatch path reads a
144
+ * block this way, over the `raw` half {@link parseConfigDocument} returns.
145
+ */
146
+ export function pluginBlock(raw, key) {
147
+ return raw[key];
127
148
  }
128
149
  export function mergeConfig(raw) {
129
150
  if (!raw.siteName) {
130
- throw new Error('config.siteName is required a stable lowercase slug used in every derived AWS resource name');
151
+ throw new Error('config.siteName is required - a stable lowercase slug used in every derived AWS resource name');
131
152
  }
132
153
  const cfg = {
133
154
  ...DEFAULT_CONFIG,
@@ -142,12 +163,6 @@ export function mergeConfig(raw) {
142
163
  seo: { ...DEFAULT_CONFIG.seo, ...raw.seo },
143
164
  paths: { ...DEFAULT_CONFIG.paths, ...raw.paths },
144
165
  };
145
- if (raw.pds) {
146
- cfg.pds = {
147
- ...raw.pds,
148
- secretName: raw.pds.secretName ?? `${cfg.siteName}/atproto`,
149
- };
150
- }
151
166
  validateConfig(cfg);
152
167
  return cfg;
153
168
  }
@@ -188,25 +203,10 @@ function validateConfig(cfg) {
188
203
  throw new Error(`config.paths.${key} must be repo-relative without "..", got "${value}"`);
189
204
  }
190
205
  }
191
- if (cfg.pds) {
192
- if (!cfg.pds.name?.trim())
193
- throw new Error('config.pds.name is required');
194
- if (cfg.pds.handleResolver !== undefined) {
195
- let resolver;
196
- try {
197
- resolver = new URL(cfg.pds.handleResolver);
198
- }
199
- catch {
200
- throw new Error(`config.pds.handleResolver must be a URL, got "${cfg.pds.handleResolver}"`);
201
- }
202
- if (resolver.protocol !== 'https:') {
203
- throw new Error(`config.pds.handleResolver must be https, got "${cfg.pds.handleResolver}"`);
204
- }
205
- }
206
- if (!/^[\w/+=.@-]+$/.test(cfg.pds.secretName)) {
207
- throw new Error(`config.pds.secretName has invalid characters: "${cfg.pds.secretName}"`);
208
- }
209
- }
206
+ // No plugin-block branch here, by design: a plugin's block is that plugin's
207
+ // own to judge. Core validates only the keys it declares, so an unknown - or
208
+ // malformed - plugin block parses through untouched and is checked by that
209
+ // plugin's `validateConfig`, off `parseConfigDocument`'s raw half.
210
210
  }
211
211
  /** Derive deterministic, environment-prefixed resource names. */
212
212
  export function deriveNames(env, accountId, cfg) {
@@ -225,6 +225,7 @@ export function deriveNames(env, accountId, cfg) {
225
225
  prefix,
226
226
  buildRole: `${prefix}-build-role`,
227
227
  execRole: `${prefix}-exec-role`,
228
+ githubRole: `${prefix}-gh`,
228
229
  microvmImage,
229
230
  microvmLogGroup: `/aws/lambda/microvms/${microvmImage}`,
230
231
  cloudfrontLogGroup: `/${cfg.siteName}/${env}/cloudfront`,
@@ -233,4 +234,3 @@ export function deriveNames(env, accountId, cfg) {
233
234
  deliveryDestination: `${prefix}-cf-dest`,
234
235
  };
235
236
  }
236
- //# sourceMappingURL=config.js.map
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export * from './aws/xml.js';
20
20
  export * from './clients.js';
21
21
  export * from './colors.js';
22
22
  export * from './config.js';
23
+ export * from './plugin.js';
23
24
  export * from './ports.js';
24
25
  export * from './repo-root.js';
25
26
  export * from './state.js';
package/dist/index.js CHANGED
@@ -20,9 +20,9 @@ export * from './aws/xml.js';
20
20
  export * from './clients.js';
21
21
  export * from './colors.js';
22
22
  export * from './config.js';
23
+ export * from './plugin.js';
23
24
  export * from './ports.js';
24
25
  export * from './repo-root.js';
25
26
  export * from './state.js';
26
27
  export * from './tags.js';
27
28
  export * from './util.js';
28
- //# sourceMappingURL=index.js.map
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The plugin service-provider interface (SPI) vocabulary. A plugin is a
3
+ * package that depends on `blogwright-core` and never on the CLI - it never
4
+ * imports from `blogwright` (the CLI package), only from this module and the
5
+ * rest of core. The CLI's own `OpsContext` (`packages/cli/src/context.ts`) is
6
+ * deliberately wider than what a plugin is handed: it carries CLI-private
7
+ * concerns - `agentDir` (the build-agent artifact directory) and the full
8
+ * `Ports` (`vcs`, `ping`, and any port a later CLI feature adds) - that a
9
+ * plugin must never see, because seeing them would let a plugin depend on
10
+ * types this module cannot even name. `PluginContext<TConfig>` below is the
11
+ * narrow slice the CLI carves out of `OpsContext` at the dispatch boundary
12
+ * for a plugin command to run against.
13
+ */
14
+ import type { AwsClients } from './clients.js';
15
+ import type { Names, OpsConfig } from './config.js';
16
+ import type { FileSystem, Terminal } from './ports.js';
17
+ import type { OpsState, ResourceOutputs, StateStore } from './state.js';
18
+ /**
19
+ * Leveled logger surface a plugin command reports through. Restates the CLI's
20
+ * `Logger` (`packages/cli/src/logger.ts`) and pds's `PdsLogger`
21
+ * (`packages/pds/src/context.ts`) so this module never imports either.
22
+ */
23
+ export interface PluginLogger {
24
+ info(msg: string): void;
25
+ step(msg: string): void;
26
+ ok(msg: string): void;
27
+ warn(msg: string): void;
28
+ error(msg: string): void;
29
+ }
30
+ /**
31
+ * The two ports core owns that a plugin may cross: repo files and the
32
+ * operator's terminal. Deliberately narrower than the CLI's six-member
33
+ * `Ports` (`packages/cli/src/ports.ts`) - `vcs` and `ping` are CLI-private
34
+ * ports declared in the CLI, and any port a later CLI feature adds joins
35
+ * them - so a `PluginContext` declared in core cannot name any of those
36
+ * types. A separate declaration from the CLI's `Ports`, rather than a
37
+ * `Pick`, keeps this module free of any import from the CLI.
38
+ */
39
+ export interface PluginPorts {
40
+ fs: FileSystem;
41
+ terminal: Terminal;
42
+ }
43
+ /**
44
+ * A **read-only** view of the site's recorded outputs (`state/<env>.json`).
45
+ * A plugin reads it to find resources the site owns - the analytics
46
+ * log-delivery node reads the site's CloudFront distribution ARN through it
47
+ * - but can never write through it: every property is `readonly`, all the
48
+ * way into the map values, so `ctx.siteState.resources['x'] = {}` does not
49
+ * typecheck. This is one of the two state surfaces a plugin sees (see
50
+ * {@link PluginContext.siteState}); the plugin's own, writable state is
51
+ * `PluginContext.state`. Any `OpsState` (`state.ts`) satisfies this type
52
+ * structurally - a mutable property is assignable to a `readonly` one of the
53
+ * same type - so the CLI needs no wrapper object to hand its site state to a
54
+ * plugin this way.
55
+ */
56
+ export interface SiteState {
57
+ readonly resources: Readonly<Record<string, Readonly<ResourceOutputs>>>;
58
+ }
59
+ /**
60
+ * The narrow slice of the host CLI's context a plugin command receives,
61
+ * generic over `TConfig` - the shape of the config block the plugin owns
62
+ * (the block its own `validateConfig` returned). It names exactly sixteen
63
+ * members and nothing else; the enumeration is exhaustive on purpose,
64
+ * because plugins are written against this type before they exercise it. A
65
+ * field left out here surfaces as a compile error many features downstream,
66
+ * not a runtime one.
67
+ *
68
+ * Every member is required except {@link PluginContext.tags}.
69
+ */
70
+ export interface PluginContext<TConfig = never> {
71
+ /** The environment name (e.g. `production`), the same value `names` was derived from. */
72
+ env: string;
73
+ /** The site's domain, when the site has one configured. */
74
+ domain: string | undefined;
75
+ /** True for the shared preview stack (host-routed, per-PR prefixes). */
76
+ preview: boolean;
77
+ /** The full, validated site config. A plugin's own block is `pluginConfig`, not a key read off this - `OpsConfig` has no index signature to read a plugin's key from. */
78
+ config: OpsConfig;
79
+ /**
80
+ * The plugin's own config block, as returned by its `validateConfig`. A
81
+ * plugin reads its settings from here, never from `ctx.config`: `OpsConfig`
82
+ * (`config.ts`) has no index signature, so `ctx.config.<pluginKey>` does
83
+ * not compile, and casting or widening to `any` around that is banned by
84
+ * DEVELOPMENT.md §Code style. Returning the validated block also gives a
85
+ * plugin's own defaults somewhere to live, so every reader keeps a total
86
+ * type instead of re-checking for `undefined`.
87
+ */
88
+ pluginConfig: TConfig;
89
+ /**
90
+ * Deterministic, environment-prefixed resource names the site derived.
91
+ * `names.deliverySource` is load-bearing for the analytics plugin: it hangs
92
+ * its CloudFront log delivery off the source name the site already owns,
93
+ * rather than deriving a second one.
94
+ */
95
+ names: Names;
96
+ /**
97
+ * The AWS account id the site's resources live in. Both the pds and
98
+ * analytics plugins build IAM resource ARNs from it directly (for example
99
+ * `arn:aws:secretsmanager:<region>:<accountId>:secret:<name>-*`).
100
+ */
101
+ accountId: string;
102
+ /** The signed AWS service clients the host built, shared with every node this context reaches. */
103
+ clients: AwsClients;
104
+ /** The two ports core owns. See {@link PluginPorts} for what stays CLI-side. */
105
+ ports: PluginPorts;
106
+ /**
107
+ * Tags applied to every AWS resource this stack creates. Optional - not
108
+ * merely possibly-empty - under `exactOptionalPropertyTypes`, because
109
+ * `PdsContext` (`packages/pds/src/context.ts`) already declares it this
110
+ * way and `createTestContext` (`packages/pds/src/test-support.ts`) builds
111
+ * a complete feature context without one; a required `tags` here would
112
+ * propagate through the pds migration's `Pick` and break every existing
113
+ * pds test.
114
+ */
115
+ tags?: Record<string, string> | undefined;
116
+ /** Leveled logger a plugin command reports progress, successes, and failures through. */
117
+ logger: PluginLogger;
118
+ /**
119
+ * The plugin's own scoped `StateStore` (`state/<env>.<plugin>.json`), the
120
+ * store `state`/`save()` persist through. A plugin node must never call
121
+ * `store.save()` itself - see {@link PluginContext.record}.
122
+ */
123
+ store: StateStore;
124
+ /**
125
+ * The plugin's **own** state, loaded from its scoped `store`. Typed as
126
+ * core's `OpsState` - not a bare outputs map - because the engine reaches
127
+ * through it: `destroyGraph` does `delete ctx.state.resources[node.id]`
128
+ * (`packages/cli/src/graph.ts`). This is the only state a plugin may
129
+ * write, and only through {@link PluginContext.record}; contrast with the
130
+ * read-only {@link PluginContext.siteState}.
131
+ */
132
+ state: OpsState;
133
+ /**
134
+ * The read-only view of the site's own recorded outputs. See
135
+ * {@link SiteState}.
136
+ */
137
+ siteState: SiteState;
138
+ /**
139
+ * Record a resource node's outputs (ARNs, ids, domains) under `nodeId` in
140
+ * the plugin's own `state`. This is the *only* way a plugin's resource
141
+ * nodes may record outputs - it mirrors what the CLI's own nodes do through
142
+ * the private `output(ctx, id)` helper (`packages/cli/src/nodes.ts`),
143
+ * but a plugin node must never call `store.save()` directly: the engine
144
+ * (`applyGraph`) saves the in-memory state after every node, and a node's
145
+ * own direct write would be clobbered by that next save.
146
+ */
147
+ record(nodeId: string, outputs: ResourceOutputs): void;
148
+ /**
149
+ * Persist the plugin's own working state through `store`. `applyGraph`
150
+ * calls this after every node (`packages/cli/src/graph.ts`); a plugin
151
+ * node itself never calls `store.save()` - see {@link PluginContext.record}.
152
+ */
153
+ save(): Promise<void>;
154
+ }
155
+ /**
156
+ * One command a plugin's namespace answers, generic over the same `TConfig`
157
+ * as the owning {@link Plugin}. `action` is a plain string, not a nested
158
+ * sub-namespace, so a multi-word action such as `secret status` (declared
159
+ * by the bundled pds plugin, `packages/pds/src/plugin.ts`) is one
160
+ * `PluginCommand`, not two levels of dispatch; the host matches the longest
161
+ * declared action first against the remaining positionals. Declared with a
162
+ * method signature (`run(...)`), not an arrow-typed property
163
+ * (`run: (...) => ...`) - that is what makes
164
+ * `PluginCommand<A>[]` and `PluginCommand<B>[]` both assignable to
165
+ * `PluginCommand<unknown>[]` with no cast; see {@link Plugin} for why the
166
+ * host's registry depends on it.
167
+ */
168
+ export interface PluginCommand<TConfig = never> {
169
+ /** The action name, e.g. `sync` or `secret status`. */
170
+ action: string;
171
+ /** One line, shown next to the action in help output. */
172
+ summary: string;
173
+ /**
174
+ * Run the command. `args` is whatever positionals/flags remain once
175
+ * `action` - and, by the usual positional/`--env` rule every built-in
176
+ * command already follows, the environment - are consumed.
177
+ */
178
+ run(ctx: PluginContext<TConfig>, args: string[]): Promise<void>;
179
+ }
180
+ /**
181
+ * A node in the infrastructure dependency graph, generic over `Ctx` - the
182
+ * context its four methods receive. `Ctx` defaults to {@link PluginContext},
183
+ * so a plugin writes plain `ResourceNode` for a node typed against its own
184
+ * narrow context; the CLI instantiates `ResourceNode<OpsContext>`
185
+ * (`packages/cli/src/nodes.ts`) for its own, wider one.
186
+ *
187
+ * The parameter is deliberately **unconstrained** - there is no
188
+ * `Ctx extends PluginContext` here, and there cannot be. The CLI's
189
+ * `OpsContext` (`packages/cli/src/context.ts`) does not satisfy
190
+ * `PluginContext`: it lacks `pluginConfig`, `siteState` and `record` - the
191
+ * same three members task 01's `PluginContext composition` test
192
+ * (`packages/cli/src/context.test.ts`) names off the `TS2739` its own gate
193
+ * rests on. Under a `Ctx extends PluginContext` bound the CLI stops
194
+ * compiling with `TS2344`, because the argument fails the constraint: its
195
+ * fifteen node factories annotate `ResourceNode` bare, so the error surfaces
196
+ * at the one alias they resolve through (`nodes.ts`'s
197
+ * `type ResourceNode = CoreResourceNode<OpsContext>`) and at each remaining
198
+ * explicit instantiation - twelve diagnostics in all, measured, not
199
+ * estimated.
200
+ * Nor is there a supertype of the two contexts worth naming as a bound -
201
+ * `OpsContext` carries CLI-private concerns (`agentDir`, the six-member
202
+ * `Ports`) that `PluginContext` must never see, and `PluginContext` carries
203
+ * `pluginConfig`/`siteState`/`record` that `OpsContext` has no use for.
204
+ * `ResourceNode<OpsContext>` and `ResourceNode<PluginContext>` are therefore
205
+ * two unrelated instantiations of the same generic type, with nothing
206
+ * converting between them.
207
+ *
208
+ * What lets one engine run both - `topoSort`, `applyGraph` and
209
+ * `destroyGraph` (`packages/cli/src/graph.ts`) - is that the engine's own
210
+ * generic constraint is not `PluginContext` either. It is the structural
211
+ * minimum the engine actually reads off a node's context (a logger, a way
212
+ * to persist state, and the state's resources map - `graph.ts`'s exported
213
+ * `GraphContext`), which both `OpsContext` and `PluginContext` satisfy
214
+ * structurally, without either one naming the other or this module.
215
+ */
216
+ export interface ResourceNode<Ctx = PluginContext> {
217
+ id: string;
218
+ dependsOn: string[];
219
+ /** Human label for logging. */
220
+ title: string;
221
+ /** Does the resource already exist? (Also hydrates outputs into ctx.state.) */
222
+ read(ctx: Ctx): Promise<boolean>;
223
+ create(ctx: Ctx): Promise<void>;
224
+ /** Reconcile an existing resource (optional). */
225
+ update?(ctx: Ctx): Promise<void>;
226
+ delete(ctx: Ctx): Promise<void>;
227
+ }
228
+ /**
229
+ * One question a plugin's `init` contributor asks, generalising
230
+ * `packages/cli/src/init.ts`'s `Question`. An unanswered optional
231
+ * question (`required` unset or `false`) resolves to the empty string,
232
+ * never `undefined` - DEVELOPMENT.md §Error handling bans `undefined`
233
+ * standing in for a domain value.
234
+ */
235
+ export interface PluginQuestion {
236
+ /** The prompt text shown to the operator. */
237
+ prompt: string;
238
+ /** Prefilled answer, shown in the prompt and used verbatim when the operator presses enter with no input. */
239
+ defaultValue?: string | undefined;
240
+ /** When true, an empty answer re-prompts instead of being accepted. */
241
+ required?: boolean | undefined;
242
+ /** Return a problem message to re-prompt with, or nothing when the answer is acceptable. */
243
+ validate?: ((answer: string) => string | undefined) | undefined;
244
+ }
245
+ /**
246
+ * The init contributor's surface: the narrow, terminal-shaped slice an
247
+ * `init?(io)` contributor needs to ask its own questions, without depending
248
+ * on the CLI's `Terminal`/`Logger` types directly. It lives here, in core,
249
+ * because both the config-block splice (`blogwright <plugin> init`) and the
250
+ * first-run wizard (`blogwright init`) reach a plugin's contributor through
251
+ * it and need one shared shape.
252
+ */
253
+ export interface PluginInitIo {
254
+ /** True when the session has an interactive TTY, mirroring `Terminal.isInteractive`. */
255
+ readonly isInteractive: boolean;
256
+ /** Leveled logger for the contributor to report through. */
257
+ logger: PluginLogger;
258
+ /** Ask one question and resolve with the answer - the empty string for an unanswered optional question, never `undefined`. */
259
+ ask(question: PluginQuestion): Promise<string>;
260
+ }
261
+ /**
262
+ * One property/comment pair an `init?(io)` contributor returns, mirroring
263
+ * the entry shape `renderConfig` builds (`packages/cli/src/init.ts`).
264
+ * `property` is the rendered `"key": value` text; `comment` is an optional
265
+ * trailing `//` note. A contributor the operator declines returns an empty
266
+ * array, never `undefined`.
267
+ */
268
+ export interface ConfigBlockEntry {
269
+ property: string;
270
+ comment?: string | undefined;
271
+ }
272
+ /**
273
+ * Namespace pattern shared by `Plugin.name` and {@link PluginManifest}'s
274
+ * `plugin` field, so the JSON Schema fragment
275
+ * (`.specs/changes/2026-07-26-cli_plugin_system.md` §Type changes) and this
276
+ * runtime check cannot drift apart. Lowercase alphanumerics and dashes.
277
+ */
278
+ export declare const PLUGIN_NAME_PATTERN: RegExp;
279
+ /**
280
+ * The `blogwright` field a plugin package declares in its own
281
+ * `package.json` - not in blogwright's own config. Discovery reads this to
282
+ * decide a candidate dependency is a plugin at all; `plugin` is the CLI
283
+ * namespace the package claims, checked against {@link PLUGIN_NAME_PATTERN}
284
+ * the same way `Plugin.name` is.
285
+ */
286
+ export interface PluginManifest {
287
+ plugin: string;
288
+ }
289
+ /**
290
+ * A plugin's default export: the whole SPI surface, generic over `TConfig` -
291
+ * the shape of the config block it owns (see {@link PluginContext.pluginConfig}
292
+ * and its doc comment on why the block is read from there, never from
293
+ * `ctx.config`). A plugin declares exactly these members and nothing else:
294
+ * no lifecycle hooks, no plugin-to-plugin dependencies, no contributed
295
+ * ports or adapters, and no merged config schema beyond the one owned key.
296
+ *
297
+ * The default type argument is `never`, not `unknown`, so a plugin that
298
+ * owns no `configKey` writes bare `Plugin` with no argument. Inside such a
299
+ * plugin every property read off `ctx.pluginConfig` -
300
+ * `ctx.pluginConfig.anything` - is `TS2339: Property 'anything' does not
301
+ * exist on type 'never'` (verified against this repo's tsc 6.0.3): that is
302
+ * the check worth having, because under `unknown` the same plugin would
303
+ * instead have to narrow a value it has no schema for, for no benefit. The
304
+ * one unsoundness `never` leaves - recorded here rather than designed
305
+ * around, because no plugin reads its config this way, and the
306
+ * property-level check above is what a mistaken read actually looks like -
307
+ * is the *whole-field* assignment `const n: number = ctx.pluginConfig`,
308
+ * which compiles, because `never` is assignable to every type. Widening the
309
+ * default to `unknown` would trade that property-level check away for a
310
+ * narrowing no plugin has a schema for, and must not be done to "fix" this.
311
+ *
312
+ * The host's registry is `Plugin<unknown>[]`: a `Plugin<PdsConfig>` and a
313
+ * `Plugin<AnalyticsConfig>` both join it, because `commands[].run` and
314
+ * `nodes` are method-declared (not arrow-typed properties) and therefore
315
+ * bivariant in their parameter types - see the type-level test in
316
+ * `plugin.test.ts`. The host consequently builds a `PluginContext<unknown>`
317
+ * and dispatches every plugin through that one registry with no cast. It
318
+ * never constructs a `PluginContext<never>`: nothing inhabits `never`, so
319
+ * `pluginConfig` would have no value to put there, and reaching one would
320
+ * take the `as` cast DEVELOPMENT.md §Code style bans. For a plugin that
321
+ * owns no `configKey` the host puts an empty object in `pluginConfig`
322
+ * instead of `undefined`, per the no-null rule - and such a plugin cannot
323
+ * read it either way, since it never names a `TConfig` to narrow it with.
324
+ */
325
+ export interface Plugin<TConfig = never> {
326
+ /** The CLI namespace this plugin claims (`analytics` answers `blogwright analytics <action>`). Lowercase alphanumerics and dashes - see {@link PLUGIN_NAME_PATTERN}. */
327
+ name: string;
328
+ /** One line, shown in `blogwright --help`. */
329
+ description: string;
330
+ /** The actions this namespace accepts. */
331
+ commands: PluginCommand<TConfig>[];
332
+ /** Resource-graph nodes this plugin contributes, if any. Never invoked by {@link validatePlugin}. */
333
+ nodes?(ctx: PluginContext<TConfig>): ResourceNode[];
334
+ /** The single top-level config key this plugin owns, if any. */
335
+ configKey?: string;
336
+ /**
337
+ * Validate this plugin's raw config block and **return it**, applying the
338
+ * plugin's own defaults, raising in the repo's own error vocabulary when
339
+ * it does not hold. Never invoked by {@link validatePlugin}.
340
+ */
341
+ validateConfig?(raw: unknown): TConfig;
342
+ /**
343
+ * The init contributor: ask questions through `io` and return the config
344
+ * block to write - an empty array when the operator declines. Never
345
+ * invoked by {@link validatePlugin}.
346
+ */
347
+ init?(io: PluginInitIo): Promise<ConfigBlockEntry[]>;
348
+ }
349
+ /**
350
+ * Turn an arbitrary imported module into a trusted {@link Plugin}, or raise
351
+ * naming `packageName` and stating what would fix it. This is the *only*
352
+ * boundary between a package discovery resolved off disk and the rest of
353
+ * the host: validation never invokes anything the module supplies - not
354
+ * `nodes`, not `init`, not `validateConfig`, not any command's `run` - only
355
+ * property reads and `typeof`/`Array.isArray` narrowing, so a malformed or
356
+ * hostile module cannot run code merely by being validated. No raised
357
+ * message echoes a value read off the module; only `packageName`, supplied
358
+ * by the caller, appears in the text.
359
+ */
360
+ export declare function validatePlugin(module: unknown, packageName: string): Plugin;