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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
64
CLAUDE.md
Normal 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`.
|
||||
22
README.md
22
README.md
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' +
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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'/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user