feat: install and update msd from git release tags, not npm

MSD is not published to npm, so /msd-update and the SessionStart update
check could never find a release. Releases are now vX.Y.Z git tags plus a
stable branch:

- check-latest-version reads release tags via git ls-remote against the
  baked repository URL (latest = highest stable tag, next = incl. -rc)
- /msd-update reruns the bootstrap installer pinned to the checked tag and
  reads the changelog from that tag
- bootstrap.sh defaults to the stable branch and supports --local/--global
- package identity: changelog URL on stable, manual install via
  msd.golem15.com
- README quickstart and update how-to describe the one-line installer
- track the repo-root CLAUDE.md

Emitted-Drift-Ack-Growth: update.md — the update flow now explains the git-tag release lookup and pins the bootstrap installer to the checked tag, replacing the npm/npx wording
This commit is contained in:
Jakub Zych
2026-10-09 02:47:50 +02:00
parent bb581803ad
commit 6524974d93
18 changed files with 349 additions and 202 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
node_modules/.cache/eslint/
TO-DOS.md
CLAUDE.md
!/CLAUDE.md
/research.claude/
commands.html

64
CLAUDE.md Normal file
View File

@@ -0,0 +1,64 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
`@golem15/msd-core` (MSD) is a meta-prompting / spec-driven development system for AI coding agents, a fork of open-gsd `gsd-core` that was renamed GSD → MSD. Most of the product is **prompt text** (slash commands, workflows, agents) plus a Node CLI (`msd-tools`) that the prompts shell out to for state, config, phase and git work. An installer copies or converts this content into each supported runtime: Claude Code, Codex, OpenCode, Cursor, ZCode and Antigravity. Other runtimes were removed on purpose, and `lint:retired-runtime-name` guards against re-adding their names.
Before you name or refactor modules, read `CONTEXT.md`, the canonical domain vocabulary. Its predicates are cited by ID, so don't paraphrase them. Also check `docs/adr/`. The architecture deep-dive is `docs/ARCHITECTURE.md`.
## Commands
Node ≥ 24 (`.nvmrc`). Install with `npm ci`, not `npm install`.
```bash
npm run build:lib # tsc: src/*.cts -> msd-core/bin/lib/*.cjs (required before running anything)
npm run build # full build: lib + generated manifests/indexes/skills/hooks
npm test # all suites (pretest runs build:lib + lint:skill-deps)
npm run test:unit # also: test:integration, test:install, test:security, test:slow, test:qa
npm run test:affected # only tests affected by your changes
node --test tests/foo.test.cjs # single file (run build:lib first)
node scripts/run-tests.cjs --files "tests/a.test.cjs tests/b.test.cjs"
npm run lint # eslint, --max-warnings 0
npm run lint:ci # the full CI lint battery (drift/parity/seam linters)
npm run lint:generated-sync # verifies every generated artifact is up to date (--check mode)
npm run regen:derived # regenerate all derived files after changing their sources
npm run check:alias-drift # after touching src/command-aliases.cts or src/*-command-router.cts
npm run changeset -- --type Fixed --pr <N> --body "..." # changelog fragment
```
The test suite comes from the filename suffix: `foo.security.test.cjs` → `security`, and a plain `foo.test.cjs` → `unit`. See `docs/TESTING-SUITES.md`.
## Architecture
- **`commands/msd/*.md`**: slash-command definitions, the source of truth. `skills/msd-*/SKILL.md` is **generated** from them (`gen:plugin-skills`), so never edit `skills/` by hand.
- **`msd-core/workflows/*.md`**: the workflow bodies that commands dispatch to. Large workflows use progressive disclosure: `workflows/<name>/modes/*.md`, with `discuss-phase/` as the canonical example. Workflows are thin orchestrators that spawn agents with fresh context, and state lives in files under the user project's `.planning/` (STATE.md, ROADMAP.md, phases/).
- **`agents/*.md`**: subagent definitions, the canonical source. `.claude/agents/`, `.cursor/agents/` and `.github/agents/msd-*` are gitignored install outputs, so never edit them.
- **`msd-core/references/`, `templates/`, `contexts/`**: shared reference text and templates loaded by workflows.
- **`src/*.cts` → `msd-core/bin/lib/*.cjs`** (ADR-457, "build-at-publish"): the core library is TypeScript compiled to CommonJS, and the compiled `.cjs` files are **gitignored**. Edit `src/`, never the generated `.cjs`. A handful of `bin/lib/*.cjs` files are still tracked: some are hand-written and some are generated-and-committed (`capability-registry.cjs`, `package-identity.cjs`, `loop-host-contract.cjs`, `vendor/`). Run `git ls-files msd-core/bin/lib` to tell which is which.
- **`msd-core/bin/msd-tools.cjs`**: the CLI the prompts call (`state …`, `phase …`, `resolve-model`, `init …`, `commit`, and more). Subcommands go through `command-routing-hub` and the per-family `src/*-command-router.cts` + `src/command-aliases.cts`, and the alias drift check keeps these in sync.
- **`capabilities/<id>/capability.json`**: optional feature/runtime modules (tdd, security, code-review, mempalace, per-runtime ones such as `claude`, `codex`, …). They declare config keys, contributions to workflow steps, hooks and agents. The aggregated registry `msd-core/bin/lib/capability-registry.cjs` is generated (`gen:capability-registry`).
- **`hooks/`**: runtime hooks (prompt/read guards, context monitor, commit validation, …), built into `hooks/dist` by `build:hooks`.
- **`bin/install.js`**: the multi-runtime installer that converts and copies commands, agents, skills and hooks into each runtime's config dir.
- **`scripts/`**: build, generate and lint tooling. Many `gen-*.cjs` scripts support `--write` and `--check`, and CI runs them all in `--check`.
Generated artifacts are everywhere (skills, registries, `docs/INVENTORY-MANIFEST.json`, `docs/CONTEXT-INDEX.json`, `FEATURES.md`, exit-code docs, …). If you change a source that feeds one, regenerate it (`npm run regen:derived` or the specific `gen:*` script), or `lint:generated-sync` will fail.
## Coding rules (from CONTRIBUTING.md / .clinerules)
- CommonJS runtime (`require`). Core lib/CLI use **Node built-ins only**, with no runtime npm deps in core (vendored deps sit in `bin/lib/vendor/`).
- To spawn a process, use `execFileSync` with array args, never `execSync` with string interpolation. Validate user-provided paths with `validatePath()` (security module).
- Tests use **`node:test` + `node:assert/strict` only**. Use `tests/helpers.cjs` (`createTempProject`, `createTempGitProject`, `cleanup`, `runMsdTools`) and spawn subprocesses through `tests/helpers/process-seam.cjs`. Clean up with `beforeEach`/`afterEach` or `t.after()`, never `try/finally` in a test body.
- **No source-grep tests and no raw-text matching on outputs** (stdout, rendered files, reason strings). Assert on structured values: `--json` modes, frozen reason enums, builder IRs. This is enforced by the `local/no-source-grep` ESLint rule. Exemptions need an `allow-test-rule: <reason>` annotation (for example `source-text-is-the-product` for `.md` prompt content).
- Changes to shipped prompt content (workflows, agents, commands) are checked by `tests/emitted-attribution.test.cjs`, and size budgets exist (`workflow-size-budget`, `agent-size-budget`). If emitted bytes move for a reason the diff doesn't show, add a commit trailer: `Emitted-Drift-Ack-Hash:` / `Emitted-Drift-Ack-Growth: <path> — <why>`.
- Commits follow conventional format `<type>(<scope>): <subject>`, with the subject ≤72 chars, lowercase, imperative and without a trailing period (enforced by `hooks/msd-validate-commit.sh`). Put the test-fixture correction in its own `test:` commit.
- A PR that touches `bin/`, `msd-core/`, `src/`, `agents/`, `commands/` or `hooks/` needs a `.changeset/*.md` fragment. Never edit `CHANGELOG.md` directly.
- Branching: feature branches open PRs against `next` on git.golem15.com (Gitea; use `tea`, not `gh`). There is no `main`. `.githooks/` contains pre-commit (alias drift) and pre-push gates.
## Distribution and releases
MSD is **not on npm**; the inherited `.github/workflows/release.yml` npm pipeline does not apply to this fork.
- Users install with `curl -fsSL https://msd.golem15.com | bash` (`scripts/bootstrap.sh`). It clones into `~/.local/share/msd-core`, builds, and runs `bin/install.js`. A Cloudflare Worker (`msd-installer`) serves the script from the `stable` branch.
- A release is a `vX.Y.Z` tag on `next`, after which the `stable` branch is moved to that tag. `/msd-update` and the SessionStart update check find the latest version from those tags (`msd-core/bin/check-latest-version.cjs`, `git ls-remote`), then rerun the bootstrap installer pinned with `--ref vX.Y.Z`.
- The repository URL and manual-install command come from the generated Package Identity seam (`scripts/generate-package-identity.cjs` → `msd-core/bin/lib/package-identity.cjs`), derived from `package.json`.

View File

@@ -34,12 +34,26 @@ Each milestone repeats the same five-step loop, one phase at a time:
## Quickstart
MSD Core is a private fork and is not published to npm. Install from the checkout:
MSD Core is not published to npm. Install the latest release (needs Node.js 24+, git and jq) with:
```bash
git clone git@git.golem15.com:golem15/msd-core.git
cd msd-core
scripts/install-golem15.sh # Node 24; installs for Claude Code and Codex
curl -fsSL https://msd.golem15.com | bash
```
This installs Claude Code if it is missing, clones MSD into `~/.local/share/msd-core` at the `stable` branch (the latest tagged release), builds it and installs it globally for Claude Code. Rerun the same command, or `/msd-update` inside your runtime, to update. Options go after `bash -s --`:
```bash
curl -fsSL https://msd.golem15.com | bash -s -- --claude --codex # several runtimes
curl -fsSL https://msd.golem15.com | bash -s -- --local # into the current project only
curl -fsSL https://msd.golem15.com | bash -s -- --ref next # unreleased work from next
```
From a checkout of this repository you can also run the installer directly:
```bash
git clone https://git.golem15.com/golem15/msd-core.git
cd msd-core && npm ci && npm run build:hooks
node bin/install.js --claude --global
```
For any other runtime, run `node bin/install.js` directly. The installer prompts for your runtime (Claude Code, OpenCode, Antigravity CLI, Codex, Cursor, and more) and whether to install globally or locally. The installer is required for cross-runtime compatibility — do not copy files from `agents/` or `commands/` directly.

View File

@@ -106,14 +106,14 @@ test('msd-tools reports the resolved config', (t) => {
**Compliant — a genuinely distinct class: name it, and size it relative to what it wraps:**
```javascript
const { NPM_VIEW_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
const { LOOKUP_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
// Real headroom beyond the inner timeout the worker itself is bounded by — not a
// second independent guess. See TESTING-STANDARDS.md's "No ad hoc timeout literals".
const WORKER_TEARDOWN_MARGIN_MS = 10_000;
test('worker run leaves a valid cache', (t) => {
const r = runHookSeam(WORKER_PATH, [], { timeoutMs: NPM_VIEW_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });
const r = runHookSeam(WORKER_PATH, [], { timeoutMs: LOOKUP_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });
assert.equal(r.exitCode, 0);
});
```

View File

@@ -192,14 +192,14 @@ const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const r = runHookSeam(WORKER_PATH, [], { timeoutMs: GIT_TIMEOUT_MS });
```
**Compliant — a genuinely distinct class, declared locally with a margin over the thing it wraps** (the actual fix in PR #4428 — the worker's inner `npm view` call is bounded by its own named `NPM_VIEW_TIMEOUT_MS`, so the outer test imports it and adds explicit headroom instead of re-guessing a number):
**Compliant — a genuinely distinct class, declared locally with a margin over the thing it wraps** (the actual fix in PR #4428 — the worker's inner `npm view` call is bounded by its own named `LOOKUP_TIMEOUT_MS`, so the outer test imports it and adds explicit headroom instead of re-guessing a number):
```javascript
const { NPM_VIEW_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
const { LOOKUP_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
const WORKER_TEARDOWN_MARGIN_MS = 10_000; // real headroom beyond the inner timeout it wraps
const r = runHookSeam(WORKER_PATH, [], { timeoutMs: NPM_VIEW_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });
const r = runHookSeam(WORKER_PATH, [], { timeoutMs: LOOKUP_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });
```
Import an existing class-norm constant from `tests/helpers/timeouts.cjs` (`PROBE_TIMEOUT_MS`, `GIT_TIMEOUT_MS`, `BUILD_TIMEOUT_MS`, `INSTALL_TIMEOUT_MS`) when the call is the same class of subprocess, or declare a local one with a comment justifying why it is a distinct class — see CONTRIBUTING.md's "Use Centralized Test Helpers" section.

View File

@@ -2,7 +2,7 @@
Update an existing MSD Core install to the latest release, preview the changelog before committing, and recover any local customisations that the update would overwrite.
**What you need:** The same runtime MSD is installed for. The update command re-runs the installer under the hood, so it needs Node.js and npx available (same requirement as the original install).
**What you need:** The same runtime MSD is installed for. The update command re-runs the installer under the hood, so it needs Node.js 24+, git and curl available (same requirement as the original install).
---
@@ -17,11 +17,11 @@ From inside your AI runtime, run:
MSD will:
1. Detect the installed version and install scope (global or local).
2. Check npm for the latest release of `@golem15/msd-core`.
2. Check the MSD repository's release tags (`vX.Y.Z`) for the latest release.
3. Fetch the changelog and show you what changed between your installed version and the latest.
4. Ask for confirmation before touching anything.
5. Back up any user-added files found inside MSD-managed directories to `msd-user-files-backup/`.
6. Run the installer (`npx @golem15/msd-core@latest --<runtime> --<scope>`).
6. Run the bootstrap installer pinned to that release (`curl -fsSL https://msd.golem15.com | bash -s -- --ref vX.Y.Z --<runtime> --<scope>`).
7. Clear the update-check cache so the statusline indicator resets.
8. Offer to restore the user-added files it backed up in step 5.
9. Report whether locally modified MSD files were backed up to `msd-local-patches/`.
@@ -46,19 +46,19 @@ Restart your runtime after a successful update to pick up new commands and agent
|------|--------------|
| `--sync` | After updating, sync skills from the MSD registry |
| `--reapply` | After updating, merge locally modified MSD files back in from `msd-local-patches/` |
| `--next` / `--rc` | Target the `@next` RC dist-tag instead of `@latest` (installs or refreshes a release candidate; see ADR #660) |
| `--next` / `--rc` | Also consider prerelease tags (`vX.Y.Z-rc.N`), so a release candidate can be installed or refreshed (see ADR #660) |
```bash
/msd-update --sync # Update and sync skills
/msd-update --reapply # Update and reapply local patches
/msd-update --next # Install from the @next RC dist-tag
/msd-update --next # Include release candidates
```
---
## Install or refresh a release candidate
MSD publishes release candidates on the `@next` npm dist-tag (established by ADR #660). To install or refresh from that channel:
MSD publishes release candidates as prerelease tags such as `v2.1.0-rc.1` (the RC channel established by ADR #660). To install or refresh from that channel:
```bash
/msd-update --next
@@ -66,11 +66,11 @@ MSD publishes release candidates on the `@next` npm dist-tag (established by ADR
/msd-update --rc
```
The full update flow applies — scope/runtime detection, changelog preview, custom-file backup, and cache clearing all run normally. The only difference is that `check-latest-version.cjs` resolves the `@next` tag and npx installs from `@golem15/msd-core@next`.
The full update flow applies — scope/runtime detection, changelog preview, custom-file backup, and cache clearing all run normally. The only difference is that `check-latest-version.cjs` also counts prerelease tags when it picks the latest version, and the installer is pinned to that tag.
Only `latest` and `next` are supported channels; no arbitrary dist-tag can be passed (the script enforces an allowlist and exits with code 2 on an invalid tag).
Only `latest` and `next` are supported channels; no arbitrary channel can be passed (the script enforces an allowlist and exits with code 2 on an invalid tag).
Omitting `--next`/`--rc` keeps targeting `@latest` (stable channel, no change in behavior).
Omitting `--next`/`--rc` targets the highest stable `vX.Y.Z` tag.
---
@@ -95,7 +95,7 @@ Latest: 1.41.0
Proceed with update? [Yes, update now / No, cancel]
```
If the changelog cannot be fetched (no network access, npm outage), the update still proceeds after confirmation — it does not block on changelog availability.
If the changelog cannot be fetched (no network access, forge outage), the update still proceeds after confirmation — it does not block on changelog availability.
---
@@ -151,9 +151,9 @@ It is safe to run `--reapply` on its own without triggering a new download — i
---
## When npm is unavailable
## When the installer is unavailable
If `npx @golem15/msd-core@latest` fails due to an npm outage, network restrictions, or because you are working from the source repository, use the manual update procedure in [docs/manual-update.md](../manual-update.md). That document covers pulling the latest commit, building the hooks dist, and running `node bin/install.js` directly.
If the bootstrap installer fails due to an outage, network restrictions, or because you are working from the source repository, use the manual update procedure in [docs/manual-update.md](../manual-update.md). That document covers pulling the latest commit, building the hooks dist, and running `node bin/install.js` directly.
---

View File

@@ -25,7 +25,7 @@ const path = require('path');
* fired, the outer one could SIGKILL the whole process tree at the exact
* same instant before it could degrade gracefully: a zero-margin race that
* failed specifically on Windows CI (fixed in PR #4428 by extracting a named
* `NPM_VIEW_TIMEOUT_MS` constant and referencing it with an explicit
* `LOOKUP_TIMEOUT_MS` constant and referencing it with an explicit
* margin). This rule closes the gap CONTRIBUTING.md already documents:
* "A non-literal value (`timeout: GIT_TIMEOUT_MS`) is trusted — that is the
* shape you should be writing" — by actually enforcing that shape.

View File

@@ -51,8 +51,9 @@ try {
ensureRuntimeBuild();
({ isSemverNewer } = require('../msd-core/bin/lib/semver-compare.cjs'));
// Latest-version lookup is delegated to the single deterministic adapter
// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver
// policy, and the package name — sourced from the baked Package Identity seam.
// (#498). checkLatestVersion() owns the release-tag lookup (git ls-remote),
// the timeout/semver policy, and the repository URL — sourced from the baked
// Package Identity seam.
// The previous `require('../package.json').name` (#378) never yielded a name in
// the installed tree — at the time it resolved to the synthetic
// {"type":"commonjs"} marker MSD wrote at the config root, which has no `.name`,
@@ -136,9 +137,9 @@ if (configDir) {
} catch (e) {}
}
// Single adapter for the registry lookup (#498). checkLatestVersion() routes
// through the shell-projection seam, which already owns the Windows shell-flag
// policy, the timeout, and semver validation. A non-ok result leaves latest
// Single adapter for the release lookup (#498). checkLatestVersion() routes
// through the shell-projection seam, which already owns the non-interactive git
// env and the timeout; it also owns the semver validation. A non-ok result leaves latest
// null, exactly as the previous inline try/catch did.
let latest = null;
try {

View File

@@ -5,67 +5,105 @@
* Deterministic latest-version check for /msd-update (#2992).
*
* The /msd-update workflow's check_latest_version step was previously
* prescribed in LLM-driven prose ("run `npm view msd-core
* version`"). The executing model could shortcut the prescription and
* invent npm queries against wrong-shaped names (`@msd-core/cli`,
* `get-shit-done-cli`, `msd`), all of which 404 or — worse — return an
* unrelated typosquat package.
* prescribed in LLM-driven prose ("run `npm view msd-core version`"). The
* executing model could shortcut the prescription and invent queries against
* wrong-shaped names, all of which 404 or — worse — return an unrelated
* typosquat package.
*
* This script makes the package name a CONSTANT in code, not a free
* choice at execution time. The workflow calls it via `npm run
* check-latest-version -- --json` and parses the structured response.
* MSD is not published to npm: releases are git tags (`vX.Y.Z`) on the
* repository named in package.json. This script makes that repository URL a
* CONSTANT in code (baked by the Package Identity seam), not a free choice at
* execution time, and reads the release tags with `git ls-remote --tags`.
* The `latest` channel is the highest stable `vX.Y.Z` tag; the `next` channel
* also considers prerelease tags (`vX.Y.Z-rc.N`).
*
* Tests assert on the typed CHECK_REASON enum and the structured result
* record, never on console prose. See CONTRIBUTING.md "Prohibited: Raw
* Text Matching on Test Outputs".
*/
const { execNpm } = require('./lib/shell-command-projection.cjs');
const { execGit } = require('./lib/shell-command-projection.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
const { compareSemverCore, isStableTripletSemver } = require('./lib/semver-compare.cjs');
// Sourced from the single Package Identity seam (#498), not re-typed. The seam
// bakes the value from package.json at build time, so it is a code constant —
// still NOT a runtime choice for the caller (#2992) — and a rename propagates
// bakes the values from package.json at build time, so they are code constants —
// still NOT a runtime choice for the caller (#2992) — and a repoint propagates
// from one place (#378). The drift-guard lint forbids re-introducing a literal.
const { packageName: PACKAGE_NAME } = require('./lib/package-identity.cjs');
const { packageName: PACKAGE_NAME, repoUrl: REPO_URL } = require('./lib/package-identity.cjs');
const CHECK_REASON = Object.freeze({
OK: 'ok',
FAIL_NPM_FAILED: 'fail_npm_failed',
FAIL_INVALID_OUTPUT: 'fail_invalid_output',
FAIL_LOOKUP_FAILED: 'fail_lookup_failed',
FAIL_NO_RELEASES: 'fail_no_releases',
});
const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
// #815: the one RC channel ADR #660 sanctions, plus the stable default.
// An allowlist (not a free string) keeps a typo from silently resolving
// `npm view` to an empty or foreign dist-tag.
// #815: the stable default plus the one prerelease channel. An allowlist (not
// a free string) keeps a typo from silently resolving to an empty channel.
const ALLOWED_TAGS = Object.freeze(['latest', 'next']);
// Bounded at 15s so a hung registry doesn't block /msd-update (#2993 CR).
// Bounded at 15s so a hung remote doesn't block /msd-update (#2993 CR).
// Exported so callers (e.g. the worker's own test harness, #4091) can derive
// their own outer timeout with real margin above this inner bound instead of
// re-hardcoding 15000 and silently drifting into a zero-margin race.
const NPM_VIEW_TIMEOUT_MS = 15_000;
const LOOKUP_TIMEOUT_MS = 15_000;
/**
* Build the `npm view` args for a dist-tag. `latest` keeps the bare package
* spec so the default invocation is byte-for-byte identical to before tag
* support existed (#815); any other allowlisted tag appends `@<tag>` so
* `npm view @golem15/msd-core@next version` resolves the RC channel (#660).
*/
function buildViewArgs(tag = 'latest') {
if (!ALLOWED_TAGS.includes(tag)) {
throw new RangeError(`invalid dist-tag '${tag}'; allowed: ${ALLOWED_TAGS.join(', ')}`);
}
const spec = tag === 'latest' ? PACKAGE_NAME : `${PACKAGE_NAME}@${tag}`;
return ['view', spec, 'version'];
/** The `git ls-remote` args that list the release tags of the baked repository. */
function buildLsRemoteArgs() {
return ['ls-remote', '--tags', '--refs', `${REPO_URL}.git`];
}
/**
* Resolve the requested dist-tag from argv. Defaults to `latest` (no flag =>
* Order two semver strings, prerelease-aware: the core triplet decides first,
* then a stable release beats any prerelease of the same core, then the
* prerelease identifiers compare dot-wise (numeric identifiers numerically).
*/
function compareVersions(a, b) {
const core = compareSemverCore(a, b);
if (core !== 0) return core;
const preA = a.includes('-') ? a.slice(a.indexOf('-') + 1).split('.') : [];
const preB = b.includes('-') ? b.slice(b.indexOf('-') + 1).split('.') : [];
if (preA.length === 0 && preB.length === 0) return 0;
if (preA.length === 0) return 1; // a is the stable release of b's prerelease
if (preB.length === 0) return -1;
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
if (preA[i] === undefined) return -1;
if (preB[i] === undefined) return 1;
const numA = /^\d+$/.test(preA[i]);
const numB = /^\d+$/.test(preB[i]);
if (numA && numB && Number(preA[i]) !== Number(preB[i])) return Number(preA[i]) > Number(preB[i]) ? 1 : -1;
if (numA !== numB) return numA ? -1 : 1;
if (preA[i] !== preB[i]) return preA[i] > preB[i] ? 1 : -1;
}
return 0;
}
/**
* Pure: pick the highest release version from `git ls-remote --tags` output.
* Ref names are untrusted data: each line is `<sha>\t<ref>`; only
* `refs/tags/vX.Y.Z[-pre]` refs count, the leading `v` is stripped, and
* prerelease tags are considered only on the `next` channel. Returns null
* when no release tag exists.
*/
function pickLatestVersion(lsRemoteOutput, tag = 'latest') {
let best = null;
for (const line of String(lsRemoteOutput || '').split('\n')) {
const ref = line.trim().split(/\s+/)[1] || '';
if (!ref.startsWith('refs/tags/v')) continue;
const version = ref.slice('refs/tags/v'.length);
if (!SEMVER_RE.test(version)) continue;
if (tag === 'latest' && !isStableTripletSemver(version)) continue;
if (best === null || compareVersions(version, best) > 0) best = version;
}
return best;
}
/**
* Resolve the requested channel from argv. Defaults to `latest` (no flag =>
* no behavior change). Restricted to ALLOWED_TAGS so a typo can't silently
* resolve to an empty/foreign tag (#815 alternative 1).
* resolve to an empty/foreign channel (#815 alternative 1).
*/
function resolveTag(argv) {
let val;
@@ -86,20 +124,20 @@ function resolveTag(argv) {
}
/**
* Pure-ish: takes an injected spawn function so tests don't actually run npm.
* In production, defaults to execNpm() from the shell-projection seam.
* Pure-ish: takes an injected spawn function so tests don't touch the network.
* In production, defaults to execGit() from the shell-projection seam.
*/
function checkLatestVersion(opts = {}) {
const tag = opts.tag || 'latest';
if (!ALLOWED_TAGS.includes(tag)) {
throw new RangeError(`invalid dist-tag '${tag}'; allowed: ${ALLOWED_TAGS.join(', ')}`);
throw new RangeError(`invalid channel '${tag}'; allowed: ${ALLOWED_TAGS.join(', ')}`);
}
// Default path routes through the shell-projection seam (execNpm owns the
// Windows shell-flag policy and timeout default). The injection point
// remains spawnSync-shaped for test compatibility — the adapter below
// translates { exitCode } → { status } so the consumer logic is unchanged.
// Default path routes through the shell-projection seam (execGit owns the
// non-interactive env and timeout default). The injection point remains
// spawnSync-shaped for test compatibility — the adapter below translates
// { exitCode } → { status } so the consumer logic is unchanged.
const defaultSpawn = () => {
const r = execNpm(buildViewArgs(tag), { timeout: NPM_VIEW_TIMEOUT_MS });
const r = execGit(buildLsRemoteArgs(), { timeout: LOOKUP_TIMEOUT_MS });
return {
status: r.exitCode,
stdout: r.stdout,
@@ -113,28 +151,27 @@ function checkLatestVersion(opts = {}) {
const r = spawn();
if (!r || r.status !== 0) {
// Distinguish timeout (status null, signal set, stderr empty) from a
// genuine npm failure. Without this, both surfaced as "npm exited
// non-zero" and the operator couldn't tell which (#2993 CR).
// genuine git failure, so the operator can tell which (#2993 CR).
let detail;
if (r && r.signal) {
detail = `npm timed out (signal: ${r.signal})`;
detail = `git timed out (signal: ${r.signal})`;
} else if (r && r.stderr) {
detail = r.stderr.trim();
} else {
detail = 'npm exited non-zero';
detail = 'git exited non-zero';
}
return {
ok: false,
reason: CHECK_REASON.FAIL_NPM_FAILED,
reason: CHECK_REASON.FAIL_LOOKUP_FAILED,
detail,
};
}
const version = (r.stdout || '').trim();
if (!SEMVER_RE.test(version)) {
const version = pickLatestVersion(r.stdout, tag);
if (version === null) {
return {
ok: false,
reason: CHECK_REASON.FAIL_INVALID_OUTPUT,
detail: version || '(empty)',
reason: CHECK_REASON.FAIL_NO_RELEASES,
detail: `no ${tag === 'latest' ? 'stable ' : ''}vX.Y.Z release tags`,
};
}
return { ok: true, version, reason: CHECK_REASON.OK };
@@ -163,4 +200,4 @@ function main() {
if (require.main === module) runMain(main);
module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME, ALLOWED_TAGS, NPM_VIEW_TIMEOUT_MS, buildViewArgs, resolveTag };
module.exports = { checkLatestVersion, pickLatestVersion, compareVersions, CHECK_REASON, PACKAGE_NAME, REPO_URL, ALLOWED_TAGS, LOOKUP_TIMEOUT_MS, buildLsRemoteArgs, resolveTag };

View File

@@ -7,17 +7,17 @@ const packageName = "@golem15/msd-core";
const binName = "msd-core";
const repoSlug = "golem15/msd-core";
const repoUrl = "https://git.golem15.com/golem15/msd-core";
const changelogRawUrl = "https://git.golem15.com/golem15/msd-core/raw/branch/next/CHANGELOG.md";
const changelogRawUrl = "https://git.golem15.com/golem15/msd-core/raw/branch/stable/CHANGELOG.md";
const cacheSlug = "golem15-msd-core";
const updateCacheFileName = "msd-update-check-golem15-msd-core.json";
function formatManualInstall({ packageName, binName, scope, runtime } = {}) {
function formatManualInstall({ scope, runtime } = {}) {
const runtimeFlag = runtime ? ` --${runtime}` : '';
return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`;
return `curl -fsSL https://msd.golem15.com | bash -s --${runtimeFlag} --${scope}`;
}
function manualInstallCommand(opts = {}) {
return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });
return formatManualInstall({ scope: opts.scope, runtime: opts.runtime });
}
module.exports = Object.freeze({

View File

@@ -1,5 +1,5 @@
<purpose>
Check for MSD updates via npm, display changelog for versions between installed and latest, obtain user confirmation, and execute clean installation with cache clearing.
Check for MSD updates against the release tags (`vX.Y.Z`) of the MSD repository, display changelog for versions between installed and latest, obtain user confirmation, and execute clean installation with cache clearing.
</purpose>
<required_reading>
@@ -87,7 +87,7 @@ UPDATE_TARGET_UNRESOLVED
MSD could not resolve an installed update target. No update was performed.
Rerun from a valid installed runtime: `/msd:update`. For a fresh installation, run `npx -y --package=@golem15/msd-core@latest -- msd-core --global`.
Rerun from a valid installed runtime: `/msd:update`. For a fresh installation, run `curl -fsSL https://msd.golem15.com | bash -s -- --global`.
```
Exit.
@@ -96,7 +96,7 @@ Exit.
</step>
<step name="parse_update_channel">
Determine the release channel from `$ARGUMENTS`. This selects which npm dist-tag the entire update flow targets — `latest` (stable) by default, or `next` (the RC channel established by ADR #660) when the user opts in with `--next`/`--rc`:
Determine the release channel from `$ARGUMENTS`. This selects which release channel the entire update flow targets — `latest` (the highest stable `vX.Y.Z` tag) by default, or `next` (which also counts prerelease `vX.Y.Z-rc.N` tags, the RC channel established by ADR #660) when the user opts in with `--next`/`--rc`:
```bash
case " $ARGUMENTS " in
@@ -111,7 +111,7 @@ case " $ARGUMENTS " in
esac
```
`TAG` is restricted to `latest`/`next` by `check-latest-version.cjs` (it rejects any other value with exit 2), so no arbitrary dist-tag can leak through. Omitting `--next`/`--rc` reproduces the prior behavior exactly: `TAG=latest`.
`TAG` is restricted to `latest`/`next` by `check-latest-version.cjs` (it rejects any other value with exit 2), so no arbitrary channel can leak through. Omitting `--next`/`--rc` reproduces the prior behavior exactly: `TAG=latest`.
**Section-manifest gate (#2994):** reuse the `$MSD_TOOLS` already resolved by `get_installed_version` above — do NOT copy the canonical launcher preamble here, it assigns the SAME `$MSD_TOOLS` variable via a different (fixed-candidate) resolution and would silently override the value `backup_custom_files`/`restore_custom_files` (later steps) still depend on. Forward `--next`/`--rc` from `$ARGUMENTS` so `init.update`'s `state:next-channel` fact matches this step's own case-statement:
@@ -130,7 +130,7 @@ Extract `section_manifest` from `INIT_UPDATE` — gates the `channel-banner` sec
</step>
<step name="check_latest_version">
Check npm for latest version via the deterministic script. **Do NOT run `npm view` or `npm search` directly** — the package name must come from the script, not from a free choice at execution time. (#2992: LLM-driven prescriptions of npm package names produced wrong-package queries; moving the package name into a script constant closes that gap.)
Check the latest release via the deterministic script, which reads the repository's release tags with `git ls-remote`. **Do NOT run `git ls-remote`, `npm view` or `npm search` directly** — the repository URL must come from the script, not from a free choice at execution time. MSD is not published to npm. (#2992: LLM-driven prescriptions of package names produced wrong-package queries; moving the coordinates into a script constant closes that gap.)
The `MSD_DIR` value emitted by `get_installed_version` (line 4) resolves to the runtime-specific config dir (`~/.claude/`, `~/.gemini/antigravity/`, `~/.codex/`, etc.), so the script invocation works for every runtime — not just Claude. An unresolved target exits in `get_installed_version` before this step.
@@ -165,7 +165,7 @@ fi
```text
Couldn't check for updates (reason: {LATEST_REASON}, exit: {LATEST_STATUS}).
To update manually: `npx -y --package=@golem15/msd-core@{TAG} -- msd-core --global`
To update manually: `curl -fsSL https://msd.golem15.com | bash -s -- --global`
```
Exit.
@@ -205,7 +205,7 @@ by re-running the local installer from your dev branch:
node bin/install.js --global --claude
Running /msd:update would install the npm release (A.B.C) and downgrade
Running /msd:update would install the release (A.B.C) and downgrade
your dev version — do NOT use it to resolve this warning.
```
@@ -215,13 +215,13 @@ Exit.
<step name="show_changes_and_confirm">
**If update available**, fetch and show what's new BEFORE updating:
1. Fetch changelog from GitHub raw URL and save to a temp file, e.g. `/tmp/msd-changelog-$$.md`.
1. Fetch the changelog of the target release tag from the repository's raw URL and save it to a temp file, e.g. `/tmp/msd-changelog-$$.md`.
2. Extract entries between installed and latest versions using the deterministic range helper (fix for #3496 — do NOT use ad-hoc grep/awk extraction which silently skips intermediate versions):
```bash
CHANGELOG_TMP="/tmp/msd-changelog-$$.md"
curl -fsSL "https://git.golem15.com/golem15/msd-core/raw/branch/next/CHANGELOG.md" -o "$CHANGELOG_TMP" 2>/dev/null \
|| wget -qO "$CHANGELOG_TMP" "https://git.golem15.com/golem15/msd-core/raw/branch/next/CHANGELOG.md" 2>/dev/null
curl -fsSL "https://git.golem15.com/golem15/msd-core/raw/tag/v${LATEST_VERSION}/CHANGELOG.md" -o "$CHANGELOG_TMP" 2>/dev/null \
|| wget -qO "$CHANGELOG_TMP" "https://git.golem15.com/golem15/msd-core/raw/tag/v${LATEST_VERSION}/CHANGELOG.md" 2>/dev/null
MSD_CHANGESET_CLI="$MSD_DIR/scripts/changeset/cli.cjs"
if [ ! -f "$MSD_CHANGESET_CLI" ]; then
@@ -387,12 +387,12 @@ RUNTIME_FLAG="--$TARGET_RUNTIME"
**If LOCAL install:**
```bash
npx -y --package=@golem15/msd-core@"$TAG" -- msd-core "$RUNTIME_FLAG" --local
curl -fsSL https://msd.golem15.com | bash -s -- --ref "v$LATEST_VERSION" "$RUNTIME_FLAG" --local
```
**If GLOBAL install:**
```bash
npx -y --package=@golem15/msd-core@"$TAG" -- msd-core "$RUNTIME_FLAG" --global
curl -fsSL https://msd.golem15.com | bash -s -- --ref "v$LATEST_VERSION" "$RUNTIME_FLAG" --global
```
Capture output. If install fails, show error and exit.
@@ -572,7 +572,7 @@ Run `/msd:update --reapply` to merge your modifications into the new version.
<success_criteria>
- [ ] Installed version read correctly
- [ ] Latest version checked via npm
- [ ] Latest version checked against the release tags
- [ ] Update skipped if already current
- [ ] Changelog fetched and displayed BEFORE update
- [ ] Clean install warning shown

View File

@@ -4,4 +4,4 @@
On the default stable channel (`TAG=latest`), do NOT add a channel line — the output must match the prior stable behavior exactly.
When `TAG=next`, the "latest" value is the release candidate published under `@next` (e.g. `1.4.0-rc.1`). Apply standard semver precedence for prereleases (`1.4.0-rc.1` is newer than `1.3.1` but older than the final `1.4.0`). Do NOT treat an `-rc.N` suffix as a dev install or as "behind" — offer it as an available update.
When `TAG=next`, the "latest" value is the highest release tag including prereleases (e.g. `v1.4.0-rc.1` → `1.4.0-rc.1`). Apply standard semver precedence for prereleases (`1.4.0-rc.1` is newer than `1.3.1` but older than the final `1.4.0`). Do NOT treat an `-rc.N` suffix as a dev install or as "behind" — offer it as an available update.

View File

@@ -4,8 +4,10 @@
# curl -fsSL https://msd.golem15.com | bash
# curl -fsSL https://msd.golem15.com | bash -s -- --codex --profile standard
#
# Clones (or updates) the MSD checkout into $MSD_HOME, builds it, and runs
# bin/install.js globally for the chosen runtime(s). Installs Claude Code
# Clones (or updates) the MSD checkout into $MSD_HOME at the `stable` branch
# (the latest tagged release), builds it, and runs bin/install.js for the
# chosen runtime(s) — globally by default, or into the current directory with
# --local. Installs Claude Code
# first when it is the target runtime and `claude` is not on PATH.
#
# Everything lives inside main() so a truncated download never runs half a script.
@@ -14,10 +16,12 @@ main() {
set -euo pipefail
local repo="${MSD_REPO:-https://git.golem15.com/golem15/msd-core.git}"
local ref="${MSD_REF:-next}"
local ref="${MSD_REF:-stable}"
local home_dir="${MSD_HOME:-$HOME/.local/share/msd-core}"
local profile="${MSD_PROFILE:-full}"
local install_claude=true
local scope=--global
local start_dir="$PWD"
local runtimes=()
while (($#)); do
@@ -34,6 +38,7 @@ main() {
(($#)) || { echo "ERROR: --ref requires a value." >&2; exit 2; }
ref="$1"
;;
--global|--local) scope="$1" ;;
--skip-claude-install) install_claude=false ;;
-h|--help)
cat <<'EOF'
@@ -45,7 +50,10 @@ Options:
--claude, --codex, --opencode, --cursor, --zcode, --antigravity
Runtime(s) to install for (default: --claude)
--profile NAME Skill profile: core, standard or full (default: full)
--ref REF Branch or tag to install (default: next)
--ref REF Branch or tag to install (default: stable, the
latest release; e.g. v2.0.0, or next for unreleased work)
--global | --local Install into the runtime's home config (default) or
into the current directory's project config
--skip-claude-install Do not install Claude Code when it is missing
Environment: MSD_HOME (checkout dir, default ~/.local/share/msd-core),
@@ -111,14 +119,15 @@ EOF
npm run --silent build:hooks
local rt
cd "$start_dir"
for rt in "${runtimes[@]}"; do
say "Installing MSD for ${rt#--} (profile: $profile)"
node bin/install.js "$rt" --global "--profile=$profile"
say "Installing MSD for ${rt#--} (${scope#--}, profile: $profile)"
node "$home_dir/bin/install.js" "$rt" "$scope" "--profile=$profile"
done
cat <<EOF
MSD installed from $home_dir ($(git rev-parse --short HEAD)).
MSD installed from $home_dir ($(git -C "$home_dir" describe --tags --always)).
Update any time by rerunning: curl -fsSL https://msd.golem15.com | bash
EOF
if [[ " ${runtimes[*]} " == *" --claude "* ]] && ! command -v claude >/dev/null 2>&1; then

View File

@@ -31,13 +31,14 @@ function parseRepoSlug(repository) {
}
/**
* Raw-file URL for CHANGELOG.md on the repo's default branch. GitHub serves raw
* files from a separate host; the golem15 Gitea forge serves them in-place.
* Raw-file URL for CHANGELOG.md on the release branch. GitHub serves raw files
* from a separate host; the golem15 Gitea forge serves them in-place from the
* `stable` branch, which always points at the latest tagged release.
*/
function changelogRawUrlFor(host, slug) {
if (!slug) return '';
if (host === 'github.com') return `https://raw.githubusercontent.com/${slug}/main/CHANGELOG.md`;
return `https://${host}/${slug}/raw/branch/next/CHANGELOG.md`;
return `https://${host}/${slug}/raw/branch/stable/CHANGELOG.md`;
}
/**
@@ -70,16 +71,18 @@ function deriveIdentity(pkg = {}) {
}
/**
* Pure: format the `npx` fallback install command. Shape matches the literal
* the update workflow embeds: `npx -y --package=<pkg>@latest -- <bin>
* Pure: format the manual install command. MSD is not published to npm; it is
* installed by the bootstrap script served at msd.golem15.com
* (scripts/bootstrap.sh on the `stable` branch). Shape matches the literal the
* update workflow embeds: `curl -fsSL https://msd.golem15.com | bash -s --
* [--<runtime>] --<scope>`. The runtime flag is omitted when not supplied.
*
* This function is the canonical source — `render()` serializes it verbatim
* into the generated module, so the runtime copy can never drift from it.
*/
function formatManualInstall({ packageName, binName, scope, runtime } = {}) {
function formatManualInstall({ scope, runtime } = {}) {
const runtimeFlag = runtime ? ` --${runtime}` : '';
return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`;
return `curl -fsSL https://msd.golem15.com | bash -s --${runtimeFlag} --${scope}`;
}
const GENERATED_HEADER =
@@ -107,7 +110,7 @@ function render(identity) {
`const updateCacheFileName = ${j(updateCacheFileName)};\n\n` +
`${formatManualInstall.toString()}\n\n` +
'function manualInstallCommand(opts = {}) {\n' +
' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' +
' return formatManualInstall({ scope: opts.scope, runtime: opts.runtime });\n' +
'}\n\n' +
'module.exports = Object.freeze({\n' +
' packageName,\n' +

View File

@@ -30,13 +30,13 @@ const fs = require('fs');
const path = require('path');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { NPM_VIEW_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
const { LOOKUP_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'msd-check-update-worker.js');
// #4091/2026-09-06 Windows CI incident: the worker's real `npm view` call
// (checkLatestVersion, msd-core/bin/check-latest-version.cjs) is bounded at
// NPM_VIEW_TIMEOUT_MS. This outer test harness timeout used to be hardcoded
// LOOKUP_TIMEOUT_MS. This outer test harness timeout used to be hardcoded
// to the SAME 15000ms, so a slow registry response raced two SIGKILLs at the
// exact same wall-clock instant: the worker's own inner npm-view timeout
// fires and it needs real time to catch that failure, build a degraded
@@ -124,7 +124,7 @@ describe('msd-check-update-worker.js: atomic cache publish (#4091)', () => {
MSD_PROJECT_VERSION_FILE: path.join(cacheDir, 'no-such-project', 'VERSION'),
MSD_GLOBAL_VERSION_FILE: path.join(cacheDir, 'no-such-global', 'VERSION'),
};
const r = runHookSeam(WORKER_PATH, [], { env, timeoutMs: NPM_VIEW_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });
const r = runHookSeam(WORKER_PATH, [], { env, timeoutMs: LOOKUP_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });
assert.equal(r.exitCode, 0, `worker must exit 0; stderr: ${r.stderr}`);
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
assert.equal(cache.installed, '0.0.0', 'worker record replaced the pre-existing one');
@@ -132,16 +132,16 @@ describe('msd-check-update-worker.js: atomic cache publish (#4091)', () => {
assert.deepEqual(residue, [], 'no temp stage files may remain after a successful publish');
});
test('outer worker-run timeout keeps real margin beyond the inner npm-view timeout (#4091 exact-tie race)', () => {
test('outer worker-run timeout keeps real margin beyond the inner lookup timeout (#4091 exact-tie race)', () => {
assert.ok(
WORKER_TEARDOWN_MARGIN_MS >= 5000,
'the outer test timeout must give the worker real margin beyond the inner npm-view ' +
'timeout (NPM_VIEW_TIMEOUT_MS) it wraps, or a slow registry response races the two ' +
'the outer test timeout must give the worker real margin beyond the inner lookup ' +
'timeout (LOOKUP_TIMEOUT_MS) it wraps, or a slow remote response races the two ' +
'SIGKILLs (see #4091/2026-09-06 Windows CI incident: exact-tie timeout killed the worker ' +
'before it could degrade gracefully)',
);
assert.ok(
NPM_VIEW_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS > NPM_VIEW_TIMEOUT_MS,
LOOKUP_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS > LOOKUP_TIMEOUT_MS,
'the combined outer timeout must strictly exceed the inner npm-view timeout it wraps',
);
});

View File

@@ -29,17 +29,17 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { scanFencedBlocks } = require('../msd-core/bin/lib/markdown-sectionizer.cjs');
const { NPM_VIEW_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
const { LOOKUP_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs');
/**
* NOT a margin above NPM_VIEW_TIMEOUT_MS (imported above for disclosure,
* NOT a margin above LOOKUP_TIMEOUT_MS (imported above for disclosure,
* per this batch's epic issue -- this file and check-latest-version.cjs
* once independently guessed the same 15000ms value, causing a Windows
* double-SIGKILL collision fixed in PR #4428). This specific scenario
* (missing compiled runtime library) degrades BEFORE reaching the wrapped
* `npm view` call at all -- see this test's own #3582 comment
* release-tag lookup at all -- see this test's own #3582 comment
* ("checkLatestVersion -> not ok" on the degrade, never invoked) -- so its
* bound has no margin relationship to NPM_VIEW_TIMEOUT_MS to preserve.
* bound has no margin relationship to LOOKUP_TIMEOUT_MS to preserve.
* Kept as its own independent, pre-existing value.
*/
const WORKER_DEGRADED_PATH_TIMEOUT_MS = 8000;
@@ -48,7 +48,7 @@ const WORKER_DEGRADED_PATH_TIMEOUT_MS = 8000;
* msd-update-banner.js only reads a small cache file from disk and prints
* JSON -- no subprocess/network work -- so this leaves generous headroom
* over its sub-second worst case even on a contended CI runner. NOT
* related to NPM_VIEW_TIMEOUT_MS or WORKER_DEGRADED_PATH_TIMEOUT_MS above
* related to LOOKUP_TIMEOUT_MS or WORKER_DEGRADED_PATH_TIMEOUT_MS above
* (a different hook script entirely).
*/
const UPDATE_BANNER_HOOK_TIMEOUT_MS = 10_000;
@@ -57,7 +57,7 @@ const UPDATE_BANNER_HOOK_TIMEOUT_MS = 10_000;
// doc comment above): confirms this file references the collision-prone
// constant from check-latest-version.cjs without deriving any of this
// file's own timeout values from it.
void NPM_VIEW_TIMEOUT_MS;
void LOOKUP_TIMEOUT_MS;
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'msd-check-update-worker.js');
const PROJECTION_PATH = path.join(
@@ -319,96 +319,124 @@ const assert = require('node:assert/strict');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME } = require(
const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME, REPO_URL } = require(
path.join(ROOT, 'msd-core', 'bin', 'check-latest-version.cjs'),
);
// checkLatestVersion is a pure-ish function: it spawns one fixed npm
// command, validates the output, and returns { ok, version | reason }.
// The package name is HARDCODED — not a free choice for the caller.
// Tests use a pluggable spawn so no real npm process is invoked.
// checkLatestVersion is a pure-ish function: it runs one fixed
// `git ls-remote --tags` against the baked repository URL, picks the highest
// release tag, and returns { ok, version | reason }. The repository is
// HARDCODED — not a free choice for the caller. Tests use a pluggable spawn
// so no real git process (or network) is involved.
// `git ls-remote --tags --refs` output: one `<sha>\t<ref>` line per tag.
const lsRemote = (...tags) => tags.map((t, i) => `${String(i).padStart(40, '0')}\trefs/tags/${t}`).join('\n') + '\n';
const okSpawn = (...tags) => () => ({ status: 0, stdout: lsRemote(...tags), stderr: '' });
describe('Bug #2992: deterministic latest-version check', () => {
test('PACKAGE_NAME is the constant @golem15/msd-core (no callers can override)', () => {
assert.equal(PACKAGE_NAME, '@golem15/msd-core');
});
test('REPO_URL is the baked repository the release tags are read from', () => {
assert.equal(REPO_URL, 'https://git.golem15.com/golem15/msd-core');
});
test('CHECK_REASON enum exposes the documented codes', () => {
assert.deepEqual(
Object.keys(CHECK_REASON).sort(),
['FAIL_INVALID_OUTPUT', 'FAIL_NPM_FAILED', 'OK'].sort(),
['FAIL_LOOKUP_FAILED', 'FAIL_NO_RELEASES', 'OK'].sort(),
);
});
test('returns { ok: true, version } when npm prints a valid semver', () => {
const fakeSpawn = () => ({ status: 0, stdout: '1.39.1\n', stderr: '' });
const r = checkLatestVersion({ spawn: fakeSpawn });
assert.deepEqual(r, { ok: true, version: '1.39.1', reason: CHECK_REASON.OK });
test('returns { ok: true, version } for the highest vX.Y.Z tag', () => {
const r = checkLatestVersion({ spawn: okSpawn('v1.39.1', 'v1.40.0', 'v1.9.9') });
assert.deepEqual(r, { ok: true, version: '1.40.0', reason: CHECK_REASON.OK });
});
});
describe('Bug #2992: error paths', () => {
const { checkLatestVersion, CHECK_REASON } = require(require('node:path').join(__dirname, '..', 'msd-core', 'bin', 'check-latest-version.cjs'));
test('FAIL_NPM_FAILED when npm exits non-zero (e.g. offline, 404)', () => {
test('FAIL_LOOKUP_FAILED when git exits non-zero (e.g. offline, repo moved)', () => {
const r = checkLatestVersion({
spawn: () => ({ status: 1, stdout: '', stderr: 'npm ERR! 404\n' }),
spawn: () => ({ status: 128, stdout: '', stderr: 'fatal: unable to access\n' }),
});
assert.equal(r.ok, false);
assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED);
assert.equal(r.detail, 'npm ERR! 404',
'detail should be the trimmed stderr when npm reports a real error');
assert.equal(r.reason, CHECK_REASON.FAIL_LOOKUP_FAILED);
assert.equal(r.detail, 'fatal: unable to access',
'detail should be the trimmed stderr when git reports a real error');
});
// #2993 CR: distinguish timeout from genuine npm failure in `detail`.
// #2993 CR: distinguish timeout from genuine failure in `detail`.
// spawnSync sets status=null and signal='SIGTERM' on timeout; stderr is
// typically empty. Without the signal-first branch, both shape as
// 'npm exited non-zero' and the operator cannot tell timeout from failure.
test('FAIL_NPM_FAILED detail names the signal when spawn times out', () => {
// 'git exited non-zero' and the operator cannot tell timeout from failure.
test('FAIL_LOOKUP_FAILED detail names the signal when spawn times out', () => {
const r = checkLatestVersion({
spawn: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '' }),
});
assert.equal(r.ok, false);
assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED);
assert.equal(r.detail, 'npm timed out (signal: SIGTERM)',
assert.equal(r.reason, CHECK_REASON.FAIL_LOOKUP_FAILED);
assert.equal(r.detail, 'git timed out (signal: SIGTERM)',
'detail should explicitly name the signal when status is null and signal is set');
});
test('FAIL_NPM_FAILED detail falls back to generic when neither stderr nor signal is present', () => {
test('FAIL_LOOKUP_FAILED detail falls back to generic when neither stderr nor signal is present', () => {
const r = checkLatestVersion({
spawn: () => ({ status: 1, stdout: '', stderr: '' }),
});
assert.equal(r.detail, 'npm exited non-zero');
assert.equal(r.detail, 'git exited non-zero');
});
test('FAIL_INVALID_OUTPUT when npm prints something that is not a semver', () => {
// E.g. if a future npm version changes the output format, or if the
// network returns an HTML error page captured as stdout.
const r = checkLatestVersion({
spawn: () => ({ status: 0, stdout: '<html>not a version</html>\n', stderr: '' }),
});
test('FAIL_NO_RELEASES when the remote has no release tags', () => {
const r = checkLatestVersion({ spawn: () => ({ status: 0, stdout: '', stderr: '' }) });
assert.equal(r.ok, false);
assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT);
assert.equal(r.reason, CHECK_REASON.FAIL_NO_RELEASES);
});
test('FAIL_INVALID_OUTPUT when stdout is empty', () => {
const r = checkLatestVersion({
spawn: () => ({ status: 0, stdout: '', stderr: '' }),
});
test('FAIL_NO_RELEASES when only non-release refs exist (junk tags are data, not versions)', () => {
const r = checkLatestVersion({ spawn: okSpawn('nightly', 'v1.2', '1.2.3', 'v1.2.3<script>') });
assert.equal(r.ok, false);
assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT);
assert.equal(r.reason, CHECK_REASON.FAIL_NO_RELEASES);
});
test('accepts pre-release semver (e.g. 1.40.0-rc.1)', () => {
const r = checkLatestVersion({
spawn: () => ({ status: 0, stdout: '1.40.0-rc.1\n', stderr: '' }),
});
assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK });
test('the latest channel skips prerelease tags', () => {
const r = checkLatestVersion({ spawn: okSpawn('v1.39.0', 'v1.40.0-rc.1') });
assert.deepEqual(r, { ok: true, version: '1.39.0', reason: CHECK_REASON.OK });
});
});
describe('Issue #815: --next dist-tag support', () => {
const { buildViewArgs, resolveTag, ALLOWED_TAGS } = require(
describe('pickLatestVersion / compareVersions: release tag ordering', () => {
const { pickLatestVersion, compareVersions } = require(
path.join(ROOT, 'msd-core', 'bin', 'check-latest-version.cjs'),
);
test('numeric, not lexical: 1.10.0 beats 1.9.0', () => {
assert.equal(pickLatestVersion(lsRemote('v1.9.0', 'v1.10.0')), '1.10.0');
});
test('only refs/tags/v* refs count (branches named like versions are ignored)', () => {
const out = `${'a'.repeat(40)}\trefs/heads/v9.9.9\n${'b'.repeat(40)}\trefs/tags/v1.0.0\n`;
assert.equal(pickLatestVersion(out), '1.0.0');
});
test('next channel: rc.10 beats rc.2 and the final release beats its prereleases', () => {
assert.equal(pickLatestVersion(lsRemote('v2.0.0-rc.2', 'v2.0.0-rc.10'), 'next'), '2.0.0-rc.10');
assert.equal(pickLatestVersion(lsRemote('v2.0.0-rc.10', 'v2.0.0'), 'next'), '2.0.0');
assert.equal(pickLatestVersion(lsRemote('v1.9.0', 'v2.0.0-rc.1'), 'next'), '2.0.0-rc.1');
});
test('compareVersions orders prereleases below their release', () => {
assert.equal(compareVersions('2.0.0', '2.0.0-rc.1'), 1);
assert.equal(compareVersions('2.0.0-rc.1', '2.0.0'), -1);
assert.equal(compareVersions('2.0.0-rc.1', '2.0.0-rc.1'), 0);
assert.equal(compareVersions('2.0.0-alpha', '2.0.0-beta'), -1);
});
});
describe('Issue #815: --next channel support', () => {
const { buildLsRemoteArgs, resolveTag, ALLOWED_TAGS } = require(
path.join(ROOT, 'msd-core', 'bin', 'check-latest-version.cjs'),
);
@@ -416,13 +444,8 @@ describe('Issue #815: --next dist-tag support', () => {
assert.deepEqual([...ALLOWED_TAGS].sort(), ['latest', 'next']);
});
test('buildViewArgs() defaults to the bare latest spec (byte-for-byte unchanged)', () => {
assert.deepEqual(buildViewArgs(), ['view', '@golem15/msd-core', 'version']);
assert.deepEqual(buildViewArgs('latest'), ['view', '@golem15/msd-core', 'version']);
});
test('buildViewArgs("next") targets the @next dist-tag', () => {
assert.deepEqual(buildViewArgs('next'), ['view', '@golem15/msd-core@next', 'version']);
test('buildLsRemoteArgs lists only tag refs of the baked repository', () => {
assert.deepEqual(buildLsRemoteArgs(), ['ls-remote', '--tags', '--refs', 'https://git.golem15.com/golem15/msd-core.git']);
});
test('resolveTag defaults to latest when no --tag flag', () => {
@@ -441,19 +464,15 @@ describe('Issue #815: --next dist-tag support', () => {
assert.throws(() => resolveTag(['--tag']), /invalid --tag ''/);
});
test('checkLatestVersion accepts an RC under the next tag', () => {
const r = checkLatestVersion({ tag: 'next', spawn: () => ({ status: 0, stdout: '1.4.0-rc.1\n', stderr: '' }) });
test('checkLatestVersion accepts an RC under the next channel', () => {
const r = checkLatestVersion({ tag: 'next', spawn: okSpawn('v1.3.1', 'v1.4.0-rc.1') });
assert.deepEqual(r, { ok: true, version: '1.4.0-rc.1', reason: CHECK_REASON.OK });
});
test('buildViewArgs rejects a tag outside the allowlist (exported-API guard)', () => {
assert.throws(() => buildViewArgs('nightly'), /invalid dist-tag 'nightly'/);
});
test('checkLatestVersion rejects an out-of-allowlist tag even with an injected spawn', () => {
test('checkLatestVersion rejects an out-of-allowlist channel even with an injected spawn', () => {
assert.throws(
() => checkLatestVersion({ tag: 'nightly', spawn: () => ({ status: 0, stdout: '9.9.9\n', stderr: '' }) }),
/invalid dist-tag 'nightly'/,
() => checkLatestVersion({ tag: 'nightly', spawn: okSpawn('v9.9.9') }),
/invalid channel 'nightly'/,
);
});

View File

@@ -173,11 +173,11 @@ describe('Issue #498: deriveIdentity (pure, package.json -> coordinates)', () =>
assert.equal(deriveIdentity(FAKE_PKG).repoUrl, 'https://github.com/acme/example-pkg');
});
test('a git.golem15.com repository yields in-place raw URLs on the next branch', () => {
test('a git.golem15.com repository yields in-place raw URLs on the stable branch', () => {
const id = deriveIdentity({ ...FAKE_PKG, repository: 'git+https://git.golem15.com/golem15/msd-core.git' });
assert.equal(id.repoSlug, 'golem15/msd-core');
assert.equal(id.repoUrl, 'https://git.golem15.com/golem15/msd-core');
assert.equal(id.changelogRawUrl, 'https://git.golem15.com/golem15/msd-core/raw/branch/next/CHANGELOG.md');
assert.equal(id.changelogRawUrl, 'https://git.golem15.com/golem15/msd-core/raw/branch/stable/CHANGELOG.md');
});
test('changelogRawUrl points at raw.githubusercontent main CHANGELOG', () => {
@@ -218,18 +218,18 @@ describe('Issue #498: slugifyPackageName (pure helper for cache filename)', () =
});
});
describe('Issue #498: formatManualInstall (the npx fallback command)', () => {
test('global scope, no runtime -> npx with --global only', () => {
describe('Issue #498: formatManualInstall (the bootstrap-installer fallback command)', () => {
test('global scope, no runtime -> bootstrap with --global only', () => {
assert.equal(
formatManualInstall({ packageName: '@scope/example-pkg', binName: 'example-pkg', scope: 'global' }),
'npx -y --package=@scope/example-pkg@latest -- example-pkg --global',
'curl -fsSL https://msd.golem15.com | bash -s -- --global',
);
});
test('local scope with runtime -> --<runtime> before --<scope>', () => {
assert.equal(
formatManualInstall({ packageName: '@scope/example-pkg', binName: 'example-pkg', scope: 'local', runtime: 'claude' }),
'npx -y --package=@scope/example-pkg@latest -- example-pkg --claude --local',
'curl -fsSL https://msd.golem15.com | bash -s -- --claude --local',
);
});
@@ -237,7 +237,7 @@ describe('Issue #498: formatManualInstall (the npx fallback command)', () => {
const id = deriveIdentity(require(path.join(ROOT, 'package.json')));
assert.equal(
formatManualInstall({ packageName: id.packageName, binName: id.binName, scope: 'global', runtime: 'claude' }),
'npx -y --package=@golem15/msd-core@latest -- msd-core --claude --global',
'curl -fsSL https://msd.golem15.com | bash -s -- --claude --global',
);
});
});
@@ -269,7 +269,7 @@ describe('Issue #498: generated runtime module (baked)', () => {
const id = require(GENERATED);
assert.equal(
id.manualInstallCommand({ scope: 'global', runtime: 'claude' }),
'npx -y --package=@golem15/msd-core@latest -- msd-core --claude --global',
'curl -fsSL https://msd.golem15.com | bash -s -- --claude --global',
);
});
});

View File

@@ -67,7 +67,7 @@ describe('#4153 regression: unresolved update targets stop before later workflow
assert.match(step, /INSTALL_SCOPE` is `UNKNOWN`, `TARGET_RUNTIME` is empty, or `MSD_DIR` is empty/);
assert.match(step, /rerun from a valid installed runtime/i);
assert.match(step, /Rerun from a valid installed runtime: `\/msd:update`\./);
assert.match(step, /npx -y --package=@golem15\/msd-core@latest -- msd-core --global/);
assert.match(step, /curl -fsSL https:\/\/msd\.golem15\.com \| bash -s -- --global/);
assert.match(step, /target runtime \(`claude`, `opencode`, `codex`, `antigravity`\)/);
assert.ok(exit > unresolved, 'unresolved target must exit before the next step');
@@ -88,7 +88,7 @@ describe('#4153 regression: unresolved update targets stop before later workflow
const mutationSpies = [
{ name: 'version check', text: src, needle: 'check-latest-version.cjs', after: end },
{ name: 'custom-file detection', text: src, needle: 'detect-custom-files --config-dir', after: end },
{ name: 'resolved installer', text: src, needle: 'npx -y --package=@golem15/msd-core@"$TAG" -- msd-core "$RUNTIME_FLAG"', after: end },
{ name: 'resolved installer', text: src, needle: 'bash -s -- --ref "v$LATEST_VERSION" "$RUNTIME_FLAG"', after: end },
{ name: 'update-cache removal', text: src, needle: 'rm -f "$HOME/.cache/msd/msd-update-check"', after: end },
{ name: 'restore apply', text: src, needle: 'restore-custom-files --config-dir "$MSD_DIR" --apply', after: end },
{ name: 'patch check', text: src, needle: 'check_local_patches', after: end },
@@ -194,17 +194,16 @@ test('issue #815: version check threads the tag through check-latest-version.cjs
assert.match(WF, /check-latest-version\.cjs"? --json --tag "\$TAG"/);
});
test('issue #815: install uses the selected tag, not a hardcoded @latest', () => {
const robust = WF.match(/npx -y --package=@golem15\/msd-core@"\$TAG" -- msd-core/g) || [];
test('issue #815: install pins the release the channel resolved, not a moving branch', () => {
const pinned = WF.match(/bash -s -- --ref "v\$LATEST_VERSION"/g) || [];
const runUpdateStart = WF.indexOf('<step name="run_update">');
const runUpdateEnd = WF.indexOf('</step>', runUpdateStart);
assert.ok(runUpdateStart >= 0 && runUpdateEnd > runUpdateStart, 'run_update step must exist');
const runUpdate = WF.slice(runUpdateStart, runUpdateEnd);
assert.ok(robust.length >= 2, `expected >=2 tag-parameterized npx invocations, found ${robust.length}`);
assert.doesNotMatch(runUpdate, /--package=@golem15\/msd-core@latest -- msd-core/,
'install lines must not hardcode @latest once --next exists');
assert.doesNotMatch(runUpdate, /--package=@golem15\/msd-core@(?:latest|next|beta|canary|rc) -- msd-core/,
'install lines must use the $TAG variable, never a hardcoded dist-tag literal');
assert.ok(pinned.length >= 2, `expected >=2 version-pinned installer invocations, found ${pinned.length}`);
assert.doesNotMatch(runUpdate, /--ref (?:stable|next|main)\b/,
'install lines must pin the checked $LATEST_VERSION tag, never a moving branch');
assert.doesNotMatch(runUpdate, /\bnpx\b/, 'MSD is not published to npm; install must not use npx');
});
test('issue #815: command documents --next/--rc and routes it to the update workflow', () => {
@@ -315,15 +314,15 @@ __t3130('bug #3130: update.md contains no bare npx invocations (cache-stale form
);
});
__t3130('bug #3130: update.md has exactly two robust resolved-install invocations', () => {
__t3130('bug #3130: update.md has exactly two resolved-install invocations (local + global)', () => {
const start = src3130.indexOf('<step name="run_update">');
const end = src3130.indexOf('</step>', start);
assert3130.ok(start >= 0 && end > start, 'run_update step must exist');
const robust = (src3130.slice(start, end).match(/npx -y --package=@golem15\/msd-core@\S+ -- msd-core/g) || []);
const robust = (src3130.slice(start, end).match(/curl -fsSL https:\/\/msd\.golem15\.com \| bash -s -- --ref "v\$LATEST_VERSION" "\$RUNTIME_FLAG" --(?:local|global)/g) || []);
assert3130.strictEqual(
robust.length,
2,
`Expected two resolved-install npx invocations in update.md, found ${robust.length}`,
`Expected two resolved-install bootstrap invocations (local + global) in update.md, found ${robust.length}`,
);
});
});