mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
7e4ece697d
Adds the two files the official MCP registry
(`registry.modelcontextprotocol.io`) needs in order to list argent.
Argent is not in that registry today
(`?search=io.github.software-mansion` returns 0 servers).
The registry is metadata-only — it points at the npm package rather than
hosting anything.
### `server.json`
Describes the server and how a client starts it: `npx @swmansion/argent
mcp`. The `mcp` positional is the dispatcher case in
`packages/argent/src/cli.ts:137`, and it is the same subcommand `argent
init` writes into every client config —
`packages/argent-installer/src/mcp-configs.ts:130-141` spells it four
ways (`node <path> mcp`, `yarn argent mcp`, `npx --no-install argent
mcp`, and the global `{ command: MCP_BINARY_NAME, args: ["mcp"] }`,
where `MCP_BINARY_NAME` is `"argent"`). None of them is literally the
npx-by-package-name form in `server.json`, which is the form the
registry expects a client to construct from `identifier` + `runtimeHint`
— so it is verified below by running it, not by matching a string.
`repository.id` is the GitHub repo id (`1164885639`); the schema
recommends it so a registry can detect a delete-and-recreate of the
repo.
### `mcpName` in `packages/argent/package.json`
The ownership proof. The registry fetches the npm package and requires
`mcpName` to equal the server name in `server.json` before it will
accept a publish under `io.github.software-mansion`. It is the field the
registry's own quickstart names for npm packages. Nothing in the publish
path drops it: `scripts/pack-mcp.cjs` shells out to `npm pack` without
rewriting the manifest, and the canary path's
`scripts/next-canary-version.mjs --write` round-trips the JSON and only
sets `version`.
### Namespace
`io.github.software-mansion/argent` — GitHub OAuth auth. The registry
grants an org namespace only to org **Owners**; plain membership is no
longer enough. If no Owner is available to run the publish, the
alternative is DNS auth via an apex TXT record on `swmansion.com`, which
changes the name to `com.swmansion/argent` (and the `mcpName` value with
it).
### Version sync
`server.json` sits at the repo root, outside `packages/*`, so a release
bump would have left its two `0.18.0`s untouched — and the registry
rejects a publish whose `packages[].version` names a version npm has
never seen. `scripts/check-workspace-versions.mjs` (already run by the
Static checks job) also holds `server.json` to the version in
`packages/argent/package.json` — the package it names — and pins the two
cross-file links a publish depends on: `name` == `mcpName`, `identifier`
== the published package name. Both halves are reported in one run, so a
half-finished bump doesn't cost a CI round-trip per file.
### Not in this PR
- **Publishing.** Nothing has been published; this is just the manifest.
Publishing is `mcp-publisher login github && mcp-publisher publish`, and
it can only succeed **after** a release whose npm tarball carries
`mcpName` — i.e. not against `0.18.0`, which is `latest` (published
2026-07-30) and predates `mcpName` — `curl
registry.npmjs.org/@swmansion%2Fargent/0.18.0 | jq .mcpName` → `null`.
The first publishable release is the next bump.
- **`github.com/mcp`.** A separate, manual step. Onboarding a new server
there is hand-curated: per
[github/github-mcp-server#1257](https://github.com/github/github-mcp-server/discussions/1257)
(trent-j, a collaborator on that repo, 19 May 2026) "onboarding a new
server is still a manual curation process today. Once a server has been
onboarded, newly published versions from the OSS registry should sync
from there." The nomination channel is `partnerships@github.com`, from
GitHub's [registry how-to
post](https://github.blog/ai-and-ml/generative-ai/how-to-find-install-and-manage-mcp-servers-with-the-github-mcp-registry/)
(24 Oct 2025) — that address does not appear anywhere in the discussion
thread.
### Verification
- `mcp-publisher validate` (v1.8.0, which posts the file to the live
registry) → `server.json is valid`. Mutation-checked what that actually
covers: removing `transport` fails it and removing `$schema` is a 422,
but a foreign namespace (`io.github.anthropics/argent`), a nonexistent
npm identifier and a `99.0.0` package version all still pass — so it
checks shape only, and the ownership/existence checks happen at publish
time. Also validated offline against the pinned `2025-12-11` schema.
- Ran the entrypoint the file declares. `npx -y @swmansion/argent@latest
mcp` fed an `initialize` over stdio answered
`{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"argent","version":"0.18.0"},"instructions":"…"}`,
with stderr empty.
- Mutation-checked the new version guard: drifting `server.json`
`version`, drifting `packages[0].version`, deleting `mcpName`, and
typo-ing `identifier` each fail `check-workspace-versions.mjs` with the
specific line; the pre-existing packages/* drift check still fires on
its own.
- Asserted across the two files: `mcpName` == `server.json` `name`,
`identifier` == package `name`, all three versions equal, description 89
chars (registry limit is 100).
- `prettier --check` and `eslint` clean on both changed files.
- Mutation-checked the guard as a whole — 22 mutants, 1 survivor.
Killed: reverting the self-invocation guard to a raw `argv[1]` compare
and making it never match; `exit(1)` → `exit(0)` on the failure path;
dropping either `failed = true`; exiting early on `packages/*` drift
instead of collecting; `packages.slice(0, 1)`; hardcoding `packages[0]`
in either message; dropping the no-packages-array guard, the
non-object-entry guard, the non-object-JSON guard, and the presence
checks for `version`, `name` and `mcpName`; `continue` → `break` in the
`packages/*` scan (both arms) and in the entry loop; dropping the
on-message read, the `git checkout` hint, and the per-version name
accumulator. The survivor is the `packages/*` readdir sort: it fixes the
scan order rather than any output, so no single run can observe it — it
is there so a scan that stops early drops the same packages on every
machine instead of a filesystem-dependent set.
- Reproduced the symlink no-op on the real repo: with
`packages/registry` at `9.9.9` and `server.json` at `0.17.0`, `node
/tmp/argent-alias/scripts/check-workspace-versions.mjs` now exits 1 and
prints both problems, where before it exited 0 with no output at all.
- Closed the vacuous-pass class in `serverJsonMismatches`: all four
comparisons it makes were bare `!==`, so a field absent from *both*
sides compared equal and passed. A `packages/argent/package.json` with
no `version` printed `All workspace packages and server.json are at
undefined.` and exited 0; with no `name`, a `server.json` naming nothing
installable was reported in sync. `version` and `name` now require
presence, as `mcpName` already did.
- `readTrackedJson` guarantees an object, not just parseable bytes — a
`server.json` holding `null` used to reach the field reads and throw,
and `"0.18.0"` or `[]` produced mismatch lines that never said the file
was the wrong shape.
- Full CI parity locally: `test:scripts` 85/85, `tsc -p
tsconfig.scripts.json`, `eslint --max-warnings 0`, `prettier --check`.
445 lines
18 KiB
JavaScript
445 lines
18 KiB
JavaScript
/**
|
|
* Pins the cross-file links a registry publish depends on: server.json's version
|
|
* and the npm coordinates it names have to match the workspace, and the ownership
|
|
* proof (mcpName on the published tarball) has to be present on both sides.
|
|
*
|
|
* Two layers, because the pure function is not what CI runs: unit tests over
|
|
* serverJsonMismatches(), then spawned runs of the real script against a
|
|
* miniature repo, which are the only thing that pins main() — its exit codes,
|
|
* its output and the guard deciding whether it runs at all.
|
|
*
|
|
* Run: node --test scripts/check-workspace-versions.test.mjs
|
|
*/
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
copyFileSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { serverJsonMismatches } from "./check-workspace-versions.mjs";
|
|
|
|
const VERSION = "0.18.0";
|
|
const ARGENT_PKG = { name: "@swmansion/argent", mcpName: "io.github.software-mansion/argent" };
|
|
const SERVER = {
|
|
name: "io.github.software-mansion/argent",
|
|
version: VERSION,
|
|
packages: [{ identifier: "@swmansion/argent", version: VERSION }],
|
|
};
|
|
|
|
/** @param {(s: typeof SERVER) => void} mutate */
|
|
function server(mutate) {
|
|
const copy = structuredClone(SERVER);
|
|
mutate(copy);
|
|
return copy;
|
|
}
|
|
|
|
test("an in-sync pair reports nothing", () => {
|
|
assert.deepEqual(serverJsonMismatches(VERSION, ARGENT_PKG, SERVER), []);
|
|
});
|
|
|
|
test("server.json version drifting from the workspace names both versions", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => (s.version = "0.17.0"))
|
|
);
|
|
assert.deepEqual(problems, [
|
|
"server.json version is 0.17.0, packages/argent/package.json is 0.18.0",
|
|
]);
|
|
});
|
|
|
|
test("a packages[] version npm has never seen is caught", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => (s.packages[0].version = "9.9.9"))
|
|
);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /packages\[0\]\.version is 9\.9\.9/);
|
|
});
|
|
|
|
// Only packages[0] exists today, so nothing else here would notice a check that
|
|
// silently stopped after the first entry — which becomes live the moment a
|
|
// second distribution channel is added.
|
|
test("every packages[] entry is validated, not just the first", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => s.packages.push({ identifier: "@attacker/pkg", version: "6.6.6" }))
|
|
);
|
|
assert.deepEqual(problems, [
|
|
"server.json packages[1].version is 6.6.6, packages/argent/package.json is 0.18.0",
|
|
"server.json packages[1].identifier (@attacker/pkg) != published package name (@swmansion/argent)",
|
|
]);
|
|
});
|
|
|
|
test("a packages[] identifier that is not the published package is caught", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => (s.packages[0].identifier = "@swmansion/argnet"))
|
|
);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /packages\[0\]\.identifier \(@swmansion\/argnet\)/);
|
|
});
|
|
|
|
test("a name that no longer matches mcpName is caught", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => (s.name = "com.swmansion/argent"))
|
|
);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /server\.json name \(com\.swmansion\/argent\)/);
|
|
});
|
|
|
|
test("dropping mcpName is caught even though server.json still names it", () => {
|
|
const problems = serverJsonMismatches(VERSION, { name: ARGENT_PKG.name }, SERVER);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /has no mcpName/);
|
|
});
|
|
|
|
// Both sides absent compares equal by ===, so the presence check is what catches
|
|
// it — the publish would be rejected for an unproven namespace.
|
|
test("dropping mcpName and the server.json name together is still caught", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
{ name: ARGENT_PKG.name },
|
|
server((s) => delete s.name)
|
|
);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /has no mcpName/);
|
|
});
|
|
|
|
// The object form is what a hand-edit that drops the brackets produces. It has no
|
|
// .entries(), so catching it here is what keeps the loop below from throwing.
|
|
test("a server.json with no usable packages array is caught", () => {
|
|
const unusable = [
|
|
server((s) => delete s.packages),
|
|
server((s) => (s.packages = [])),
|
|
server(
|
|
(s) =>
|
|
(s.packages = /** @type {any} */ ({ identifier: "@swmansion/argent", version: VERSION }))
|
|
),
|
|
];
|
|
for (const emptied of unusable) {
|
|
const problems = serverJsonMismatches(VERSION, ARGENT_PKG, emptied);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /no packages array/);
|
|
}
|
|
});
|
|
|
|
// The name half of the same shape: with neither side naming a package, the
|
|
// identifier comparison is undefined === undefined and server.json passes while
|
|
// pointing at nothing installable.
|
|
test("a packages/argent manifest with no name is caught", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
{ mcpName: ARGENT_PKG.mcpName },
|
|
server((s) => delete s.packages[0].identifier)
|
|
);
|
|
assert.deepEqual(problems, [
|
|
"packages/argent/package.json has no name — server.json's identifier has nothing to point at",
|
|
]);
|
|
});
|
|
|
|
// packages/argent/package.json is the sole source of the version everything else
|
|
// is compared against, so losing it turns every comparison below into
|
|
// undefined === undefined and the whole check passes vacuously.
|
|
test("a packages/argent manifest with no version is caught", () => {
|
|
const problems = serverJsonMismatches(undefined, ARGENT_PKG, SERVER);
|
|
assert.deepEqual(problems, [
|
|
"packages/argent/package.json has no version — nothing for server.json to be checked against",
|
|
"server.json packages[0].version is 0.18.0, packages/argent/package.json is undefined",
|
|
]);
|
|
});
|
|
|
|
// A hand-edit that empties an entry rather than the array leaves the array shape
|
|
// intact, so the check above passes it through to the loop that reads .version
|
|
// off it.
|
|
test("a packages[] entry that is not an object is caught", () => {
|
|
for (const [entry, shown] of [
|
|
[null, "null"],
|
|
["@swmansion/argent", "string"],
|
|
]) {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => (s.packages = [/** @type {any} */ (entry)]))
|
|
);
|
|
assert.deepEqual(problems, [
|
|
`server.json packages[0] is ${shown}, not an object naming a registry and identifier`,
|
|
]);
|
|
}
|
|
});
|
|
|
|
// Reporting the bad entry must not cost the entries after it, or one stray null
|
|
// would quietly shrink the report the sibling test above relies on being complete.
|
|
test("a non-object packages[] entry does not stop the entries after it", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server(
|
|
(s) =>
|
|
(s.packages = [
|
|
/** @type {any} */ (null),
|
|
{ identifier: "@attacker/pkg", version: "6.6.6" },
|
|
])
|
|
)
|
|
);
|
|
assert.deepEqual(problems, [
|
|
"server.json packages[0] is null, not an object naming a registry and identifier",
|
|
"server.json packages[1].version is 6.6.6, packages/argent/package.json is 0.18.0",
|
|
"server.json packages[1].identifier (@attacker/pkg) != published package name (@swmansion/argent)",
|
|
]);
|
|
});
|
|
|
|
test("every problem is reported at once, not just the first", () => {
|
|
const problems = serverJsonMismatches(
|
|
VERSION,
|
|
ARGENT_PKG,
|
|
server((s) => {
|
|
s.version = "0.17.0";
|
|
s.name = "com.swmansion/argent";
|
|
s.packages[0].version = "0.17.0";
|
|
s.packages[0].identifier = "argent";
|
|
})
|
|
);
|
|
assert.equal(problems.length, 4);
|
|
});
|
|
|
|
// --- the real script, spawned -----------------------------------------------
|
|
// main() is what repo-hygiene.yml runs and none of the above touches it, so a
|
|
// fail-open edit there (an exit(0) on the failure path, a guard that skips
|
|
// main() altogether) would pass every other check in the repo. These run the
|
|
// script for real against a throwaway repo and assert on exit codes.
|
|
|
|
const SCRIPT_PATH = fileURLToPath(new URL("./check-workspace-versions.mjs", import.meta.url));
|
|
|
|
/**
|
|
* A miniature repo: a copy of the real script, two packages/* manifests and a
|
|
* server.json. Realpath'd so the tests below isolate the behaviour they name —
|
|
* macOS's own tmpdir is behind a /var -> /private/var symlink, which the
|
|
* symlink test then reintroduces deliberately.
|
|
* @param {import("node:test").TestContext} t
|
|
* @param {{ argentVersion?: string | null, otherVersion?: string, serverJson?: unknown,
|
|
* extraPackages?: Record<string, unknown> }} [options]
|
|
* serverJson: an object to write as JSON, a string to write verbatim, or null
|
|
* to leave the file out entirely. argentVersion: null omits the version key.
|
|
* extraPackages: further packages/<dir> entries, a null value creating the
|
|
* directory with no package.json in it.
|
|
* @returns {string} the repo root
|
|
*/
|
|
function fixtureRepo(
|
|
t,
|
|
{ argentVersion = VERSION, otherVersion = VERSION, serverJson, extraPackages = {} } = {}
|
|
) {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "check-workspace-versions-")));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
mkdirSync(join(root, "scripts"));
|
|
mkdirSync(join(root, "packages", "argent"), { recursive: true });
|
|
mkdirSync(join(root, "packages", "registry"), { recursive: true });
|
|
copyFileSync(SCRIPT_PATH, join(root, "scripts", "check-workspace-versions.mjs"));
|
|
|
|
const write = (/** @type {string} */ path, /** @type {unknown} */ value) =>
|
|
writeFileSync(path, typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
|
|
write(join(root, "packages", "argent", "package.json"), {
|
|
...ARGENT_PKG,
|
|
...(argentVersion === null ? {} : { version: argentVersion }),
|
|
});
|
|
write(join(root, "packages", "registry", "package.json"), {
|
|
name: "@argent/registry",
|
|
version: otherVersion,
|
|
});
|
|
for (const [dir, manifest] of Object.entries(extraPackages)) {
|
|
mkdirSync(join(root, "packages", dir), { recursive: true });
|
|
if (manifest !== null) write(join(root, "packages", dir, "package.json"), manifest);
|
|
}
|
|
if (serverJson !== null) write(join(root, "server.json"), serverJson ?? SERVER);
|
|
|
|
return root;
|
|
}
|
|
|
|
/** @param {string} root the path to invoke through, which need not be the real one */
|
|
function runScript(root) {
|
|
return spawnSync(process.execPath, [join(root, "scripts", "check-workspace-versions.mjs")], {
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
/** @param {string} stderr */
|
|
function assertNoStackTrace(stderr) {
|
|
assert.doesNotMatch(stderr, /^\s+at /m, `expected guidance, got a stack trace:\n${stderr}`);
|
|
}
|
|
|
|
test("[script] an in-sync repo passes quietly", (t) => {
|
|
const result = runScript(fixtureRepo(t));
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stderr, "");
|
|
assert.match(result.stdout, /All workspace packages and server\.json are at 0\.18\.0\./);
|
|
});
|
|
|
|
test("[script] server.json drift alone exits 1", (t) => {
|
|
const root = fixtureRepo(t, {
|
|
serverJson: server((s) => {
|
|
s.version = "0.17.0";
|
|
s.packages[0].version = "0.17.0";
|
|
}),
|
|
});
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1, `expected a failure, got:\n${result.stdout}${result.stderr}`);
|
|
assert.match(result.stderr, /server\.json version is 0\.17\.0/);
|
|
assert.match(result.stderr, /server\.json packages\[0\]\.version is 0\.17\.0/);
|
|
});
|
|
|
|
// The check the script existed for before this PR. Every other failing [script]
|
|
// test here reaches its exit 1 through a server.json problem, so this is the only
|
|
// one that pins the exit code to workspace drift on its own.
|
|
test("[script] packages/* drift alone exits 1", (t) => {
|
|
const root = fixtureRepo(t, { otherVersion: "0.17.0" });
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1, `expected a failure, got:\n${result.stdout}${result.stderr}`);
|
|
assert.match(result.stderr, /Workspace package versions are out of sync/);
|
|
assert.match(result.stderr, /0\.17\.0: @argent\/registry/);
|
|
// server.json agrees with packages/argent, so nothing else could have failed it.
|
|
assert.doesNotMatch(result.stderr, /server\.json is out of sync/);
|
|
});
|
|
|
|
// packages/argent-private is exactly this in the real repo: a submodule directory
|
|
// carrying no package.json. The scan has to step over an unusable directory and
|
|
// keep going, or every package sorting after it silently leaves the comparison.
|
|
test("[script] an unusable packages/* directory does not stop the scan", (t) => {
|
|
const root = fixtureRepo(t, {
|
|
otherVersion: "0.17.0",
|
|
extraPackages: { nomanifest: null, noversion: { name: "@argent/noversion" } },
|
|
});
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1, `expected a failure, got:\n${result.stdout}${result.stderr}`);
|
|
assert.match(result.stderr, /0\.17\.0: @argent\/registry/);
|
|
});
|
|
|
|
// JSON.parse succeeds on a file holding `null`, so the scan's try/catch never
|
|
// sees it and the version read below is what would throw.
|
|
test("[script] a packages/* manifest holding null does not crash the scan", (t) => {
|
|
const root = fixtureRepo(t, {
|
|
otherVersion: "0.17.0",
|
|
extraPackages: { corrupt: "null" },
|
|
});
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1, `expected a failure, got:\n${result.stdout}${result.stderr}`);
|
|
assert.match(result.stderr, /0\.17\.0: @argent\/registry/);
|
|
assertNoStackTrace(result.stderr);
|
|
});
|
|
|
|
// The report groups every package sharing a version onto one line, which is what
|
|
// makes a drift report readable at 17 packages.
|
|
test("[script] the drift report lists every package sharing a version", (t) => {
|
|
const root = fixtureRepo(t, {
|
|
otherVersion: "0.17.0",
|
|
extraPackages: { alpha: { name: "@argent/alpha", version: VERSION } },
|
|
});
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /0\.18\.0: @argent\/alpha, @swmansion\/argent\n/);
|
|
});
|
|
|
|
// The one shape where every comparison the script makes is vacuously satisfied:
|
|
// with no version on the manifest and none in server.json, each `!==` compares
|
|
// undefined against undefined and the repo passes with nothing checked.
|
|
test("[script] a version-less manifest fails instead of passing vacuously", (t) => {
|
|
const root = fixtureRepo(t, {
|
|
argentVersion: null,
|
|
serverJson: server((s) => {
|
|
delete s.version;
|
|
delete s.packages[0].version;
|
|
}),
|
|
});
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1, `expected a failure, got:\n${result.stdout}${result.stderr}`);
|
|
assert.match(result.stderr, /packages\/argent\/package\.json has no version/);
|
|
assert.doesNotMatch(result.stdout, /at undefined/);
|
|
});
|
|
|
|
test("[script] a half-finished bump reports packages/* and server.json in one run", (t) => {
|
|
const root = fixtureRepo(t, {
|
|
otherVersion: "0.17.0",
|
|
serverJson: server((s) => {
|
|
s.version = "0.17.0";
|
|
s.packages[0].version = "0.17.0";
|
|
}),
|
|
});
|
|
const result = runScript(root);
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /Workspace package versions are out of sync/);
|
|
assert.match(result.stderr, /@argent\/registry/);
|
|
assert.match(result.stderr, /server\.json version is 0\.17\.0/);
|
|
});
|
|
|
|
// node realpaths the main module before setting import.meta.url but leaves
|
|
// process.argv[1] as typed, so a self-invocation guard comparing them raw skips
|
|
// main() here and exits 0 with no output — a drifted repo indistinguishable
|
|
// from a clean one.
|
|
test("[script] a run through a symlinked path still catches drift", (t) => {
|
|
const root = fixtureRepo(t, { serverJson: server((s) => (s.version = "0.17.0")) });
|
|
const aliasParent = realpathSync(mkdtempSync(join(tmpdir(), "check-workspace-versions-alias-")));
|
|
t.after(() => rmSync(aliasParent, { recursive: true, force: true }));
|
|
const alias = join(aliasParent, "repo");
|
|
symlinkSync(root, alias, "dir");
|
|
|
|
const result = runScript(alias);
|
|
assert.equal(result.status, 1, `expected a failure, got:\n${result.stdout}${result.stderr}`);
|
|
assert.match(result.stderr, /server\.json version is 0\.17\.0/);
|
|
});
|
|
|
|
test("[script] a missing server.json fails on-message", (t) => {
|
|
const result = runScript(fixtureRepo(t, { serverJson: null }));
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /Cannot read server\.json: /);
|
|
assert.match(result.stderr, /git checkout -- server\.json/);
|
|
assertNoStackTrace(result.stderr);
|
|
});
|
|
|
|
test("[script] a corrupt server.json does not swallow the packages/* drift", (t) => {
|
|
const result = runScript(fixtureRepo(t, { otherVersion: "0.17.0", serverJson: null }));
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /Workspace package versions are out of sync/);
|
|
assert.match(result.stderr, /Cannot read server\.json/);
|
|
});
|
|
|
|
test("[script] a malformed server.json fails on-message", (t) => {
|
|
const result = runScript(fixtureRepo(t, { serverJson: "{ not json" }));
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /server\.json is not valid JSON: /);
|
|
assert.match(result.stderr, /git checkout -- server\.json/);
|
|
assertNoStackTrace(result.stderr);
|
|
});
|
|
|
|
// `null`, a bare scalar and an array all parse cleanly, so the read has to reject
|
|
// them itself — every field lookup after it would either throw or quietly match
|
|
// undefined.
|
|
test("[script] a server.json that parses but is not an object fails on-message", (t) => {
|
|
for (const [content, shape] of [
|
|
["null", "null"],
|
|
['"0.18.0"', "string"],
|
|
["[]", "an array"],
|
|
]) {
|
|
const result = runScript(fixtureRepo(t, { serverJson: content }));
|
|
assert.equal(result.status, 1, `expected a failure for ${content}, got:\n${result.stdout}`);
|
|
assert.equal(result.stderr.split("\n")[0], `server.json is ${shape}, not a JSON object`);
|
|
assertNoStackTrace(result.stderr);
|
|
}
|
|
});
|