machine-bridge-mcp 0.11.1 → 0.12.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/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -5
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/AGENT_CONTEXT.md +1 -1
- package/docs/ARCHITECTURE.md +14 -8
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +5 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +12 -4
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +23 -8
- package/package.json +8 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/agent-context.mjs +19 -6
- package/src/local/app-automation.mjs +17 -9
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +165 -81
- package/src/local/daemon-process.mjs +3 -67
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +53 -25
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/shared/tool-catalog.json +10 -0
- package/src/worker/index.ts +1 -1
package/docs/PRIVACY.md
CHANGED
|
@@ -10,7 +10,7 @@ Run:
|
|
|
10
10
|
npm run privacy:check
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The check scans tracked and unignored new UTF-8 files and
|
|
13
|
+
The check scans tracked and unignored new UTF-8 files and relative names for generic/encrypted/algorithm-specific private-key headers, AWS/GitHub/GitLab/npm/Slack/Google/live-payment/API token forms, JWT-shaped bearer values, embedded-credential URLs, absolute user-home paths, non-example email/`user@host` identifiers, credential-shaped filenames, and locally configured private identifiers. A tracked `.npmrc` is parsed: non-secret repository settings such as `engine-strict=true` are allowed, while authentication/identity keys, environment interpolation, and embedded credentials fail closed. Publication-surface symbolic links are rejected rather than followed. Binary, invalid UTF-8, and files above the bounded scanner limit require explicit manual review instead of being silently skipped. Findings report only file, line, and rule; the matched value is never printed.
|
|
14
14
|
|
|
15
15
|
Maintain machine-specific names in an ignored owner-only file:
|
|
16
16
|
|
|
@@ -43,9 +43,11 @@ Before committing or publishing:
|
|
|
43
43
|
- use reserved example domains and neutral aliases;
|
|
44
44
|
- run `npm run privacy:check`, `npm run check`, and `npm pack --dry-run`;
|
|
45
45
|
- treat paths, host aliases, usernames, codenames, real browser URLs/page captures, application names tied to a user, and form data as private metadata even when they are not authentication secrets;
|
|
46
|
-
- keep browser pairing-state files and captured source/screenshots out of fixtures, documentation, support logs, and release assets
|
|
46
|
+
- keep browser pairing-state files and captured source/screenshots out of fixtures, documentation, support logs, and release assets;
|
|
47
|
+
- review any tracked `.npmrc` as configuration code and never commit authentication, registry identity, environment interpolation, cert/key paths, or credential-bearing URLs;
|
|
48
|
+
- inspect package modes and filenames as well as file contents—an empty `.env`, private-key filename, database, or log is still an inappropriate publication artifact.
|
|
47
49
|
|
|
48
|
-
The scanner is heuristic. It cannot identify every personal or organizational name, transformed value, image, archive, binary fixture, or data already present in Git history.
|
|
50
|
+
The scanner is heuristic. It cannot identify every personal or organizational name, split/transformed/encrypted value, image, archive, binary fixture, custom credential format, or data already present in Git history. Passing it is a gate, not proof that a publication contains no private data.
|
|
49
51
|
|
|
50
52
|
## Incident response
|
|
51
53
|
|
package/docs/RELEASING.md
CHANGED
|
@@ -22,7 +22,7 @@ The release invariant is:
|
|
|
22
22
|
|
|
23
23
|
2. Add the matching dated `CHANGELOG.md` section.
|
|
24
24
|
3. Run `npm run release-impact:check`, `npm run privacy:check`, `npm run check`, both dependency audits, `npm audit signatures`, and generate a CycloneDX `npm sbom`.
|
|
25
|
-
4. Inspect the complete diff and `npm pack --dry-run`, then commit and push all release changes to `main`.
|
|
25
|
+
4. Inspect the complete diff and `npm pack --dry-run`, including packaged file modes and every helper referenced by package scripts, then commit and push all release changes to `main`.
|
|
26
26
|
|
|
27
27
|
A privacy/security documentation correction is not “docs only” for release purposes. It requires a replacement npm version and, when appropriate, deprecation or unpublication of the affected version.
|
|
28
28
|
|
package/docs/TESTING.md
CHANGED
|
@@ -15,10 +15,12 @@ The suite includes:
|
|
|
15
15
|
- release-impact enforcement requiring a new package version and CHANGELOG section for release-relevant changes;
|
|
16
16
|
- release-state diagnostics distinguishing missing local/remote version tags from tags that point to the wrong commit, including the required publication order;
|
|
17
17
|
- generated Cloudflare Worker types and strict TypeScript checking, including unused-local and unused-parameter rejection;
|
|
18
|
-
- syntax validation for every shipped
|
|
18
|
+
- recursive syntax validation for every JavaScript file under the shipped/runtime/test roots plus the shell wrapper;
|
|
19
19
|
- shared tool-catalog schema, annotation, and profile-inventory checks;
|
|
20
20
|
- default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, live project/skill rescanning and fingerprints, automatic task ranking/loading, command override/removal, direct argv handling, timeout ceilings, and execution-profile denial;
|
|
21
|
-
-
|
|
21
|
+
- concurrent complete-before-visible lock claims, atomic replacement under active readers, malformed-lock grace, snapshot/token-safe reclamation, absolute-age expiry, PID-reuse detection, and bounded startup-lock waiting;
|
|
22
|
+
- foreground takeover of active and orphaned background daemons, legacy lock identification from canonicalized process arguments, foreground-process protection, actual-PID exit waiting, non-forcing timeout guidance, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts;
|
|
23
|
+
- fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results;
|
|
22
24
|
- machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, and broker result redaction;
|
|
23
25
|
- canonical path and symbolic-link escape tests;
|
|
24
26
|
- relative-path privacy and error-path redaction tests;
|
|
@@ -29,16 +31,16 @@ The suite includes:
|
|
|
29
31
|
- nested Git repository detection and helper suppression;
|
|
30
32
|
- author-email privacy in `git_log`;
|
|
31
33
|
- isolated command HOME/temp/cache behavior;
|
|
32
|
-
- one-shot timeout, descendant termination, cancellation, and process-session interaction;
|
|
34
|
+
- one-shot timeout, descendant process-group/tree termination including descendants that ignore graceful shutdown, cancellation, and process-session interaction;
|
|
33
35
|
- layered fixed runtime diagnostics for filesystem, direct process, shell, managed-job storage, and resource availability;
|
|
34
36
|
- local resource CLI registration, permission checks, dynamic reload, state-path redaction, and content non-disclosure;
|
|
35
37
|
- real Ed25519 and RSA generation, idempotent reuse, public/private correspondence, mode enforcement, incomplete/mismatched/symlink rejection, and private-content non-disclosure;
|
|
36
38
|
- real-machine canonical-full sandbox acceptance for outside-workspace I/O, direct/shell execution, full environment inheritance, SSH prerequisites, temporary authorized-key writing, and detached cleanup without external state changes;
|
|
37
39
|
- deterministic injected atomic-replace failures and repeated Windows full-sandbox runs to catch transient file-sharing races;
|
|
38
40
|
- canonical named-profile repair and full-only tool exposure parity between local and Worker policy filters;
|
|
39
|
-
- managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution,
|
|
40
|
-
- daemon/startup locking and
|
|
41
|
-
- guarded state-root removal, schema migration, policy-origin persistence, and legacy implicit-default migration;
|
|
41
|
+
- managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, plan scrubbing, PID-reuse-safe dead-runner recovery, and legacy PID-file compatibility;
|
|
42
|
+
- daemon/startup locking, successfully-read corrupt-JSON recovery, and explicit propagation/preservation of oversized, symbolic-link, permission, and I/O failures;
|
|
43
|
+
- guarded state-root removal, unsafe state-root/workspace overlap rejection before creation, all-profile lock/daemon scanning, schema migration, policy-origin persistence, and legacy implicit-default migration;
|
|
42
44
|
- no filename-based sensitive-file denial under unrestricted policy;
|
|
43
45
|
- log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug, service warning-level configuration, and idempotent bounded migration of legacy log formats;
|
|
44
46
|
- deterministic relay connection lifecycle coverage for transport construction/error/deadline, pre-handshake `welcome` validation, authenticated `hello_ack` readiness, identity/version mismatch, retryable Worker handshake errors, fatal protocol errors, autonomous outage-reminder backoff, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
|
|
@@ -47,6 +49,18 @@ The suite includes:
|
|
|
47
49
|
- live stdio MCP initialization with session instructions, capability resolution, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
|
|
48
50
|
- live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure.
|
|
49
51
|
|
|
52
|
+
## Opt-in live desktop and browser validation
|
|
53
|
+
|
|
54
|
+
The normal suite uses deterministic mocks for macOS Accessibility and an authenticated in-process extension peer for browser routing. Before a release that changes local UI automation, run the macOS live smoke test on a machine where the invoking Node/terminal process has Accessibility permission:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm run app-automation:live-test
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It opens Calculator, activates it through the fixed JXA helper, verifies structured JSON output, inspects main-window Accessibility controls with menu recursion disabled, clicks the `One` control, and quits the application. It is intentionally excluded from CI because macOS TCC permission and a graphical login session are host state.
|
|
61
|
+
|
|
62
|
+
For browser changes, perform an isolated-profile smoke test with the packaged unpacked extension: pair it to a temporary source runtime, navigate a local no-store page, inspect controls, fill multiple fields, click a button, verify the live DOM, and capture a screenshot. A Playwright persistent Chromium context is an acceptable harness because it loads the real MV3 service worker while keeping the user's daily browser profile untouched.
|
|
63
|
+
|
|
50
64
|
## Additional release checks
|
|
51
65
|
|
|
52
66
|
```sh
|
|
@@ -65,6 +79,7 @@ GitHub Actions executes the main suite on Linux, macOS, and Windows using the pi
|
|
|
65
79
|
## Test design rules
|
|
66
80
|
|
|
67
81
|
- Tests should exercise the public boundary rather than only helper functions when practical.
|
|
82
|
+
- Lock, service lifecycle, state deletion, detached process, and credential changes require behavior-level concurrent or fault-injection coverage; source-string assertions are supplementary only.
|
|
68
83
|
- Every permission-expanding feature needs a denial test. Browser/application features also require a token/value/content non-disclosure assertion.
|
|
69
84
|
- Every bounded resource needs an over-limit test.
|
|
70
85
|
- Every multi-stage mutation needs a no-partial-commit test.
|
|
@@ -80,10 +95,10 @@ Run `npm run privacy:check` before committing and before packaging. Developers s
|
|
|
80
95
|
|
|
81
96
|
## Package manifest
|
|
82
97
|
|
|
83
|
-
`npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, and verifies that privacy/engineering guidance,
|
|
98
|
+
`npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, validates every packaged mode as `0644` or `0755`, and verifies that privacy/engineering guidance, runtime/relay/secure-file/lock/service/browser/app modules, all package-script helpers, the packaged browser extension, contribution discipline, and privacy/release-impact checkers are present. `npm run install:test` packs the real tarball, installs it into an isolated global prefix with the documented npm 12 options, rejects blocked-script warnings, confirms optional `fsevents` is absent, and runs the installed CLI.
|
|
84
99
|
|
|
85
100
|
The stdio integration test also sends an oversized line, verifies bounded rejection, and confirms that the next valid request is still processed.
|
|
86
101
|
|
|
87
102
|
## Architecture and documentation regression checks
|
|
88
103
|
|
|
89
|
-
`npm run architecture:test` rejects local-module dependency cycles, missing relative imports, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
|
|
104
|
+
`npm run architecture:test` rejects local-module dependency cycles, missing static or dynamic relative imports, package scripts that reference missing files, drift from the recursive syntax scanner, incomplete executable package directories, inconsistent installation guidance, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,10 +20,7 @@
|
|
|
20
20
|
"src/local",
|
|
21
21
|
"src/shared",
|
|
22
22
|
"src/worker/index.ts",
|
|
23
|
-
"scripts
|
|
24
|
-
"scripts/privacy-check.mjs",
|
|
25
|
-
"scripts/release-impact-check.mjs",
|
|
26
|
-
"scripts/network-retry.mjs",
|
|
23
|
+
"scripts",
|
|
27
24
|
"wrangler.jsonc",
|
|
28
25
|
"tsconfig.json",
|
|
29
26
|
"README.md",
|
|
@@ -44,8 +41,8 @@
|
|
|
44
41
|
"prepublishOnly": "npm run check && npm run version:check && npm run release:check",
|
|
45
42
|
"worker:types": "wrangler types src/worker/worker-configuration.d.ts",
|
|
46
43
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
|
|
47
|
-
"syntax": "
|
|
48
|
-
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
44
|
+
"syntax": "node scripts/syntax-check.mjs",
|
|
45
|
+
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
49
46
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
50
47
|
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
|
51
48
|
"release:check": "node scripts/github-release.mjs --check",
|
|
@@ -70,7 +67,10 @@
|
|
|
70
67
|
"release-state:test": "node tests/release-state-test.mjs",
|
|
71
68
|
"agent-context:test": "node tests/agent-context-test.mjs",
|
|
72
69
|
"browser-bridge:test": "node tests/browser-bridge-test.mjs",
|
|
73
|
-
"app-automation:test": "node tests/app-automation-test.mjs"
|
|
70
|
+
"app-automation:test": "node tests/app-automation-test.mjs",
|
|
71
|
+
"process-lock:test": "node tests/process-lock-test.mjs",
|
|
72
|
+
"service-lifecycle:test": "node tests/service-lifecycle-test.mjs",
|
|
73
|
+
"app-automation:live-test": "node tests/app-automation-test.mjs --live-macos"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"wrangler": "4.110.0",
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { runNetworkCommand } from "./network-retry.mjs";
|
|
13
|
+
import { tagSyncError } from "./release-state.mjs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
process.chdir(root);
|
|
18
|
+
|
|
19
|
+
function fail(message) {
|
|
20
|
+
console.error(`release error: ${message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run(command, args, options = {}) {
|
|
25
|
+
const result = spawnSync(command, args, {
|
|
26
|
+
cwd: root,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
stdio: options.capture ? "pipe" : "inherit",
|
|
29
|
+
env: process.env,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (result.error) {
|
|
33
|
+
fail(`${command} could not be started: ${result.error.message}`);
|
|
34
|
+
}
|
|
35
|
+
if (result.status !== 0 && !options.allowFailure) {
|
|
36
|
+
const detail = options.capture
|
|
37
|
+
? `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
38
|
+
: "";
|
|
39
|
+
fail(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
function runNetwork(command, args, options = {}) {
|
|
46
|
+
const result = runNetworkCommand(command, args, { cwd: root, env: process.env });
|
|
47
|
+
if (!options.capture) {
|
|
48
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
49
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
50
|
+
}
|
|
51
|
+
if (result.error && !options.allowFailure) {
|
|
52
|
+
fail(`${command} could not be started: ${result.error.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (result.status !== 0 && !options.allowFailure) failCommandResult(command, args, result);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function outputNetwork(command, args, options = {}) {
|
|
59
|
+
const result = runNetwork(command, args, { ...options, capture: true });
|
|
60
|
+
return (result.stdout ?? "").trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function failCommandResult(command, args, result) {
|
|
64
|
+
const detail = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
65
|
+
fail(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function output(command, args, options = {}) {
|
|
69
|
+
const result = run(command, args, { ...options, capture: true });
|
|
70
|
+
return (result.stdout ?? "").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function packageMetadata() {
|
|
74
|
+
const data = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
75
|
+
if (typeof data.name !== "string" || typeof data.version !== "string") {
|
|
76
|
+
fail("package.json must contain string name and version fields");
|
|
77
|
+
}
|
|
78
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(data.version)) {
|
|
79
|
+
fail(`unsupported package version: ${data.version}`);
|
|
80
|
+
}
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function changelogBody(version) {
|
|
85
|
+
const changelog = readFileSync(join(root, "CHANGELOG.md"), "utf8");
|
|
86
|
+
const heading = new RegExp(`^## ${version.replaceAll(".", "\\.")}(?:\\s+-[^\\n]*)?$`, "m");
|
|
87
|
+
const match = heading.exec(changelog);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const start = match.index + match[0].length;
|
|
90
|
+
const next = changelog.slice(start).search(/^## /m);
|
|
91
|
+
const body = changelog.slice(start, next < 0 ? undefined : start + next).trim();
|
|
92
|
+
return body || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ensureClean() {
|
|
96
|
+
const status = output("git", ["status", "--porcelain"]);
|
|
97
|
+
if (status) fail(`working tree is not clean:\n${status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fetchRemote() {
|
|
101
|
+
runNetwork("git", ["fetch", "origin", "main", "--tags", "--prune"]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function localTagCommit(tag) {
|
|
105
|
+
const result = run("git", ["rev-list", "-n", "1", tag], {
|
|
106
|
+
capture: true,
|
|
107
|
+
allowFailure: true,
|
|
108
|
+
});
|
|
109
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function remoteTagCommit(tag) {
|
|
113
|
+
const text = outputNetwork("git", [
|
|
114
|
+
"ls-remote",
|
|
115
|
+
"--tags",
|
|
116
|
+
"origin",
|
|
117
|
+
`refs/tags/${tag}`,
|
|
118
|
+
`refs/tags/${tag}^{}`,
|
|
119
|
+
]);
|
|
120
|
+
if (!text) return null;
|
|
121
|
+
const rows = text.split("\n").map((line) => line.trim().split(/\s+/));
|
|
122
|
+
const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`);
|
|
123
|
+
const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`);
|
|
124
|
+
return (peeled ?? direct)?.[0] ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function releaseInfo(tag) {
|
|
128
|
+
const result = runNetwork(
|
|
129
|
+
"gh",
|
|
130
|
+
[
|
|
131
|
+
"release",
|
|
132
|
+
"view",
|
|
133
|
+
tag,
|
|
134
|
+
"--json",
|
|
135
|
+
"tagName,name,targetCommitish,isDraft,isPrerelease,assets,url",
|
|
136
|
+
],
|
|
137
|
+
{ capture: true, allowFailure: true },
|
|
138
|
+
);
|
|
139
|
+
if (result.status !== 0) return null;
|
|
140
|
+
return JSON.parse(result.stdout);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertCoreSync({ requireReleaseAsset }) {
|
|
144
|
+
const pkg = packageMetadata();
|
|
145
|
+
const tag = `v${pkg.version}`;
|
|
146
|
+
const head = output("git", ["rev-parse", "HEAD"]);
|
|
147
|
+
const originMain = output("git", ["rev-parse", "origin/main"]);
|
|
148
|
+
if (head !== originMain) {
|
|
149
|
+
fail(`HEAD ${head} does not match origin/main ${originMain}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const localCommit = localTagCommit(tag);
|
|
153
|
+
const localTagError = tagSyncError({ scope: "local", tag, head, commit: localCommit });
|
|
154
|
+
if (localTagError) fail(localTagError);
|
|
155
|
+
|
|
156
|
+
const remoteCommit = remoteTagCommit(tag);
|
|
157
|
+
const remoteTagError = tagSyncError({ scope: "remote", tag, head, commit: remoteCommit });
|
|
158
|
+
if (remoteTagError) fail(remoteTagError);
|
|
159
|
+
|
|
160
|
+
const release = releaseInfo(tag);
|
|
161
|
+
if (!release || release.isDraft || release.isPrerelease) {
|
|
162
|
+
fail(`published GitHub Release ${tag} is missing or not final`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (requireReleaseAsset) {
|
|
166
|
+
const expectedAsset = `${pkg.name}-${pkg.version}.tgz`;
|
|
167
|
+
const names = new Set((release.assets ?? []).map((asset) => asset.name));
|
|
168
|
+
if (!names.has(expectedAsset)) {
|
|
169
|
+
fail(`GitHub Release ${tag} is missing ${expectedAsset}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(`GitHub source, tag, release, and package asset are in sync: ${tag}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeNotesFile(directory, version) {
|
|
177
|
+
const body = changelogBody(version);
|
|
178
|
+
if (!body) return null;
|
|
179
|
+
const path = join(directory, `release-${version}.md`);
|
|
180
|
+
writeFileSync(path, body.endsWith("\n") ? body : `${body}\n`, "utf8");
|
|
181
|
+
return path;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function packReleaseAsset(directory, pkg) {
|
|
185
|
+
const result = run(
|
|
186
|
+
"npm",
|
|
187
|
+
["pack", "--silent", "--json", "--pack-destination", directory],
|
|
188
|
+
{ capture: true },
|
|
189
|
+
);
|
|
190
|
+
let records;
|
|
191
|
+
try {
|
|
192
|
+
records = JSON.parse(result.stdout);
|
|
193
|
+
} catch {
|
|
194
|
+
fail("npm pack did not return valid JSON");
|
|
195
|
+
}
|
|
196
|
+
const record = normalizePackRecord(records, pkg.name);
|
|
197
|
+
const filename = record?.filename;
|
|
198
|
+
if (typeof filename !== "string") fail("npm pack did not report a filename");
|
|
199
|
+
const path = join(directory, filename);
|
|
200
|
+
const expected = `${pkg.name.replaceAll("/", "-").replace(/^@/, "")}-${pkg.version}.tgz`;
|
|
201
|
+
if (filename !== expected) {
|
|
202
|
+
fail(`unexpected npm package filename ${filename}; expected ${expected}`);
|
|
203
|
+
}
|
|
204
|
+
return path;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizePackRecord(value, packageName) {
|
|
208
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
209
|
+
if (!value || typeof value !== "object") return null;
|
|
210
|
+
if (value[packageName] && typeof value[packageName] === "object") return value[packageName];
|
|
211
|
+
return Object.values(value).find((item) => item && typeof item === "object") ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ensureRelease(tag, version, assetPath, latest) {
|
|
215
|
+
const temp = dirname(assetPath);
|
|
216
|
+
const notes = writeNotesFile(temp, version);
|
|
217
|
+
const existing = releaseInfo(tag);
|
|
218
|
+
const title = `machine-bridge-mcp ${tag}`;
|
|
219
|
+
|
|
220
|
+
if (!existing) {
|
|
221
|
+
const args = [
|
|
222
|
+
"release",
|
|
223
|
+
"create",
|
|
224
|
+
tag,
|
|
225
|
+
assetPath,
|
|
226
|
+
"--verify-tag",
|
|
227
|
+
"--title",
|
|
228
|
+
title,
|
|
229
|
+
latest ? "--latest" : "--latest=false",
|
|
230
|
+
];
|
|
231
|
+
if (notes) args.push("--notes-file", notes);
|
|
232
|
+
else args.push("--generate-notes");
|
|
233
|
+
const created = runNetwork("gh", args, { allowFailure: true });
|
|
234
|
+
if (created.status !== 0 && !releaseInfo(tag)) failCommandResult("gh", args, created);
|
|
235
|
+
} else {
|
|
236
|
+
runNetwork("gh", [
|
|
237
|
+
"release",
|
|
238
|
+
"edit",
|
|
239
|
+
tag,
|
|
240
|
+
"--title",
|
|
241
|
+
title,
|
|
242
|
+
latest ? "--latest" : "--latest=false",
|
|
243
|
+
...(notes ? ["--notes-file", notes] : []),
|
|
244
|
+
]);
|
|
245
|
+
runNetwork("gh", ["release", "upload", tag, assetPath, "--clobber"]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function publishCurrent() {
|
|
250
|
+
ensureClean();
|
|
251
|
+
fetchRemote();
|
|
252
|
+
|
|
253
|
+
const branch = output("git", ["branch", "--show-current"]);
|
|
254
|
+
if (branch !== "main") fail(`release must run from main, not ${branch || "detached HEAD"}`);
|
|
255
|
+
|
|
256
|
+
const pkg = packageMetadata();
|
|
257
|
+
const tag = `v${pkg.version}`;
|
|
258
|
+
if (!changelogBody(pkg.version)) {
|
|
259
|
+
fail(`CHANGELOG.md has no section for ${pkg.version}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
run("npm", ["run", "check"]);
|
|
263
|
+
run("npm", ["run", "version:check"]);
|
|
264
|
+
ensureClean();
|
|
265
|
+
|
|
266
|
+
const head = output("git", ["rev-parse", "HEAD"]);
|
|
267
|
+
const originMain = output("git", ["rev-parse", "origin/main"]);
|
|
268
|
+
if (head !== originMain) {
|
|
269
|
+
const ancestor = run("git", ["merge-base", "--is-ancestor", "origin/main", "HEAD"], {
|
|
270
|
+
capture: true,
|
|
271
|
+
allowFailure: true,
|
|
272
|
+
});
|
|
273
|
+
if (ancestor.status !== 0) {
|
|
274
|
+
fail("origin/main is not an ancestor of HEAD; refusing a non-fast-forward release");
|
|
275
|
+
}
|
|
276
|
+
runNetwork("git", ["push", "origin", "HEAD:main"]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const existingLocal = localTagCommit(tag);
|
|
280
|
+
if (existingLocal && existingLocal !== head) {
|
|
281
|
+
fail(`local ${tag} points to ${existingLocal}, not ${head}`);
|
|
282
|
+
}
|
|
283
|
+
if (!existingLocal) {
|
|
284
|
+
run("git", ["tag", "-a", tag, "-m", `Release ${pkg.version}`]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const existingRemote = remoteTagCommit(tag);
|
|
288
|
+
if (existingRemote && existingRemote !== head) {
|
|
289
|
+
fail(`remote ${tag} points to ${existingRemote}, not ${head}`);
|
|
290
|
+
}
|
|
291
|
+
if (!existingRemote) {
|
|
292
|
+
runNetwork("git", ["push", "origin", tag]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const temp = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-release-"));
|
|
296
|
+
try {
|
|
297
|
+
const assetPath = packReleaseAsset(temp, pkg);
|
|
298
|
+
ensureRelease(tag, pkg.version, assetPath, true);
|
|
299
|
+
} finally {
|
|
300
|
+
rmSync(temp, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fetchRemote();
|
|
304
|
+
assertCoreSync({ requireReleaseAsset: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function backfillMissingReleases() {
|
|
308
|
+
ensureClean();
|
|
309
|
+
fetchRemote();
|
|
310
|
+
|
|
311
|
+
const tags = output("git", ["tag", "--sort=version:refname"])
|
|
312
|
+
.split("\n")
|
|
313
|
+
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
|
314
|
+
const releases = JSON.parse(outputNetwork("gh", [
|
|
315
|
+
"release",
|
|
316
|
+
"list",
|
|
317
|
+
"--limit",
|
|
318
|
+
"1000",
|
|
319
|
+
"--json",
|
|
320
|
+
"tagName",
|
|
321
|
+
]));
|
|
322
|
+
const existing = new Set(releases.map((release) => release.tagName));
|
|
323
|
+
const missing = tags.filter((tag) => !existing.has(tag));
|
|
324
|
+
|
|
325
|
+
if (missing.length === 0) {
|
|
326
|
+
console.log("No GitHub Releases are missing.");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const temp = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-backfill-"));
|
|
331
|
+
try {
|
|
332
|
+
for (const tag of missing) {
|
|
333
|
+
if (!remoteTagCommit(tag)) fail(`remote tag ${tag} is missing`);
|
|
334
|
+
const version = tag.slice(1);
|
|
335
|
+
const notes = writeNotesFile(temp, version);
|
|
336
|
+
const args = [
|
|
337
|
+
"release",
|
|
338
|
+
"create",
|
|
339
|
+
tag,
|
|
340
|
+
"--verify-tag",
|
|
341
|
+
"--title",
|
|
342
|
+
`machine-bridge-mcp ${tag}`,
|
|
343
|
+
"--latest=false",
|
|
344
|
+
];
|
|
345
|
+
if (notes) args.push("--notes-file", notes);
|
|
346
|
+
else args.push("--generate-notes");
|
|
347
|
+
const created = runNetwork("gh", args, { allowFailure: true });
|
|
348
|
+
if (created.status !== 0 && !releaseInfo(tag)) failCommandResult("gh", args, created);
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
rmSync(temp, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log(`Backfilled ${missing.length} GitHub Release(s): ${missing.join(", ")}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const mode = process.argv[2] ?? "--check";
|
|
358
|
+
if (mode === "--check") {
|
|
359
|
+
ensureClean();
|
|
360
|
+
fetchRemote();
|
|
361
|
+
assertCoreSync({ requireReleaseAsset: true });
|
|
362
|
+
} else if (mode === "--publish") {
|
|
363
|
+
publishCurrent();
|
|
364
|
+
} else if (mode === "--backfill") {
|
|
365
|
+
backfillMissingReleases();
|
|
366
|
+
} else {
|
|
367
|
+
fail("usage: node scripts/github-release.mjs [--check|--publish|--backfill]");
|
|
368
|
+
}
|