blogwright 0.3.2 → 0.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +11 -11
  2. package/agent/agent-manifest.json +1 -1
  3. package/agent/server.js +37 -19
  4. package/dist/adapters/fetch-ping.d.ts +1 -1
  5. package/dist/adapters/fetch-ping.js +3 -4
  6. package/dist/adapters/node-module-loader.d.ts +11 -0
  7. package/dist/adapters/node-module-loader.js +146 -0
  8. package/dist/adapters/process-package-manager.d.ts +41 -0
  9. package/dist/adapters/process-package-manager.js +116 -0
  10. package/dist/adapters/process-vcs.d.ts +5 -4
  11. package/dist/adapters/process-vcs.js +6 -6
  12. package/dist/agent-package.d.ts +1 -1
  13. package/dist/agent-package.js +4 -4
  14. package/dist/bin.js +13 -3
  15. package/dist/cli.d.ts +68 -1
  16. package/dist/cli.js +270 -89
  17. package/dist/commands.d.ts +70 -3
  18. package/dist/commands.js +180 -32
  19. package/dist/config-block.d.ts +34 -0
  20. package/dist/config-block.js +262 -0
  21. package/dist/context.d.ts +76 -6
  22. package/dist/context.js +98 -19
  23. package/dist/deploy.d.ts +2 -2
  24. package/dist/deploy.js +14 -14
  25. package/dist/graph.d.ts +27 -16
  26. package/dist/graph.js +1 -2
  27. package/dist/init.d.ts +37 -3
  28. package/dist/init.js +146 -23
  29. package/dist/known-commands.d.ts +63 -0
  30. package/dist/known-commands.js +78 -0
  31. package/dist/logger.js +0 -1
  32. package/dist/microvms.d.ts +2 -2
  33. package/dist/microvms.js +3 -4
  34. package/dist/nodes.d.ts +5 -3
  35. package/dist/nodes.js +97 -31
  36. package/dist/plugin-commands.d.ts +298 -0
  37. package/dist/plugin-commands.js +990 -0
  38. package/dist/plugins.d.ts +194 -0
  39. package/dist/plugins.js +523 -0
  40. package/dist/ports.d.ts +89 -1
  41. package/dist/ports.js +0 -1
  42. package/dist/render.d.ts +55 -0
  43. package/dist/render.js +89 -2
  44. package/dist/repo.d.ts +3 -3
  45. package/dist/repo.js +8 -9
  46. package/dist/rkey.js +0 -1
  47. package/dist/seo.d.ts +1 -1
  48. package/dist/seo.js +1 -2
  49. package/package.json +6 -6
  50. package/dist/adapters/fetch-ping.js.map +0 -1
  51. package/dist/adapters/process-vcs.js.map +0 -1
  52. package/dist/agent-package.js.map +0 -1
  53. package/dist/bin.js.map +0 -1
  54. package/dist/cli.js.map +0 -1
  55. package/dist/commands.js.map +0 -1
  56. package/dist/context.js.map +0 -1
  57. package/dist/deploy.js.map +0 -1
  58. package/dist/graph.js.map +0 -1
  59. package/dist/init.js.map +0 -1
  60. package/dist/logger.js.map +0 -1
  61. package/dist/microvms.js.map +0 -1
  62. package/dist/nodes.js.map +0 -1
  63. package/dist/ports.js.map +0 -1
  64. package/dist/render.js.map +0 -1
  65. package/dist/repo.js.map +0 -1
  66. package/dist/rkey.js.map +0 -1
  67. package/dist/seo.js.map +0 -1
  68. package/dist/test-support.d.ts +0 -45
  69. package/dist/test-support.js +0 -126
  70. package/dist/test-support.js.map +0 -1
package/dist/nodes.js CHANGED
@@ -54,7 +54,7 @@ function bucketNode() {
54
54
  }
55
55
  function logGroupNode(id, title, name, days,
56
56
  // CloudFront vended log delivery exists only in us-east-1, so its log group
57
- // must live there too regardless of the stack's primary region.
57
+ // must live there too - regardless of the stack's primary region.
58
58
  usEast1 = false) {
59
59
  const logs = (ctx) => (usEast1 ? ctx.clients.logsUsEast1 : ctx.clients.logs);
60
60
  const region = (ctx) => (usEast1 ? 'us-east-1' : ctx.config.region);
@@ -92,7 +92,7 @@ const LAMBDA_TRUST = {
92
92
  ],
93
93
  };
94
94
  /** IAM role Lambda assumes while building the MicroVM image. */
95
- /** Apply the build role's inline policy (idempotent used by create + update). */
95
+ /** Apply the build role's inline policy (idempotent - used by create + update). */
96
96
  async function applyBuildRolePolicy(ctx) {
97
97
  // The build role is BOTH the image-build role AND the MicroVM's ambient runtime
98
98
  // identity (via IMDS), so it needs the build's runtime S3 permissions: read the
@@ -104,7 +104,7 @@ async function applyBuildRolePolicy(ctx) {
104
104
  { Effect: 'Allow', Action: ['s3:ListBucket'], Resource: `arn:aws:s3:::${ctx.names.bucket}` },
105
105
  {
106
106
  // s3:PutObjectTagging is required even though the tags ride on the PUT
107
- // itself (x-amz-tagging header) AWS checks it as a distinct action, and
107
+ // itself (x-amz-tagging header) - AWS checks it as a distinct action, and
108
108
  // PutObject does not imply it. Without it every tagged upload 403s.
109
109
  Effect: 'Allow',
110
110
  Action: ['s3:PutObject', 's3:PutObjectTagging', 's3:DeleteObject'],
@@ -158,7 +158,7 @@ async function applyExecRolePolicy(ctx) {
158
158
  Resource: `arn:aws:s3:::${ctx.names.bucket}/*`,
159
159
  },
160
160
  {
161
- // s3:PutObjectTagging: see the build role a tagged PUT needs it explicitly.
161
+ // s3:PutObjectTagging: see the build role - a tagged PUT needs it explicitly.
162
162
  Effect: 'Allow',
163
163
  Action: ['s3:PutObject', 's3:PutObjectTagging', 's3:DeleteObject'],
164
164
  Resource: siteWriteResource(ctx),
@@ -235,7 +235,7 @@ async function imageInput(ctx) {
235
235
  }
236
236
  /**
237
237
  * Poll until the image reaches its expected terminal state, treating a timeout (still
238
- * CREATING/UPDATING) as failure never success. For updates, require the version to
238
+ * CREATING/UPDATING) as failure - never success. For updates, require the version to
239
239
  * advance past `priorVersion` so a stale pre-update state can't be read as done.
240
240
  */
241
241
  async function awaitImageSettled(ctx, arn, expected, priorVersion) {
@@ -272,7 +272,7 @@ export function builderImageAction(image, recorded, hash, logGroup) {
272
272
  * Create, rebuild, or leave the MicroVM builder image, depending on what's deployed:
273
273
  * create it if missing, rebuild it if the agent bundle (or its log group) changed or the
274
274
  * last build is unhealthy, otherwise no-op. Idempotent and cheap in the common case (a
275
- * single GetMicrovmImage + hash compare), so it's safe to run before every deploy which
275
+ * single GetMicrovmImage + hash compare), so it's safe to run before every deploy - which
276
276
  * is how build-agent changes propagate through CI without a separate `bootstrap`.
277
277
  */
278
278
  export async function reconcileBuilderImage(ctx) {
@@ -333,12 +333,12 @@ function microvmImageNode() {
333
333
  },
334
334
  };
335
335
  }
336
- /** ACM certificate (us-east-1) only present when a custom domain is configured. */
336
+ /** ACM certificate (us-east-1) - only present when a custom domain is configured. */
337
337
  function certificateNode() {
338
338
  return {
339
339
  id: 'acm-certificate',
340
340
  // Depends on the bucket so state (with the cert ARN) can be saved before the long
341
- // ISSUED wait the id sorts before 'bucket', so without this it would run first.
341
+ // ISSUED wait - the id sorts before 'bucket', so without this it would run first.
342
342
  dependsOn: ['bucket'],
343
343
  title: 'ACM certificate',
344
344
  async read(ctx) {
@@ -373,7 +373,7 @@ function certificateNode() {
373
373
  let initial = await ctx.clients.acm.describeCertificate(arn);
374
374
  if (initial.status === 'PENDING_VALIDATION' && initial.validation.length === 0) {
375
375
  // ACM populates the validation ResourceRecords asynchronously after
376
- // RequestCertificate an empty set here is that race, not "nothing to
376
+ // RequestCertificate - an empty set here is that race, not "nothing to
377
377
  // validate". Acting on it would skip record creation entirely and the
378
378
  // issuance poll below could never succeed.
379
379
  initial = await pollUntil(() => ctx.clients.acm.describeCertificate(arn), (s) => s.status !== 'PENDING_VALIDATION' || s.validation.length > 0, { intervalMs: 5_000, timeoutMs: 5 * 60_000 });
@@ -383,7 +383,7 @@ function certificateNode() {
383
383
  }
384
384
  if (initial.status !== 'ISSUED' && initial.validation.length > 0) {
385
385
  if (ctx.preview) {
386
- // Preview domain is a Route53 hosted zone create the validation records for you.
386
+ // Preview domain is a Route53 hosted zone - create the validation records for you.
387
387
  const zoneId = await ctx.clients.route53.hostedZoneId(domain);
388
388
  if (!zoneId)
389
389
  throw new Error(`no Route53 hosted zone found for ${domain}`);
@@ -454,7 +454,7 @@ const PREVIEW_FUNCTION_CODE = `function handler(event) {
454
454
  /**
455
455
  * CloudFront Function (viewer-request) for staging/production: resolve a directory URL
456
456
  * to its index document (`/projects/` → `/projects/index.html`). Required because the S3
457
- * origin is the private REST endpoint (via OAC), which unlike an S3 website endpoint
457
+ * origin is the private REST endpoint (via OAC), which - unlike an S3 website endpoint -
458
458
  * does no index-document resolution, so `DefaultRootObject` only covers the apex. The
459
459
  * `/site` origin path is applied by the distribution, so this function must not add it.
460
460
  */
@@ -496,7 +496,7 @@ function routerFunctionNode(preview) {
496
496
  /**
497
497
  * Find the distribution a crashed earlier bootstrap created but never recorded in
498
498
  * state. The deterministic comment (`"<siteName> <env>"`) narrows candidates; identity
499
- * is confirmed by CallerReference comments are editable in the console, while the
499
+ * is confirmed by CallerReference - comments are editable in the console, while the
500
500
  * reference is immutable for the distribution's life and collision-free across
501
501
  * environments by construction.
502
502
  */
@@ -548,7 +548,7 @@ function distributionNode(hasDomain, preview) {
548
548
  functionArn: String(output(ctx, 'cloudfront-function').arn),
549
549
  // Previews are served uncached (per-PR content, host-routed); staging/production
550
550
  // keep the default cache policy. Non-preview stacks map the S3 REST origin's
551
- // 403/404 (a missing key) to the site's 404 page or, in SPA mode, to
551
+ // 403/404 (a missing key) to the site's 404 page - or, in SPA mode, to
552
552
  // /index.html with a 200 so client-side routes deep-link correctly.
553
553
  ...(preview
554
554
  ? { cachePolicyId: CACHING_DISABLED }
@@ -563,8 +563,8 @@ function distributionNode(hasDomain, preview) {
563
563
  }
564
564
  catch (err) {
565
565
  // A crashed earlier bootstrap can leave a distribution in AWS that state never
566
- // recorded. Retrying then 409s on the duplicate alias CloudFront's CNAME
567
- // conflict check fires before the CallerReference idempotency match so adopt
566
+ // recorded. Retrying then 409s on the duplicate alias - CloudFront's CNAME
567
+ // conflict check fires before the CallerReference idempotency match - so adopt
568
568
  // the orphan instead. No verified match means the alias belongs to a foreign
569
569
  // distribution: that conflict is real and must surface.
570
570
  const conflict = err instanceof AwsError &&
@@ -599,7 +599,7 @@ function distributionNode(hasDomain, preview) {
599
599
  },
600
600
  async update(ctx) {
601
601
  // A domain added (or changed) after the first bootstrap must reach the
602
- // existing distribution the certificate node validates the cert, but
602
+ // existing distribution - the certificate node validates the cert, but
603
603
  // only this reconcile attaches the alias + viewer certificate.
604
604
  const id = output(ctx, 'cloudfront-distribution').id;
605
605
  if (typeof id !== 'string')
@@ -607,7 +607,7 @@ function distributionNode(hasDomain, preview) {
607
607
  if (!ctx.domain) {
608
608
  // Deliberately no automatic alias removal: dropping --domain from a
609
609
  // later run must not detach a live site's hostname.
610
- ctx.logger.ok('no domain configured existing aliases left as-is');
610
+ ctx.logger.ok('no domain configured - existing aliases left as-is');
611
611
  return;
612
612
  }
613
613
  const alias = preview ? `*.${ctx.domain}` : ctx.domain;
@@ -635,6 +635,52 @@ function distributionNode(hasDomain, preview) {
635
635
  },
636
636
  };
637
637
  }
638
+ /**
639
+ * True when `delivery` is the one this site's node created. AWS permits exactly
640
+ * one delivery source per distribution, so the analytics plugin necessarily
641
+ * hangs its own delivery off the site's source: ownership is the destination a
642
+ * delivery feeds, and nothing else. The final `:`-separated segment of a
643
+ * `delivery-destination` ARN is its name, which is derived from the environment
644
+ * (`deriveNames`) and therefore also matches the previous stack's delivery on a
645
+ * destroy → bootstrap cycle - exactly the delivery the ConflictException retry
646
+ * exists to remove.
647
+ *
648
+ * Position cannot stand in for this (`findDeliveryIdBySource` takes whichever
649
+ * delivery AWS lists first), and neither can the recorded `destination` output:
650
+ * it is empty precisely when the retry needs it, because `putDeliverySource`
651
+ * throws the Conflict before `putDeliveryDestination` ever runs.
652
+ */
653
+ function isOwnDelivery(delivery, destinationName) {
654
+ return delivery.deliveryDestinationArn.split(':').pop() === destinationName;
655
+ }
656
+ /**
657
+ * The ids of the site's own deliveries on `names.deliverySource`, after
658
+ * refusing outright when the shared source carries a delivery the site did not
659
+ * create. Both the `delete()` teardown and the ConflictException retry call
660
+ * this BEFORE deleting anything: each of them goes on to delete the delivery
661
+ * source, and AWS rejects that while another delivery is attached - a refusal
662
+ * `deleteDeliverySource` does not catch (it swallows only not-found). Refusing
663
+ * up front turns a teardown that throws part-way, after the site's own delivery
664
+ * is already gone, into an actionable stop with nothing removed.
665
+ */
666
+ async function ownDeliveryIdsOrRefuse(ctx) {
667
+ const deliveries = await ctx.clients.logsUsEast1.deliveriesForSource(ctx.names.deliverySource);
668
+ const foreign = deliveries.filter((d) => !isOwnDelivery(d, ctx.names.deliveryDestination));
669
+ if (foreign.length > 0) {
670
+ const listed = foreign
671
+ .map((d) => `${d.id} (destination ${d.deliveryDestinationArn})`)
672
+ .join(', ');
673
+ throw new Error(`delivery source ${ctx.names.deliverySource} still carries ` +
674
+ `${foreign.length === 1 ? 'a delivery' : `${foreign.length} deliveries`} this site does ` +
675
+ `not own (${listed}); removing the source would break whoever does. Tear that stack ` +
676
+ `down first, then retry: blogwright analytics destroy ${ctx.env} --yes`);
677
+ }
678
+ // Filtered, not `deliveries.map(...)`: the throw above already guarantees every
679
+ // delivery here is the site's own, but that is a non-local guarantee. Re-applying
680
+ // the predicate makes the name and the body agree on their own, so softening the
681
+ // refusal cannot silently start feeding foreign ids to the delete loop.
682
+ return deliveries.filter((d) => isOwnDelivery(d, ctx.names.deliveryDestination)).map((d) => d.id);
683
+ }
638
684
  /** Wire CloudFront access logs to the CloudWatch log group via vended log delivery. */
639
685
  function logDeliveryNode() {
640
686
  async function wire(ctx) {
@@ -663,13 +709,21 @@ function logDeliveryNode() {
663
709
  }
664
710
  catch (err) {
665
711
  // delete() below leaves the delivery plumbing behind, and PutDeliverySource
666
- // refuses to repoint an existing source at a new distribution ARN so a
712
+ // refuses to repoint an existing source at a new distribution ARN - so a
667
713
  // destroy → bootstrap cycle hits ConflictException here. Remove the stale
668
714
  // delivery/source/destination trio and retry once.
669
715
  if (!(err instanceof AwsError && /Conflict/i.test(err.code)))
670
716
  throw err;
671
- ctx.logger.step('stale log delivery from a previous stack removing and retrying');
672
- for (const id of await ctx.clients.logsUsEast1.deliveriesForSource(ctx.names.deliverySource)) {
717
+ ctx.logger.step('stale log delivery from a previous stack - removing and retrying');
718
+ // Refuse before deleting anything when the source is shared: removing it
719
+ // IS the retry (PutDeliverySource will not repoint a source that still
720
+ // exists), so a foreign delivery forecloses the retry entirely. Deleting
721
+ // only the site's own delivery and pressing on would leave the stack with
722
+ // no CloudWatch delivery and a failed bootstrap - worse than the conflict
723
+ // being healed. Only the site's own ids are removed here; the old loop
724
+ // deleted every delivery on the source, silently unwiring the other owner.
725
+ const ownDeliveryIds = await ownDeliveryIdsOrRefuse(ctx);
726
+ for (const id of ownDeliveryIds) {
673
727
  await ctx.clients.logsUsEast1.deleteDelivery(id);
674
728
  }
675
729
  await ctx.clients.logsUsEast1.deleteDeliverySource(ctx.names.deliverySource);
@@ -682,10 +736,11 @@ function logDeliveryNode() {
682
736
  // delivery source/destination persist, and a later bootstrap against a new
683
737
  // distribution ARN fails with ConflictException ("Update to existing Delivery Source
684
738
  // with new ResourceId is not allowed"). Delete the delivery first (it references both),
685
- // then the source and destination. The id isn't in state, so look it up by source name.
686
- const deliveryId = await ctx.clients.logsUsEast1.findDeliveryIdBySource(ctx.names.deliverySource);
687
- if (deliveryId)
688
- await ctx.clients.logsUsEast1.deleteDelivery(deliveryId);
739
+ // then the source and destination. The ids aren't in state, so look them up by source
740
+ // name - which also settles whether this site still owns the source at all.
741
+ const deliveryIds = await ownDeliveryIdsOrRefuse(ctx);
742
+ for (const id of deliveryIds)
743
+ await ctx.clients.logsUsEast1.deleteDelivery(id);
689
744
  await ctx.clients.logsUsEast1.deleteDeliverySource(ctx.names.deliverySource);
690
745
  await ctx.clients.logsUsEast1.deleteDeliveryDestination(ctx.names.deliveryDestination);
691
746
  },
@@ -731,12 +786,12 @@ const GITHUB_OIDC_URL = 'token.actions.githubusercontent.com';
731
786
  // this value, but the API requires one.
732
787
  const GITHUB_OIDC_THUMBPRINT = '6938fd4d98bab03faadb97b34396831e3780aea1';
733
788
  /**
734
- * IAM role a GitHub Actions workflow assumes via OIDC to deploy/destroy previews
789
+ * IAM role a GitHub Actions workflow assumes via OIDC - to deploy/destroy previews
735
790
  * (preview stack, any ref) or to deploy production (main branch only, plus CloudFront
736
791
  * invalidation and read access to the PDS credentials secret).
737
792
  */
738
793
  function githubOidcRoleNode(preview) {
739
- const roleName = (ctx) => `${ctx.names.prefix}-gh`;
794
+ const roleName = (ctx) => ctx.names.githubRole;
740
795
  return {
741
796
  id: 'gh-oidc-role',
742
797
  // Production deploys invalidate the distribution, so its ARN must be in state.
@@ -763,7 +818,7 @@ function githubOidcRoleNode(preview) {
763
818
  /**
764
819
  * The workflow's OIDC subject claim, scoped per environment to match how each one
765
820
  * deploys: previews from any PR ref; staging from pushes to main; production from the
766
- * `production` GitHub Environment (release-gated see production.yml), which lets
821
+ * `production` GitHub Environment (release-gated - see production.yml), which lets
767
822
  * deploys be gated behind environment protection rules.
768
823
  */
769
824
  export function oidcSubClaim(repo, env, preview) {
@@ -829,6 +884,18 @@ export function oidcRolePolicyStatements(ctx) {
829
884
  // refresh tokens are single-use, so every sync persists the rotated
830
885
  // session (PutSecretValue via the upsert helper, which tries CreateSecret
831
886
  // first when the secret is missing).
887
+ //
888
+ // The `??` default duplicates the pds plugin's own `resolvePdsSecretName`
889
+ // deliberately, and must NOT be replaced by an import of it: this
890
+ // resource graph carries no plugin knowledge, which is the whole point of
891
+ // the plugin owning its own node (`packages/pds/src/nodes.ts`). Core
892
+ // stopped defaulting `pds.secretName`, so without this default the
893
+ // template literal would happily interpolate `undefined` into a live IAM
894
+ // policy - `applyOidcRole` rewrites this whole document on every
895
+ // `blogwright bootstrap`, so a wrong ARN here is a broken permission and
896
+ // not a test failure. Task 59 deletes this statement together with the
897
+ // `ctx.config.pds` branch around it; the duplication lives only until
898
+ // then.
832
899
  statements.push({
833
900
  Effect: 'Allow',
834
901
  Action: [
@@ -836,7 +903,7 @@ export function oidcRolePolicyStatements(ctx) {
836
903
  'secretsmanager:PutSecretValue',
837
904
  'secretsmanager:CreateSecret',
838
905
  ],
839
- Resource: `arn:aws:secretsmanager:${ctx.config.region}:${ctx.accountId}:secret:${ctx.config.pds.secretName}-*`,
906
+ Resource: `arn:aws:secretsmanager:${ctx.config.region}:${ctx.accountId}:secret:${ctx.config.pds.secretName ?? `${ctx.config.siteName}/atproto`}-*`,
840
907
  });
841
908
  }
842
909
  }
@@ -872,7 +939,7 @@ async function applyOidcRole(ctx, roleName) {
872
939
  }
873
940
  /**
874
941
  * Route53 wildcard record pointing *.<domain> at the preview CloudFront
875
- * distribution A/AAAA alias records (free queries, apex-safe), not a CNAME.
942
+ * distribution - A/AAAA alias records (free queries, apex-safe), not a CNAME.
876
943
  */
877
944
  function previewDnsNode() {
878
945
  async function upsertAliases(ctx) {
@@ -885,7 +952,7 @@ function previewDnsNode() {
885
952
  const cf = String(output(ctx, 'cloudfront-distribution').domainName);
886
953
  const out = output(ctx, 'preview-dns');
887
954
  // Route53 refuses A/AAAA alongside a CNAME at the same name, so clear any
888
- // CNAME first a pre-0.2.1 bootstrap's (recorded in state) or an
955
+ // CNAME first - a pre-0.2.1 bootstrap's (recorded in state) or an
889
956
  // operator's manual workaround (pointing at the distribution). Deleting a
890
957
  // record that is not there is a no-op.
891
958
  if (out.type !== 'ALIAS') {
@@ -979,4 +1046,3 @@ export function buildNodes(ctx) {
979
1046
  }
980
1047
  return nodes;
981
1048
  }
982
- //# sourceMappingURL=nodes.js.map
@@ -0,0 +1,298 @@
1
+ /**
2
+ * OWNERSHIP: this module and its test file (`plugin-commands.test.ts`) are
3
+ * created here, by task 10, which is their sole author for the whole plan.
4
+ * Tasks 13 (the generic `blogwright <plugin> init` action), 16 (the generic
5
+ * `bootstrap`/`status`/`destroy` lifecycle verbs) and 17 (`blogwright plugin
6
+ * list`) all extend the SAME dispatch surface and the SAME test file rather
7
+ * than re-creating either - on the plan's dependency graph none of the three
8
+ * later tasks depends on either of the other two, so without one named
9
+ * owner here, three tasks would each try to create these two files from
10
+ * scratch and collide. Extend this module; never recreate it.
11
+ *
12
+ * `runPlugin` is generic dispatch for `blogwright <plugin> <action>`: the
13
+ * fall-through `cli.ts` reaches once the first positional is neither a
14
+ * built-in command nor `plugin` itself (`KNOWN_COMMANDS`,
15
+ * `known-commands.ts`). It:
16
+ *
17
+ * 1. Runs `discover` (over the `fs`/`loader` ports the caller supplies -
18
+ * see `runPlugin`'s `ports` parameter) to find the installed plugin
19
+ * claiming `command` as its namespace.
20
+ * 2. Matches the LONGEST declared action against the leading positionals,
21
+ * so a multi-word action such as `secret status` dispatches by
22
+ * declaration - never by hand-rolled positional shifting. `cli.ts`'s
23
+ * `runPds` branch shifted positionals to reach `secret status` and
24
+ * `secret delete`; task 29 deleted it, and this step is now the only
25
+ * thing that resolves a multi-word action anywhere in the CLI.
26
+ * 3. Resolves the environment exactly the way every built-in command
27
+ * already does: the first positional left over once the action is
28
+ * consumed, overridden by `--env`, defaulting to `production`.
29
+ * 4. Builds the ONE `OpsContext` this dispatch needs, now that the real
30
+ * environment is confirmed, validates the matched plugin's OWN config
31
+ * block off that context's raw `configDocument` (`resolvePluginConfig`,
32
+ * `plugins.ts` - the dispatched plugin's block and no other), adapts
33
+ * the context into the narrow `PluginContext` the SPI promises
34
+ * (`toPluginContext`, below) with the validated block on
35
+ * `pluginConfig`, and runs the matched command, mapping a normal return
36
+ * to exit code 0.
37
+ *
38
+ * Steps 1-3 run BEFORE any `OpsContext` is built - see `runPlugin`'s own
39
+ * doc comment for why an earlier, provisional-context version of this
40
+ * function was wrong, not merely wasteful.
41
+ *
42
+ * TASK 13 - the generic `init` action, and PRECEDENCE. Step 2 above matches
43
+ * a plugin's own `commands` FIRST; the generic `init` action (`runGenericInit`,
44
+ * below) is only ever reached once that match has already failed AND the
45
+ * leading word is exactly `init`. This is the whole of §CLI → `blogwright
46
+ * <plugin> init`'s precedence rule: a plugin declaring its own `init`
47
+ * command owns whatever `blogwright <plugin> init` does, full stop - pds's
48
+ * `init` creates the standard.site publication record
49
+ * (`packages/pds/src/commands.ts:118`) and writes no config block at all,
50
+ * and it must never be shadowed by a generic config writer. Nothing here
51
+ * requires a declared `init` command to write config; the generic action
52
+ * applies only where NO `init` command is declared. The other half of the
53
+ * rule - a plugin may not declare BOTH an `init` command and an `init?(io)`
54
+ * contributor, because the contributor would then never run - is a
55
+ * discovery-time rejection in `plugins.ts`'s collision pass, not something
56
+ * this dispatcher has to account for: by the time a plugin reaches here it
57
+ * has at most one of the two.
58
+ *
59
+ * The generic action itself needs none of the AWS-reaching machinery
60
+ * `makeContext` builds (accountId, clients, state) - only the two ports a
61
+ * plugin's `init?(io)` contributor is typed against (`fs`, `terminal`) and
62
+ * the resolved environment/repo root `runPlugin` already has before any
63
+ * `OpsContext` exists. Building a real context just to splice a text file
64
+ * would additionally require a runnable AWS session before an operator has
65
+ * even finished being asked their plugin's questions - so it deliberately
66
+ * does not.
67
+ *
68
+ * TASK 16 - the generic `bootstrap`/`status`/`destroy` lifecycle verbs, and
69
+ * PRECEDENCE. Like `init`, these three are only ever reached once step 2's
70
+ * `matchAction` has already failed to match the plugin's own `commands` -
71
+ * but the precedence differs by verb:
72
+ *
73
+ * - `bootstrap` and `destroy` are ALWAYS the generic verbs. A plugin may
74
+ * not import the CLI (§CLI → Plugin dispatch), and so cannot run the
75
+ * engine (`applyGraph`/`destroyGraph`, `graph.ts`) itself - there is no
76
+ * way for a plugin's own `bootstrap`/`destroy` command to do what these
77
+ * verbs need to do. A plugin declaring either as one of its own
78
+ * `commands` is therefore rejected at discovery, naming the plugin and
79
+ * the colliding action - `plugins.ts`'s `rejectDeclaredLifecycleCollisions`,
80
+ * beside `rejectDeclaredInitCollisions` in the same collision pass (see
81
+ * that module's DECISION note) - so `matchAction` never has a real
82
+ * `bootstrap`/`destroy` command to match against in the first place.
83
+ * - `status` is the generic verb ONLY UNLESS the plugin declares its own -
84
+ * `read()` lives on the plugin's own nodes, no engine call is needed,
85
+ * so there is nothing stopping a plugin from implementing `status`
86
+ * itself (pds's `secret status` is a precedent for a plugin owning its
87
+ * own status reporting). A declared `status` command is therefore left
88
+ * alone: `matchAction` already matches it in step 2, and the generic
89
+ * verb below is never reached for that plugin.
90
+ *
91
+ * All three are further gated on `plugin.nodes` being declared at all
92
+ * (`genericLifecycleCommand`, below): a plugin with no `nodes` contributor
93
+ * gains none of the three, and asking for one falls through to the same
94
+ * unknown-action refusal every other unmatched action gets - it is not a
95
+ * special case, because `genericLifecycleCommand` returns `undefined` for
96
+ * exactly that plugin, the same way an undeclared `init` contributor leaves
97
+ * `runGenericInit` unreached above.
98
+ *
99
+ * Each of the three runs the CLI's own engine - `applyGraph`, `destroyGraph`
100
+ * (`graph.ts`) and `readNodeStatus` (`commands.ts`) - over `plugin.nodes(ctx)`
101
+ * against a context built by `toPluginContext` (below), which by this task
102
+ * re-points `store`/`state`/`save()` at a `StateStore` scoped to the
103
+ * plugin's own name (`state/<env>.<plugin>.json`) rather than the site's.
104
+ * `destroy` additionally refuses without `--yes`, mirroring the site verb's
105
+ * own contract (`commands.ts`'s `destroy`), and deletes the scoped state
106
+ * object itself once `destroyGraph` has torn down every node - mirroring
107
+ * `commands.ts`'s own `destroy`/`previewTeardown`, both of which call
108
+ * `ctx.store.delete()` right after `destroyGraph`.
109
+ *
110
+ * TASK 17 - the built-in `plugin` namespace (`runPluginNamespace`, at the
111
+ * foot of this module) is a SECOND entry point, not an action of the generic
112
+ * dispatch above: `plugin` is reserved (`known-commands.ts`), so no installed
113
+ * plugin can claim it and `runPlugin` never sees it. It lives here because it
114
+ * is the same surface - the actions an operator types after `blogwright` -
115
+ * and shares this module's discovery call and its unknown-action refusal
116
+ * shape. See its own section comment for why `cli.ts` must dispatch it before
117
+ * `createContext`.
118
+ */
119
+ import { type Plugin, type PluginContext, type Terminal } from 'blogwright-core';
120
+ import { type ContextOptions, type OpsContext } from './context.js';
121
+ import { type Logger } from './logger.js';
122
+ import type { PackageManager, Ports } from './ports.js';
123
+ /**
124
+ * The parsed flag values `main`'s single `parseArgs` call produces, as far
125
+ * as generic plugin dispatch reads them. `main` passes its own `values`
126
+ * object here directly - this interface only narrows what `runPlugin` reads
127
+ * off it, the same way `cli.ts`'s hand-rolled `PreviewValues` narrows the
128
+ * same shared object for itself. `PdsValues` was the other such narrowing
129
+ * until task 29 deleted it with the rest of the pds branch, which is why
130
+ * `identifier` below matters: this interface, and `serialiseFlags` under
131
+ * it, are now the only thing that carries `--identifier` to `pds login`.
132
+ */
133
+ export interface PluginValues {
134
+ env?: string | undefined;
135
+ domain?: string | undefined;
136
+ config?: string | undefined;
137
+ endpoint?: string | undefined;
138
+ hash?: string | undefined;
139
+ id?: string | undefined;
140
+ identifier?: string | undefined;
141
+ plain: boolean;
142
+ refresh: boolean;
143
+ yes: boolean;
144
+ help: boolean;
145
+ }
146
+ /**
147
+ * Adapt an `OpsContext` into the narrow `PluginContext` a plugin command
148
+ * runs against. This is an ADAPTATION, not an assignment: an `OpsContext`
149
+ * carries thirteen of `PluginContext`'s sixteen members and none of
150
+ * `pluginConfig`, `siteState` or `record`, so a bare assignment is
151
+ * `TS2739`. This function supplies exactly those three - plus the
152
+ * two-member `ports` `PluginPorts` narrows the CLI's six-member `Ports`
153
+ * to, and the plugin's own scoped `store`/`state`/`save()` (below) - and
154
+ * passes every other member through unchanged. No cast, no `any`, anywhere
155
+ * in it.
156
+ *
157
+ * `pluginConfig` is supplied by the CALLER rather than read here, because
158
+ * this function is handed a plugin's NAME and not the plugin: `runPlugin`
159
+ * calls `resolvePluginConfig(plugin, ctx.configDocument)` (`plugins.ts`)
160
+ * first, so an invalid block fails BEFORE the scoped `store.load()` below
161
+ * makes the dispatch's first AWS call and long before the command does any
162
+ * work. The parameter is required, with no `{}` default: the dispatcher
163
+ * erases `TConfig` (it dispatches `Plugin<unknown>` and returns
164
+ * `PluginContext<unknown>`), so a forgotten argument could not be caught
165
+ * anywhere downstream - `pnpm typecheck` catching it here is the only
166
+ * check there is.
167
+ *
168
+ * `siteState` is `ops.state` passed through as the read-only view the SPI
169
+ * promises - a plugin reads the site's own recorded outputs through it (the
170
+ * analytics log-delivery node reads the site's CloudFront distribution
171
+ * through it), but never writes it. It is deliberately NOT the scoped load
172
+ * below: overwriting it would leave a plugin unable to see the site's own
173
+ * outputs at all.
174
+ *
175
+ * `store`, `state` and `save()` are the ONE thing this function gets that a
176
+ * bare assignment from `OpsContext` would not: a `StateStore` scoped to
177
+ * `pluginName` (`state/<env>.<pluginName>.json`, `StateStore`'s fourth
178
+ * constructor argument - `packages/core/src/state.ts`), its own freshly
179
+ * loaded `OpsState`, and a `save()` that persists THAT state through THAT
180
+ * store - never the site's own `state/<env>.json`. This is why the function
181
+ * is `async` where a straight field-for-field adaptation would not need to
182
+ * be: building the plugin's own `state` requires awaiting the scoped
183
+ * store's `load()`. Before this existed (tasks 10-15), `OpsContext`'s
184
+ * `store`/`state`/`save()` typechecked straight through as `PluginContext`'s
185
+ * of the same names with no error - the TYPES lined up even though the
186
+ * STORAGE did not - which is why nothing before this task may call a
187
+ * plugin's `nodes(ctx)`: doing so would have silently recorded a plugin's
188
+ * resources into the site's own state document instead of its own, and
189
+ * `record`, below, closes exactly that gap by writing into the scoped
190
+ * `state.resources` rather than the site's.
191
+ */
192
+ export declare function toPluginContext(ops: OpsContext, pluginName: string, pluginConfig: unknown): Promise<PluginContext<unknown>>;
193
+ /**
194
+ * The generic lifecycle verbs `plugin` actually answers, as `{ action,
195
+ * summary }` pairs, for the two places that LIST a plugin's actions: the
196
+ * unknown-action refusal ({@link renderActions}, below) and `--help`
197
+ * (`cli.ts`'s `renderPluginSection`). Exported for the second of those;
198
+ * both must list exactly what {@link genericLifecycleCommand} would
199
+ * dispatch, or the listing advertises a verb the dispatcher refuses (or,
200
+ * worse, hides one that works - the state this function was added to fix,
201
+ * where a nodes-only plugin's refusal printed a heading and nothing at
202
+ * all).
203
+ *
204
+ * Gated on `plugin.nodes` exactly as `genericLifecycleCommand` is, so a
205
+ * plugin with no `nodes` contributor advertises none of the three. A verb
206
+ * the plugin declares ITSELF is omitted here rather than listed twice: only
207
+ * `status` can be declared (`bootstrap`/`destroy` are rejected at
208
+ * discovery, `plugins.ts`'s `rejectDeclaredLifecycleCollisions`), and its
209
+ * own command already appears in the caller's declared-command lines -
210
+ * where `matchAction`'s precedence means that is the one that actually
211
+ * runs.
212
+ */
213
+ export declare function genericLifecycleActions(plugin: Plugin<unknown>): {
214
+ action: string;
215
+ summary: string;
216
+ }[];
217
+ /**
218
+ * Handle `blogwright <command> <action> [env] [args]` once `command` has
219
+ * failed the `KNOWN_COMMANDS` membership test - i.e. it is either an
220
+ * installed plugin's namespace or entirely unknown.
221
+ *
222
+ * `ports` - the `fs`/`loader` pair `discover` needs - is supplied by the
223
+ * caller (`cli.ts`, from a small factory `bin.ts` wires to the real
224
+ * adapters) rather than read off an `OpsContext` built here. An EARLIER
225
+ * version of this function built a throwaway `OpsContext` first (via
226
+ * `makeContext`, guessing `production` or `--env`'s value) purely to reach
227
+ * its `ports.fs`/`ports.loader` for discovery, then rebuilt a second
228
+ * context once the real environment was known. That guess was not merely
229
+ * wasteful - it was WRONG on a repo whose only config file is for a
230
+ * non-default environment: `blogwright <plugin> <action> staging` on a repo
231
+ * with `config/staging.jsonc` and neither `config/production.jsonc` nor
232
+ * `ops.config.jsonc` made the throwaway build's `loadConfig` call
233
+ * (`context.ts`) throw `no config found for environment "production"` -
234
+ * naming an environment the operator never asked for, before the real one
235
+ * (`staging`) was ever read off the positionals. That is worse than the
236
+ * silent fallback-to-production this dispatcher exists to avoid, because
237
+ * the message actively misleads.
238
+ *
239
+ * `discover` only ever needed `Pick<Ports, 'fs' | 'loader'>` - both of which
240
+ * `createContext` builds BEFORE it loads any config (`context.ts`), and
241
+ * `cli.ts`'s `init` branch already constructs a `FileSystem` directly with
242
+ * no context at all - so threading the same two ports in from the caller
243
+ * removes the guess completely: the environment is resolved from
244
+ * `command`'s matched action BEFORE any `OpsContext` - throwaway or real -
245
+ * is built, and exactly one `makeContext` call happens, with the confirmed
246
+ * environment, reusing the SAME `fs`/`loader` discovery already used rather
247
+ * than letting a second call default fresh ones.
248
+ *
249
+ * EXIT CODES, and a deliberate deviation tasks 13/16/17 must not assume away.
250
+ * This task's definition of done asks that "a plugin command's return value
251
+ * maps to the process exit code". It cannot: `PluginCommand.run` is declared
252
+ * `Promise<void>` (`blogwright-core`'s `plugin.ts`), and the change spec names
253
+ * no return-code channel. So there is no value to map. What this dispatcher
254
+ * owns it returns - 0 once `run` resolves, 1 for an unknown plugin and 1 for an
255
+ * unknown action - and a command that genuinely fails signals it by REJECTING,
256
+ * which propagates to `bin.ts`'s error path. Adding actions here (tasks 13, 16,
257
+ * 17) means following that contract: reject to fail, do not invent a numeric
258
+ * return the SPI has nowhere to carry.
259
+ */
260
+ export declare function runPlugin(command: string, rest: string[], values: PluginValues, terminal: Terminal, logger: Logger, makeContext: (opts: ContextOptions) => Promise<OpsContext>, ports: Pick<Ports, 'fs' | 'loader'>): Promise<number>;
261
+ /**
262
+ * What `add` and `remove` need beyond the `fs`/`loader` pair `list` runs on.
263
+ * A single object rather than four more positional parameters, and every
264
+ * member is a FACTORY or a value rather than a live port, so a refusal path
265
+ * pays for none of them:
266
+ *
267
+ * - `values` - the same parsed flags `runPlugin` reads (`--yes`, `--env`,
268
+ * and the context overrides `remove`'s teardown branch forwards).
269
+ * - `makePackages` - the `PackageManager` port, built at the composition
270
+ * root. Called at most once per invocation, and never by `list` or by any
271
+ * refusal.
272
+ * - `cliVersion` - the running CLI's own version (`context.ts`'s
273
+ * `cliVersion`), resolved there because this module may not touch
274
+ * `node:fs`. Called only once `add` has decided it is going to install
275
+ * something.
276
+ * - `makeContext` - the ONE `OpsContext` `remove`'s "tear it down first"
277
+ * branch needs, and the only path in this namespace that builds one at
278
+ * all. Never called for `add`, for `list`, or for a `remove` that does
279
+ * not run a teardown.
280
+ */
281
+ export interface PluginNamespaceDeps {
282
+ readonly values: PluginValues;
283
+ readonly makePackages: () => PackageManager;
284
+ readonly cliVersion: () => Promise<string>;
285
+ readonly makeContext: (opts: ContextOptions) => Promise<OpsContext>;
286
+ }
287
+ /**
288
+ * Handle `blogwright plugin <action>`. Dispatched by `cli.ts` ahead of any
289
+ * `OpsContext` - see this section's own comment above for why that placement
290
+ * is load-bearing rather than merely tidy.
291
+ *
292
+ * An absent or unrecognised action lists the namespace's actions and returns
293
+ * 1, the same shape `runPlugin` refuses an unknown action of an installed
294
+ * plugin in. `add` and `remove` share this function's name resolution and
295
+ * validation - one gate, so the two verbs cannot disagree about what
296
+ * `analytics` means or about what is a package name at all.
297
+ */
298
+ export declare function runPluginNamespace(rest: readonly string[], terminal: Terminal, logger: Logger, ports: Pick<Ports, 'fs' | 'loader'>, deps: PluginNamespaceDeps): Promise<number>;