blogwright 0.3.3 → 0.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/agent/agent-manifest.json +1 -1
- package/agent/server.js +37 -19
- package/dist/adapters/fetch-ping.d.ts +1 -1
- package/dist/adapters/fetch-ping.js +3 -3
- package/dist/adapters/node-module-loader.d.ts +11 -0
- package/dist/adapters/node-module-loader.js +146 -0
- package/dist/adapters/process-package-manager.d.ts +41 -0
- package/dist/adapters/process-package-manager.js +116 -0
- package/dist/adapters/process-vcs.d.ts +5 -4
- package/dist/adapters/process-vcs.js +6 -5
- package/dist/agent-package.d.ts +1 -1
- package/dist/agent-package.js +4 -3
- package/dist/bin.js +13 -2
- package/dist/cli.d.ts +68 -1
- package/dist/cli.js +270 -88
- package/dist/commands.d.ts +70 -3
- package/dist/commands.js +177 -30
- package/dist/config-block.d.ts +34 -0
- package/dist/config-block.js +262 -0
- package/dist/context.d.ts +76 -6
- package/dist/context.js +98 -18
- package/dist/deploy.d.ts +2 -2
- package/dist/deploy.js +12 -12
- package/dist/graph.d.ts +27 -16
- package/dist/graph.js +1 -1
- package/dist/init.d.ts +37 -3
- package/dist/init.js +146 -22
- package/dist/known-commands.d.ts +63 -0
- package/dist/known-commands.js +78 -0
- package/dist/microvms.d.ts +2 -2
- package/dist/microvms.js +3 -3
- package/dist/nodes.d.ts +5 -3
- package/dist/nodes.js +97 -30
- package/dist/plugin-commands.d.ts +298 -0
- package/dist/plugin-commands.js +990 -0
- package/dist/plugins.d.ts +194 -0
- package/dist/plugins.js +523 -0
- package/dist/ports.d.ts +89 -1
- package/dist/render.d.ts +55 -0
- package/dist/render.js +89 -1
- package/dist/repo.d.ts +3 -3
- package/dist/repo.js +8 -8
- package/dist/seo.d.ts +1 -1
- package/dist/seo.js +1 -1
- package/package.json +6 -6
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
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}
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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`)
|
|
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
|
|
7820
|
-
* rather than mis-render an unknown payload
|
|
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
|
|
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(", ")}
|
|
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
|
|
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
|
|
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
|
|
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,12 +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
|
|
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
|
|
22
|
+
/* expected - the point is the wake-up, not the response */
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
}
|
|
@@ -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.
|
|
3
|
-
* outside the build-agent that may import node:child_process
|
|
4
|
-
*
|
|
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
|
|
11
|
+
* honor .gitignore - tracked files plus untracked files that are not ignored.
|
|
11
12
|
*/
|
|
12
13
|
export declare function createProcessVcs(): Vcs;
|