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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Deploy a static site to AWS from one CLI: S3 + CloudFront hosting, builds in an
4
4
  isolated **Lambda MicroVM**, PR previews, keyless GitHub-OIDC CI deploys, and
5
5
  optional [standard.site](https://standard.site) (AT Protocol) publishing. No
6
- CloudFormation, no Terraform, no CDK the infrastructure is a reconcilable
6
+ CloudFormation, no Terraform, no CDK - the infrastructure is a reconcilable
7
7
  dependency graph the CLI applies directly through signed AWS API calls.
8
8
 
9
9
  Works with any static site that installs and builds with pnpm: an Astro blog at
@@ -14,7 +14,7 @@ ends in a directory of files to serve.
14
14
 
15
15
  - Node ≥ 22 and pnpm (your site must build with `pnpm build`)
16
16
  - AWS credentials in the ambient provider chain (`aws sso login`, env vars, or
17
- an assumed role whatever your shell already has)
17
+ an assumed role - whatever your shell already has)
18
18
  - A git or jj repository (deploys are keyed to your revision hash)
19
19
 
20
20
  ## Get running
@@ -28,12 +28,12 @@ pnpm exec blogwright deploy # zip → build in a MicroVM → live site
28
28
  ```
29
29
 
30
30
  `init` asks four questions (site name, region, optional domain, optional GitHub
31
- repo). `bootstrap` prints the CloudFront domain and, if you set a domain, the
31
+ repo). `bootstrap` prints the CloudFront domain - and, if you set a domain, the
32
32
  ACM validation CNAMEs to add to DNS. `deploy` streams the build log with live
33
33
  progress and ends with a summary card and your URL. The `bw` alias works
34
34
  everywhere `blogwright` does.
35
35
 
36
- No TTY? Create the config by hand only two fields are required:
36
+ No TTY? Create the config by hand - only two fields are required:
37
37
 
38
38
  ```jsonc
39
39
  // config/production.jsonc
@@ -44,7 +44,7 @@ No TTY? Create the config by hand — only two fields are required:
44
44
 
45
45
  | Command | What it does |
46
46
  | --- | --- |
47
- | `init` | First-run wizard writes `config/production.jsonc` |
47
+ | `init` | First-run wizard - writes `config/production.jsonc` |
48
48
  | `bootstrap [env]` | Create/reconcile the infrastructure (idempotent; re-run after config changes) |
49
49
  | `deploy [env]` | Zip the repo, build in a MicroVM, sync to S3, invalidate only changed paths |
50
50
  | `status [env]` | Planned infrastructure vs live state, as a drift tree |
@@ -57,7 +57,7 @@ No TTY? Create the config by hand — only two fields are required:
57
57
 
58
58
  `deploy --refresh` re-uploads every file, even unchanged ones. Deploys normally
59
59
  skip content-identical files, but S3 only writes object metadata (content type,
60
- tags) on a PUT so use it once after an upgrade that fixes a content type or
60
+ tags) on a PUT - so use it once after an upgrade that fixes a content type or
61
61
  adds tags, to push that metadata onto live objects.
62
62
 
63
63
  Environment defaults to `production`; pass `staging` (or anything) positionally.
@@ -72,7 +72,7 @@ Everything beyond `region` + `siteName` has sensible defaults:
72
72
  ```jsonc
73
73
  {
74
74
  "region": "us-east-1",
75
- "siteName": "myblog", // names every AWS resource never change it
75
+ "siteName": "myblog", // names every AWS resource - never change it
76
76
  "domain": "blog.example.com", // ACM cert + CloudFront alias
77
77
  "githubRepo": "you/your-repo", // enables keyless CI deploys (OIDC)
78
78
 
@@ -85,14 +85,14 @@ Everything beyond `region` + `siteName` has sensible defaults:
85
85
  ```
86
86
 
87
87
  `sourceInclude` is for artifacts you build *before* deploying (a wasm bundle, a
88
- generated dataset) with toolchains the builder image deliberately lacks run
88
+ generated dataset) with toolchains the builder image deliberately lacks - run
89
89
  your pre-build, then `blogwright deploy`; a missing entry fails fast with a
90
90
  pointer.
91
91
 
92
92
  ## CI deploys (no stored keys)
93
93
 
94
94
  With `githubRepo` set, `bootstrap` provisions a GitHub-OIDC role. Your workflow
95
- assumes it and deploys no AWS secrets in GitHub:
95
+ assumes it and deploys - no AWS secrets in GitHub:
96
96
 
97
97
  ```yaml
98
98
  permissions: { id-token: write, contents: read }
@@ -115,14 +115,14 @@ a prefix delete.
115
115
 
116
116
  ## Output modes
117
117
 
118
- Pretty by default on a TTY live build progress, a deploy summary card, drift
118
+ Pretty by default on a TTY - live build progress, a deploy summary card, drift
119
119
  trees. Piped output and CI get stable, line-oriented plain text automatically;
120
120
  `--plain` forces it (ideal for agents), and `NO_COLOR` disables colour only.
121
121
 
122
122
  ## standard.site publishing
123
123
 
124
124
  Add a `pds` section to the config and your posts mirror to your AT Protocol
125
- PDS as standard.site records after every production deploy OAuth
125
+ PDS as standard.site records after every production deploy - OAuth
126
126
  confidential client, keys in Secrets Manager, rkeys derived from URL paths
127
127
  (exposed as the `blogwright/rkey` subpath so your site renders matching link
128
128
  tags). Setup order matters; see the
@@ -1,3 +1,3 @@
1
1
  {
2
- "hash": "946264210100"
2
+ "hash": "5891d8f87b3a"
3
3
  }
package/agent/server.js CHANGED
@@ -53,7 +53,7 @@ var AwsError = class extends Error {
53
53
  requestId;
54
54
  service;
55
55
  constructor(opts) {
56
- super(`${opts.service}: ${opts.code} ${opts.message} (HTTP ${opts.statusCode})`);
56
+ super(`${opts.service}: ${opts.code} - ${opts.message} (HTTP ${opts.statusCode})`);
57
57
  this.name = "AwsError";
58
58
  this.service = opts.service;
59
59
  this.code = opts.code;
@@ -78,7 +78,7 @@ function isRetryable(err) {
78
78
  //#region ../core/dist/aws/xml.js
79
79
  /**
80
80
  * Minimal XML helpers for the handful of AWS REST-XML / query responses this CLI
81
- * reads (S3, STS, IAM, CloudFront). Not a general XML parser it extracts tag
81
+ * reads (S3, STS, IAM, CloudFront). Not a general XML parser - it extracts tag
82
82
  * contents by name, which is sufficient for the flat response shapes we consume.
83
83
  */
84
84
  const ENTITIES = {
@@ -112,7 +112,7 @@ function textTag(xml, name) {
112
112
  return inner === void 0 ? void 0 : decodeEntities(inner.trim());
113
113
  }
114
114
  /**
115
- * Like {@link textTag} but verbatim no trimming. For values where whitespace
115
+ * Like {@link textTag} but verbatim - no trimming. For values where whitespace
116
116
  * is data, like S3 object keys (a trimmed key would target a different object).
117
117
  */
118
118
  function rawTextTag(xml, name) {
@@ -5719,8 +5719,25 @@ const GLOBAL_SERVICES = /* @__PURE__ */ new Set([
5719
5719
  "cloudfront",
5720
5720
  "route53"
5721
5721
  ]);
5722
+ /**
5723
+ * Turns a core `ServiceKey` or a plugin-supplied `ServiceDescriptor` into one shape.
5724
+ * The single resolution helper every `service`-keyed site reads.
5725
+ */
5726
+ function resolveService(service) {
5727
+ if (typeof service === "string") return {
5728
+ name: service,
5729
+ signingName: SIGNING_NAMES[service],
5730
+ global: GLOBAL_SERVICES.has(service)
5731
+ };
5732
+ return {
5733
+ name: service.service,
5734
+ signingName: service.signingName,
5735
+ global: service.global ?? false
5736
+ };
5737
+ }
5722
5738
  function resolveEndpoint(service, region, override) {
5723
- const signingRegion = GLOBAL_SERVICES.has(service) ? "us-east-1" : region;
5739
+ const resolved = resolveService(service);
5740
+ const signingRegion = resolved.global ? "us-east-1" : region;
5724
5741
  if (override) {
5725
5742
  const url = new URL(override);
5726
5743
  return {
@@ -5732,7 +5749,7 @@ function resolveEndpoint(service, region, override) {
5732
5749
  }
5733
5750
  return {
5734
5751
  protocol: "https:",
5735
- host: canonicalHost(service, region),
5752
+ host: canonicalHost(resolved.name, region),
5736
5753
  signingRegion,
5737
5754
  override: false
5738
5755
  };
@@ -7113,6 +7130,7 @@ var SigningClient = class {
7113
7130
  this.transport = opts.transport ?? fetchTransport;
7114
7131
  }
7115
7132
  async send(opts) {
7133
+ const resolved = resolveService(opts.service);
7116
7134
  const ep = resolveEndpoint(opts.service, this.region, this.endpointOverride);
7117
7135
  const [hostname, portStr] = ep.host.split(":");
7118
7136
  const port = portStr ? Number(portStr) : void 0;
@@ -7135,7 +7153,7 @@ var SigningClient = class {
7135
7153
  ...opts.body !== void 0 ? { body: opts.body } : {}
7136
7154
  });
7137
7155
  const signed = await new import_dist_cjs$1.SignatureV4({
7138
- service: SIGNING_NAMES[opts.service],
7156
+ service: resolved.signingName,
7139
7157
  region: ep.signingRegion,
7140
7158
  credentials: async () => {
7141
7159
  const c = await this.credentials();
@@ -7146,7 +7164,7 @@ var SigningClient = class {
7146
7164
  };
7147
7165
  },
7148
7166
  sha256: import_main.Sha256,
7149
- uriEscapePath: opts.service !== "s3"
7167
+ uriEscapePath: resolved.name !== "s3"
7150
7168
  }).sign(request);
7151
7169
  const qs = Object.keys(query).map((k) => `${escape(k)}=${escape(query[k])}`).join("&");
7152
7170
  const url = `${ep.protocol}//${ep.host}${opts.path}${qs ? `?${qs}` : ""}`;
@@ -7157,7 +7175,7 @@ var SigningClient = class {
7157
7175
  headers: signed.headers,
7158
7176
  body: opts.body
7159
7177
  });
7160
- if (response.statusCode >= 300) throw parseError(opts.service, response);
7178
+ if (response.statusCode >= 300) throw parseError(resolved.name, response);
7161
7179
  return response;
7162
7180
  }, { retryable: [
7163
7181
  "GET",
@@ -7634,7 +7652,7 @@ function unzipSync(data, opts) {
7634
7652
  let credCache;
7635
7653
  /**
7636
7654
  * Resolve the MicroVM's execution-role credentials. Tries standard env vars first
7637
- * (Lambda-style), then the ECS/container credentials endpoint covering both ways
7655
+ * (Lambda-style), then the ECS/container credentials endpoint - covering both ways
7638
7656
  * the exec role might be exposed inside the MicroVM.
7639
7657
  */
7640
7658
  async function resolveCredentials() {
@@ -7766,7 +7784,7 @@ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
7766
7784
  /**
7767
7785
  * Whether a built file must be PUT again. Content-identical files are normally
7768
7786
  * skipped (that is what keeps a redeploy cheap and its invalidation narrow),
7769
- * but S3 writes object metadata content type, tags only on a PUT, so a
7787
+ * but S3 writes object metadata - content type, tags - only on a PUT, so a
7770
7788
  * metadata fix would never reach them. `refresh` forces the upload.
7771
7789
  */
7772
7790
  function shouldUpload(existingEtag, md5, refresh) {
@@ -7781,12 +7799,12 @@ function isAccessDenied(err) {
7781
7799
  * Upload the site files, degrading gracefully when the role may not tag.
7782
7800
  *
7783
7801
  * Object tags ride on the PUT (`x-amz-tagging`), but AWS still checks
7784
- * `s3:PutObjectTagging` as a distinct action a role granted only `s3:PutObject`
7802
+ * `s3:PutObjectTagging` as a distinct action - a role granted only `s3:PutObject`
7785
7803
  * gets a 403 and the whole upload fails. Tags are metadata, not content: rather
7786
7804
  * than fail a deploy whose files are otherwise fine, drop the tags for the rest
7787
7805
  * of the run and say so. (A stack bootstrapped on a version that grants the
7788
7806
  * action never takes this path; one upgrading in place does, until it
7789
- * re-bootstraps and a CI deploy role cannot fix its own IAM.)
7807
+ * re-bootstraps - and a CI deploy role cannot fix its own IAM.)
7790
7808
  */
7791
7809
  function createSiteUploader(s3, log) {
7792
7810
  let taggingDenied = false;
@@ -7797,14 +7815,14 @@ function createSiteUploader(s3, log) {
7797
7815
  } catch (err) {
7798
7816
  if (!isAccessDenied(err)) throw err;
7799
7817
  taggingDenied = true;
7800
- log("warning: this role cannot tag objects (s3:PutObjectTagging denied) uploading untagged. Run `blogwright bootstrap <env>` to grant it, then redeploy with --refresh to tag the existing objects.");
7818
+ log("warning: this role cannot tag objects (s3:PutObjectTagging denied) - uploading untagged. Run `blogwright bootstrap <env>` to grant it, then redeploy with --refresh to tag the existing objects.");
7801
7819
  }
7802
7820
  await s3.putObject(bucket, key, content, type);
7803
7821
  };
7804
7822
  }
7805
7823
  /**
7806
7824
  * The lowercase extension of a key, or undefined when it has none (`LICENSE`,
7807
- * `_headers`) a leading dot is a dotfile, not an extension (`.nojekyll`).
7825
+ * `_headers`) - a leading dot is a dotfile, not an extension (`.nojekyll`).
7808
7826
  * Splitting the whole path would treat an extensionless *key* as its own
7809
7827
  * extension, which both mistypes files and produces nonsense diagnostics.
7810
7828
  */
@@ -7816,8 +7834,8 @@ function extensionOf(path) {
7816
7834
  }
7817
7835
  /**
7818
7836
  * Content type for a key, or {@link DEFAULT_CONTENT_TYPE} when the extension is
7819
- * unmapped or absent. The default is deliberate it makes a browser download
7820
- * rather than mis-render an unknown payload but a *silently* wrong header is
7837
+ * unmapped or absent. The default is deliberate - it makes a browser download
7838
+ * rather than mis-render an unknown payload - but a *silently* wrong header is
7821
7839
  * how the .webmanifest gap survived, so runBuild logs unmapped extensions.
7822
7840
  */
7823
7841
  function contentType(path) {
@@ -7925,7 +7943,7 @@ async function runBuild(s3, payload, log) {
7925
7943
  const uploaded = /* @__PURE__ */ new Set();
7926
7944
  const changedKeys = /* @__PURE__ */ new Set();
7927
7945
  const unmapped = /* @__PURE__ */ new Set();
7928
- if (payload.refresh) log("refresh: re-uploading every file (metadata content type, tags may have changed)");
7946
+ if (payload.refresh) log("refresh: re-uploading every file (metadata - content type, tags - may have changed)");
7929
7947
  for (const file of built) {
7930
7948
  const key = prefix + relative(distDir, file).split("\\").join("/");
7931
7949
  uploaded.add(key);
@@ -7938,7 +7956,7 @@ async function runBuild(s3, payload, log) {
7938
7956
  await uploadSiteFile(payload.bucket, key, content, type, payload.objectTags);
7939
7957
  changedKeys.add(key);
7940
7958
  }
7941
- if (unmapped.size > 0) log(`warning: no content type mapped for extension(s) ${[...unmapped].sort().join(", ")} serving them as ${DEFAULT_CONTENT_TYPE}`);
7959
+ if (unmapped.size > 0) log(`warning: no content type mapped for extension(s) ${[...unmapped].sort().join(", ")} - serving them as ${DEFAULT_CONTENT_TYPE}`);
7942
7960
  for (const key of existing.keys()) if (!uploaded.has(key)) {
7943
7961
  await s3.deleteObject(payload.bucket, key);
7944
7962
  changedKeys.add(key);
@@ -7998,7 +8016,7 @@ function startBuild(payload) {
7998
8016
  }
7999
8017
  /**
8000
8018
  * Poll s3://<bucket>/build/pending/ for jobs (the build trigger). Drains one unbuilt job
8001
- * per tick multiple targets (concurrent PR previews) each have their own key. The
8019
+ * per tick - multiple targets (concurrent PR previews) each have their own key. The
8002
8020
  * MicroVM's ambient IMDS credentials are the build role, which holds the site-write perms.
8003
8021
  */
8004
8022
  let polling = false;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Fetch adapter for the PingBuilder port: opens a connection to the builder
3
3
  * MicroVM's proxy endpoint to wake the resumed agent's event loop. Errors never
4
- * cross the port the ping is best-effort by contract, and the agent's HTTP/1
4
+ * cross the port - the ping is best-effort by contract, and the agent's HTTP/1
5
5
  * server may not even parse the request.
6
6
  */
7
7
  import type { PingBuilder } from '../ports.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Fetch adapter for the PingBuilder port: opens a connection to the builder
3
3
  * MicroVM's proxy endpoint to wake the resumed agent's event loop. Errors never
4
- * cross the port the ping is best-effort by contract, and the agent's HTTP/1
4
+ * cross the port - the ping is best-effort by contract, and the agent's HTTP/1
5
5
  * server may not even parse the request.
6
6
  */
7
7
  /** A nudge only needs the connection to land; never hold a poll cycle longer than this. */
@@ -14,13 +14,12 @@ export function createFetchPing(fetchImpl = fetch) {
14
14
  headers: { 'X-aws-proxy-auth': token, 'X-aws-proxy-port': '8080' },
15
15
  signal: AbortSignal.timeout(PING_TIMEOUT_MS),
16
16
  });
17
- // Release the connection an unread body pins an undici socket per
17
+ // Release the connection - an unread body pins an undici socket per
18
18
  // poll cycle until GC.
19
19
  await res.body?.cancel();
20
20
  }
21
21
  catch {
22
- /* expected the point is the wake-up, not the response */
22
+ /* expected - the point is the wake-up, not the response */
23
23
  }
24
24
  };
25
25
  }
26
- //# sourceMappingURL=fetch-ping.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Node adapter for the ModuleLoader port: the only file outside the
3
+ * composition root that imports `node:module`. Resolution goes through
4
+ * `createRequire` anchored on the caller's directory (Node's own CJS/ESM
5
+ * resolver, which understands `exports` maps, symlinked `node_modules`, and
6
+ * pnpm's workspace layout); loading goes through a dynamic `import()` over
7
+ * the resolved file's URL, so both CJS and ESM plugin packages work.
8
+ */
9
+ import type { ModuleLoader } from '../ports.js';
10
+ /** Build the real Node adapter for the ModuleLoader port. */
11
+ export declare function createNodeModuleLoader(): ModuleLoader;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Node adapter for the ModuleLoader port: the only file outside the
3
+ * composition root that imports `node:module`. Resolution goes through
4
+ * `createRequire` anchored on the caller's directory (Node's own CJS/ESM
5
+ * resolver, which understands `exports` maps, symlinked `node_modules`, and
6
+ * pnpm's workspace layout); loading goes through a dynamic `import()` over
7
+ * the resolved file's URL, so both CJS and ESM plugin packages work.
8
+ */
9
+ import { readFile } from 'node:fs/promises';
10
+ import { createRequire } from 'node:module';
11
+ import { dirname, join } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+ function isModuleNotFound(err) {
14
+ return err instanceof Error && err.code === 'MODULE_NOT_FOUND';
15
+ }
16
+ /** Wrap a resolver failure with the specifier and directory, preserving the cause. */
17
+ function resolutionFailure(operation, specifier, fromDir, err) {
18
+ return new Error(`failed to ${operation} "${specifier}" from ${fromDir}: ${err.message}`, { cause: err });
19
+ }
20
+ /**
21
+ * Resolve `specifier`'s entry-point file via `require.resolve`, anchored on
22
+ * `fromDir`. A missing module (`MODULE_NOT_FOUND`) is a normal "not
23
+ * installed" outcome and returns `{ found: false }`; anything else - a
24
+ * malformed specifier, an `exports` map that blocks the subpath, a
25
+ * filesystem error - is unexpected and raises with context.
26
+ */
27
+ function resolveEntryPoint(specifier, fromDir) {
28
+ const require = createRequire(join(fromDir, 'package.json'));
29
+ try {
30
+ return { found: true, path: require.resolve(specifier) };
31
+ }
32
+ catch (err) {
33
+ if (isModuleNotFound(err))
34
+ return { found: false };
35
+ throw resolutionFailure('resolve', specifier, fromDir, err);
36
+ }
37
+ }
38
+ /**
39
+ * True when `path` exists, parses as a JSON object, and carries a non-empty
40
+ * `name` - i.e. is genuinely a package manifest, not merely a `package.json`
41
+ * that happens to sit in the walk's path. A dual-package layout
42
+ * (`exports: {".": "./dist/index.js"}` plus a `dist/package.json` stub of
43
+ * `{"type": "module"}`) has exactly such a stub as the nearest file on disk:
44
+ * it carries no `name` and no `blogwright` field, so treating it as the
45
+ * manifest would make discovery conclude the package is not a plugin and
46
+ * skip it silently. Any read or parse failure - the file is absent, or is not
47
+ * valid JSON - answers `false` so the walk continues upward, rather than
48
+ * raising: a stray malformed `package.json` above the real one (unrelated to
49
+ * this resolution) must not abort the walk before it reaches the manifest
50
+ * that does carry a `name`.
51
+ */
52
+ async function isPackageManifest(path) {
53
+ let text;
54
+ try {
55
+ text = await readFile(path, 'utf8');
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(text);
62
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
63
+ return false;
64
+ const name = parsed.name;
65
+ return typeof name === 'string' && name.length > 0;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * Walk up from `entryFile` to the nearest `package.json` **that is a package
73
+ * manifest** - one carrying a `name` (see {@link isPackageManifest}). This -
74
+ * not `require.resolve('<specifier>/package.json')` - is how
75
+ * `packageJsonPathFor` finds a plugin's manifest.
76
+ *
77
+ * The direct subpath throws under Node's exports encapsulation whenever a
78
+ * package's `exports` map omits `"./package.json"`, which is true of every
79
+ * published package in this repo:
80
+ *
81
+ * ```
82
+ * require.resolve('blogwright-pds/package.json')
83
+ * -> ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './package.json' is not
84
+ * defined by "exports" in .../blogwright-pds/package.json
85
+ * ```
86
+ *
87
+ * (verified 2026-07-26 against this workspace; `blogwright-pds`'s `exports`
88
+ * map lists only `.` and `./rkey`). Walking up from the already-resolved
89
+ * entry file sidesteps `exports` entirely - it is a directory walk over the
90
+ * real filesystem, not a second module resolution - so it works against any
91
+ * package regardless of what its `exports` map declares. A map-backed test
92
+ * fake cannot reproduce the throw above (a fake has no `exports` map to
93
+ * enforce), which is why this behaviour is proven by an integration test
94
+ * against this real adapter (`node-module-loader.test.ts`) rather than a fake.
95
+ * Do not "simplify" this back to the direct subpath.
96
+ *
97
+ * The `name`-carrying qualifier was added 2026-08-29: without it, the walk
98
+ * stops at the *first* `package.json` it finds above the entry file, which
99
+ * for a dual-package layout is the `dist/package.json` stub, not the real
100
+ * manifest one directory further up. Skipping past a name-less manifest costs
101
+ * one extra read per level and closes that gap.
102
+ */
103
+ async function nearestPackageJson(entryFile) {
104
+ let dir = dirname(entryFile);
105
+ for (;;) {
106
+ const candidate = join(dir, 'package.json');
107
+ if (await isPackageManifest(candidate))
108
+ return { found: true, path: candidate };
109
+ const parent = dirname(dir);
110
+ if (parent === dir)
111
+ return { found: false };
112
+ dir = parent;
113
+ }
114
+ }
115
+ /** Build the real Node adapter for the ModuleLoader port. */
116
+ export function createNodeModuleLoader() {
117
+ return {
118
+ async resolve(specifier, fromDir) {
119
+ return resolveEntryPoint(specifier, fromDir);
120
+ },
121
+ async packageJsonPathFor(specifier, fromDir) {
122
+ const entry = resolveEntryPoint(specifier, fromDir);
123
+ if (!entry.found)
124
+ return entry;
125
+ const manifest = await nearestPackageJson(entry.path);
126
+ if (!manifest.found) {
127
+ // The walk reached the filesystem root with no package.json above a
128
+ // module Node itself just resolved - an environment inconsistency,
129
+ // not a "not installed" outcome, so it raises rather than reporting
130
+ // `{ found: false }`.
131
+ throw resolutionFailure('locate the package.json for', specifier, fromDir, new Error(`no package.json found above resolved entry point ${entry.path}`));
132
+ }
133
+ return manifest;
134
+ },
135
+ async load(path) {
136
+ try {
137
+ return (await import(pathToFileURL(path).href));
138
+ }
139
+ catch (err) {
140
+ throw new Error(`failed to load module at ${path}: ${err.message}`, {
141
+ cause: err,
142
+ });
143
+ }
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Process adapter for the PackageManager port: detects which package manager
3
+ * governs the repo from the lockfile it wrote (read through the injected
4
+ * FileSystem port, never node:fs), then shells out to it for add/remove. One
5
+ * of only two modules that may import node:child_process (the other is
6
+ * process-vcs.ts); failures are translated with the command, its arguments
7
+ * and the directory before they cross the port, mirroring runVcsCommand.
8
+ */
9
+ import { type FileSystem } from 'blogwright-core';
10
+ import type { AddPackageOptions, PackageManager, PackageManagerName } from '../ports.js';
11
+ /** A verbose install/uninstall can exceed the 1 MiB default buffer, same rationale as process-vcs.ts. */
12
+ export declare const MAX_OUTPUT_BYTES: number;
13
+ /** The shape of `execFile`, promisified - injectable so tests exercise command
14
+ * construction and error translation without spawning a process. */
15
+ type ExecFileAsync = (command: string, args: string[], options: {
16
+ cwd: string;
17
+ maxBuffer: number;
18
+ }) => Promise<{
19
+ stdout: string;
20
+ stderr: string;
21
+ }>;
22
+ /** Exported for tests, so the negative case's expected list is derived, not hand-copied. */
23
+ export declare const PACKAGE_MANAGER_LOCKFILES: Readonly<Record<PackageManagerName, string>>;
24
+ /** Exported for tests: the exact argument vector `add` shells out with, per manager. */
25
+ export declare function addArgs(manager: PackageManagerName, spec: string, opts: AddPackageOptions): string[];
26
+ /** Exported for tests: the exact argument vector `remove` shells out with, per manager. */
27
+ export declare function removeArgs(manager: PackageManagerName, name: string): string[];
28
+ /**
29
+ * Build the process package-manager adapter. `detect` probes `repoRoot` for
30
+ * each candidate lockfile through `fs`; `add`/`remove` resolve their own repo
31
+ * root (`findRepoRoot`, starting from `startDir` - `process.cwd()` in
32
+ * production) and manager, then shell out from that directory. `runProcess`
33
+ * and `startDir` are injectable so tests can exercise the whole add/remove
34
+ * pipeline - repo/manager resolution, command construction, and error
35
+ * translation - without spawning a process or depending on the real cwd.
36
+ */
37
+ export declare function createProcessPackageManager(fs: FileSystem, options?: {
38
+ runProcess?: ExecFileAsync;
39
+ startDir?: string;
40
+ }): PackageManager;
41
+ export {};
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Process adapter for the PackageManager port: detects which package manager
3
+ * governs the repo from the lockfile it wrote (read through the injected
4
+ * FileSystem port, never node:fs), then shells out to it for add/remove. One
5
+ * of only two modules that may import node:child_process (the other is
6
+ * process-vcs.ts); failures are translated with the command, its arguments
7
+ * and the directory before they cross the port, mirroring runVcsCommand.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+ import { join } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+ import { findRepoRoot } from 'blogwright-core';
13
+ const run = promisify(execFile);
14
+ /** A verbose install/uninstall can exceed the 1 MiB default buffer, same rationale as process-vcs.ts. */
15
+ export const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
16
+ /**
17
+ * The module-level table `detect` and command-building read from - a named
18
+ * constant rather than a chain of literals at each call site. The binary
19
+ * invoked for a manager is its own key (`pnpm`, `npm`, `yarn`, `bun`).
20
+ */
21
+ const PACKAGE_MANAGERS = {
22
+ pnpm: {
23
+ lockfile: 'pnpm-lock.yaml',
24
+ addVerb: 'add',
25
+ removeVerb: 'remove',
26
+ devFlag: '--save-dev',
27
+ exactFlag: '--save-exact',
28
+ },
29
+ npm: {
30
+ lockfile: 'package-lock.json',
31
+ addVerb: 'install',
32
+ removeVerb: 'uninstall',
33
+ devFlag: '--save-dev',
34
+ exactFlag: '--save-exact',
35
+ },
36
+ yarn: {
37
+ lockfile: 'yarn.lock',
38
+ addVerb: 'add',
39
+ removeVerb: 'remove',
40
+ devFlag: '--dev',
41
+ exactFlag: '--exact',
42
+ },
43
+ bun: {
44
+ lockfile: 'bun.lock',
45
+ addVerb: 'add',
46
+ removeVerb: 'remove',
47
+ devFlag: '--dev',
48
+ exactFlag: '--exact',
49
+ },
50
+ };
51
+ /** Exported for tests, so the negative case's expected list is derived, not hand-copied. */
52
+ export const PACKAGE_MANAGER_LOCKFILES = Object.fromEntries(Object.entries(PACKAGE_MANAGERS).map(([name, spec]) => [name, spec.lockfile]));
53
+ /** Probe each candidate lockfile in `repoRoot` through `fs`; first match wins. */
54
+ async function detectManager(fs, repoRoot) {
55
+ for (const [name, spec] of Object.entries(PACKAGE_MANAGERS)) {
56
+ if (await fs.exists(join(repoRoot, spec.lockfile)))
57
+ return name;
58
+ }
59
+ const lockfiles = Object.values(PACKAGE_MANAGERS)
60
+ .map((spec) => spec.lockfile)
61
+ .join(', ');
62
+ throw new Error(`no supported package manager detected in ${repoRoot} - looked for ${lockfiles}`);
63
+ }
64
+ /** Exported for tests: the exact argument vector `add` shells out with, per manager. */
65
+ export function addArgs(manager, spec, opts) {
66
+ const { addVerb, devFlag, exactFlag } = PACKAGE_MANAGERS[manager];
67
+ const args = [addVerb, spec];
68
+ if (opts.dev)
69
+ args.push(devFlag);
70
+ if (opts.exact)
71
+ args.push(exactFlag);
72
+ return args;
73
+ }
74
+ /** Exported for tests: the exact argument vector `remove` shells out with, per manager. */
75
+ export function removeArgs(manager, name) {
76
+ return [PACKAGE_MANAGERS[manager].removeVerb, name];
77
+ }
78
+ /**
79
+ * Build the process package-manager adapter. `detect` probes `repoRoot` for
80
+ * each candidate lockfile through `fs`; `add`/`remove` resolve their own repo
81
+ * root (`findRepoRoot`, starting from `startDir` - `process.cwd()` in
82
+ * production) and manager, then shell out from that directory. `runProcess`
83
+ * and `startDir` are injectable so tests can exercise the whole add/remove
84
+ * pipeline - repo/manager resolution, command construction, and error
85
+ * translation - without spawning a process or depending on the real cwd.
86
+ */
87
+ export function createProcessPackageManager(fs, options = {}) {
88
+ const runProcess = options.runProcess ?? run;
89
+ const startDir = options.startDir ?? process.cwd();
90
+ async function runPackageCommand(cwd, command, args) {
91
+ try {
92
+ await runProcess(command, args, { cwd, maxBuffer: MAX_OUTPUT_BYTES });
93
+ }
94
+ catch (err) {
95
+ throw new Error(`${command} ${args.join(' ')} failed in ${cwd}: ${err.message}`, {
96
+ cause: err,
97
+ });
98
+ }
99
+ }
100
+ async function resolveRepo() {
101
+ const repoRoot = await findRepoRoot(fs, startDir);
102
+ const manager = await detectManager(fs, repoRoot);
103
+ return { repoRoot, manager };
104
+ }
105
+ return {
106
+ detect: (repoRoot) => detectManager(fs, repoRoot),
107
+ async add(spec, opts = {}) {
108
+ const { repoRoot, manager } = await resolveRepo();
109
+ await runPackageCommand(repoRoot, manager, addArgs(manager, spec, opts));
110
+ },
111
+ async remove(name) {
112
+ const { repoRoot, manager } = await resolveRepo();
113
+ await runPackageCommand(repoRoot, manager, removeArgs(manager, name));
114
+ },
115
+ };
116
+ }
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Process adapter for the Vcs port: shells out to jj/git. The only module
3
- * outside the build-agent that may import node:child_process; failures are
4
- * translated with the command and directory before they cross the port.
2
+ * Process adapter for the Vcs port: shells out to jj/git. One of only two
3
+ * modules outside the build-agent that may import node:child_process (the
4
+ * other is process-package-manager.ts); failures are translated with the
5
+ * command and directory before they cross the port.
5
6
  */
6
7
  import type { Vcs } from '../ports.js';
7
8
  /**
8
9
  * Build the jj/git process adapter. The revision hash prefers jj's git commit
9
10
  * id (jj auto-commits the working copy), falling back to git HEAD; listings
10
- * honor .gitignore tracked files plus untracked files that are not ignored.
11
+ * honor .gitignore - tracked files plus untracked files that are not ignored.
11
12
  */
12
13
  export declare function createProcessVcs(): Vcs;