feat(bmad-module): reach installer parity on source parsing, channels, and clone cache
Close the remaining capability gaps where the skill's custom-module install was narrower than the canonical installer (tools/installer/modules/custom-module-manager.js + channel-resolver.js). The new-spec install path was already shared code and the legacy resolver a faithful port, so this targets only the independently-implemented source/channel/cache plumbing. - source.mjs parseSource: accept `owner/repo@ref`, and browser-style deep-path git URLs (tree/blob, GitLab `-/tree`, Gitea `src/branch`, `?path=`), extracting the embedded ref + repo subdirectory — so a module in a monorepo subfolder installs directly. URL-based parsing handles Azure DevOps `_git`, nested groups, and dotted repo names. - lib/cache.mjs: shared clone cache at ~/.bmad/cache/custom-modules/<host>/<owner>/<repo>/ with .bmad-source.json/.bmad-channel.json metadata, matching the installer (reuse on matching ref, fetch/refresh otherwise, keep stale copy on fetch failure). materializeSource copies the module root out of the cache into a throwaway temp tree so the cache is never mutated. - lib/channel-resolver.mjs: node:-only port of resolveChannel (stable/next/pinned); `stable` resolves the latest non-prerelease GitHub tag, falling back to next. semver-lite gains prerelease/compare/rcompare so it stays registry-free. - install.mjs/update.mjs: resolve channel+ref before clone; update re-resolves the channel the module was installed with. Tests + CI: - test/test-bmad-module-source.mjs: unit coverage for parseSource, semver-lite, and channel-resolver, at parity with the installer's test-parse-source-urls.js / test-installer-channels.js. - Wire the skill's unit test (test:skill-source) and its end-to-end integration test (test:skill) into npm test, npm run quality, and the quality.yaml CI job — the integration test was previously committed but only ever run by hand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34b2fb1c78
commit
b41c3b0a04
|
|
@ -109,6 +109,12 @@ jobs:
|
||||||
- name: Test agent compilation components
|
- name: Test agent compilation components
|
||||||
run: npm run test:install
|
run: npm run test:install
|
||||||
|
|
||||||
|
- name: Test bmad-module skill (source parsing + channels)
|
||||||
|
run: npm run test:skill-source
|
||||||
|
|
||||||
|
- name: Test bmad-module skill (end-to-end install/update/remove)
|
||||||
|
run: npm run test:skill
|
||||||
|
|
||||||
- name: Validate file references
|
- name: Validate file references
|
||||||
run: npm run validate:refs
|
run: npm run validate:refs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,15 @@
|
||||||
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
||||||
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
||||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
||||||
"quality": "npm run vendor:check && npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar",
|
"quality": "npm run vendor:check && npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:skill-source && npm run test:skill && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar",
|
||||||
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
||||||
"test": "npm run vendor:check && npm run test:refs && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
|
"test": "npm run vendor:check && npm run test:refs && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:channels && npm run test:skill-source && npm run test:skill && npm run lint && npm run lint:md && npm run format:check",
|
||||||
"test:channels": "node test/test-installer-channels.js",
|
"test:channels": "node test/test-installer-channels.js",
|
||||||
"test:ide-sync": "node test/test-ide-sync.js",
|
"test:ide-sync": "node test/test-ide-sync.js",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
"test:refs": "node test/test-file-refs-csv.js",
|
"test:refs": "node test/test-file-refs-csv.js",
|
||||||
|
"test:skill": "bash src/core-skills/bmad-module/tests/integration.test.sh",
|
||||||
|
"test:skill-source": "node test/test-bmad-module-source.mjs",
|
||||||
"test:urls": "node test/test-parse-source-urls.js",
|
"test:urls": "node test/test-parse-source-urls.js",
|
||||||
"validate:refs": "node tools/validate-file-refs.js --strict",
|
"validate:refs": "node tools/validate-file-refs.js --strict",
|
||||||
"validate:skills": "node tools/validate-skills.js --strict",
|
"validate:skills": "node tools/validate-skills.js --strict",
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ bmad-module remove <code> [--purge]
|
||||||
bmad-module list [--json]
|
bmad-module list [--json]
|
||||||
```
|
```
|
||||||
|
|
||||||
`<source>` accepts `owner/repo`, a full git URL, or a local path.
|
`<source>` accepts `owner/repo`, a full git URL (`https://…`, `git@…`, `ssh://`, `git://`), or a local path. A git source may carry an `@<tag-or-branch>` suffix and may be a browser-style deep link (`/tree|blob/<ref>[/<subdir>]`, GitLab `/-/tree/…`, Gitea `/src/branch/…`, or `?path=`); `parseSource` in `lib/source.mjs` extracts the embedded ref and a repo subdirectory, mirroring the installer's `custom-module-manager.js`.
|
||||||
|
|
||||||
## Behavior notes
|
## Behavior notes
|
||||||
|
|
||||||
|
- **Source resolution & caching.** Git sources are cloned into a shared cache at `~/.bmad/cache/custom-modules/<host>/<owner>/<repo>/` (with `.bmad-source.json` / `.bmad-channel.json` metadata), the same cache the full installer uses; a matching ref is reused, otherwise the clone is fetched/refreshed, and a fetch failure keeps the stale copy so installs work offline. The install then copies the module root (the subdir, if the URL named one) out of the cache into a throwaway temp tree to stage from — the cache is never mutated. Local sources are copied straight to the temp tree. See `lib/cache.mjs`.
|
||||||
|
- **Channels.** `lib/channel-resolver.mjs` (a `node:`-only port of the installer's `channel-resolver.js`) resolves `--channel`: `pinned` → an explicit `--ref`/`@ref`; `stable` → the latest non-prerelease GitHub release tag (falls back to `next` when there are no tags, the URL isn't GitHub, or the tags API is unreachable); `next` (the default for a bare git source) → the default branch. `update` re-resolves the channel the module was installed with.
|
||||||
- **Source of truth** for what was installed is `_bmad/_config/files-manifest.csv` (per-file hashes) and `_bmad/_config/skill-manifest.csv` (one row per shipped skill). `manifest.yaml` carries the source/version/sha tuple.
|
- **Source of truth** for what was installed is `_bmad/_config/files-manifest.csv` (per-file hashes) and `_bmad/_config/skill-manifest.csv` (one row per shipped skill). `manifest.yaml` carries the source/version/sha tuple.
|
||||||
- **`update`** refuses to overwrite locally-modified files (hash mismatch against the recorded hash). Move overrides into `_bmad/custom/<code>/` and retry.
|
- **`update`** refuses to overwrite locally-modified files (hash mismatch against the recorded hash). Move overrides into `_bmad/custom/<code>/` and retry.
|
||||||
- **`remove`** without `--purge` preserves `_bmad/custom/<code>/` so a re-install picks the customizations back up. `--purge` deletes them. Remove also prunes the module's skills from every configured IDE.
|
- **`remove`** without `--purge` preserves `_bmad/custom/<code>/` so a re-install picks the customizations back up. `--purge` deletes them. Remove also prunes the module's skills from every configured IDE.
|
||||||
|
|
@ -34,7 +36,8 @@ bmad-module list [--json]
|
||||||
The skill itself is a thin verb router (`SKILL.md`). `scripts/bmad-module.mjs` is a zero-import launcher that guards the import graph (a missing/corrupt runtime file becomes a documented exit code, not a raw stack trace); the verb dispatcher lives in `scripts/cli.mjs` and all filesystem work happens in the `lib/` modules. These carry **no registry dependencies** — important because the installer copies the skill into the IDE skills directories (e.g. `.claude/skills/bmad-module/`) without `node_modules` and never runs `npm install` there:
|
The skill itself is a thin verb router (`SKILL.md`). `scripts/bmad-module.mjs` is a zero-import launcher that guards the import graph (a missing/corrupt runtime file becomes a documented exit code, not a raw stack trace); the verb dispatcher lives in `scripts/cli.mjs` and all filesystem work happens in the `lib/` modules. These carry **no registry dependencies** — important because the installer copies the skill into the IDE skills directories (e.g. `.claude/skills/bmad-module/`) without `node_modules` and never runs `npm install` there:
|
||||||
|
|
||||||
- `manifest.yaml` is read/written with a **vendored copy of the real `yaml` library** (`lib/vendor/yaml.mjs`, regenerated by `lib/vendor/build-vendor.mjs`) so it stays byte-identical to BMAD core's writer.
|
- `manifest.yaml` is read/written with a **vendored copy of the real `yaml` library** (`lib/vendor/yaml.mjs`, regenerated by `lib/vendor/build-vendor.mjs`) so it stays byte-identical to BMAD core's writer.
|
||||||
- `semver` validity/range checks use a small `node:`-only helper (`lib/semver-lite.mjs`).
|
- `semver` validity/range checks **and** the version comparison the stable-channel resolver needs (`prerelease`, `compare`, `rcompare`) use a small `node:`-only helper (`lib/semver-lite.mjs`) instead of the `semver` package.
|
||||||
|
- The shared clone cache (`lib/cache.mjs`) and channel resolution (`lib/channel-resolver.mjs`) use only `node:child_process` / `node:https` so the skill needs no dependencies after distribution.
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
|
|
@ -43,3 +46,5 @@ See `SKILL.md` for the full table. The script's stderr always names the conditio
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Integration tests live in `tests/integration.test.sh` and run end-to-end on a fresh BMAD install. Fixtures for negative cases (collisions, path traversal, reserved codes) are under `tests/fixtures/`; legacy-format fixtures (strategy-1 module files, a reserved code, and the synthesize fallback) are under `tests/fixtures/examples/legacy/`.
|
Integration tests live in `tests/integration.test.sh` and run end-to-end on a fresh BMAD install. Fixtures for negative cases (collisions, path traversal, reserved codes) are under `tests/fixtures/`; legacy-format fixtures (strategy-1 module files, a reserved code, and the synthesize fallback) are under `tests/fixtures/examples/legacy/`.
|
||||||
|
|
||||||
|
The pure (no-network) install plumbing — `parseSource` URL/`@ref`/subdir parsing, `semver-lite`, and the `channel-resolver` helpers — is unit-tested in `test/test-bmad-module-source.mjs` (run via `npm run test:skill-source`, included in `npm test`), kept at parity with the installer's `test/test-parse-source-urls.js` and `test/test-installer-channels.js`.
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ If the verb is ambiguous (e.g. the user says "manage modules"), ASK which verb t
|
||||||
|
|
||||||
### Step 2 — Parse the args
|
### Step 2 — Parse the args
|
||||||
|
|
||||||
- **install:** the user supplies `<source>` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref <branch-tag-or-sha>`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>` (override a module config answer; repeatable), `--module <code>`, `--dry-run`. Use `--module <code>` only when a legacy marketplace.json repo defines more than one module: the script exits 20 listing the available codes, then re-run picking one. First-party legacy modules whose codes are reserved (`gds`, `bmm`, …) install on the legacy path; the same reserved code in a current-spec `plugin.json` is still rejected (exit 21).
|
- **install:** the user supplies `<source>` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. A source may carry an `@<tag-or-branch>` suffix (`owner/repo@v1.2.3`) and a git URL may be a browser-style deep link (`https://github.com/owner/repo/tree/<ref>/<subdir>`, GitLab `/-/tree/…`, Gitea `/src/branch/…`, or `?path=`): the script extracts the ref and a repo subdirectory automatically, so a module living in a monorepo subfolder installs directly. Optional flags: `--ref <branch-or-tag>`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>` (override a module config answer; repeatable), `--module <code>`, `--dry-run`. Channels: `pinned` clones an explicit `--ref`/`@ref`; `stable` resolves the latest non-prerelease GitHub release tag (falls back to the default branch when there are no tags / the host isn't GitHub / the tags API is unreachable); `next` (the default for a bare git source) tracks the default branch. Use `--module <code>` only when a legacy marketplace.json repo defines more than one module: the script exits 20 listing the available codes, then re-run picking one. First-party legacy modules whose codes are reserved (`gds`, `bmm`, …) install on the legacy path; the same reserved code in a current-spec `plugin.json` is still rejected (exit 21).
|
||||||
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>`.
|
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>`. Without overrides, update re-resolves the channel the module was installed with — a `stable` module moves to the latest release tag, a `pinned` module stays put unless `--ref` moves it, and a `next` module re-pulls the default branch.
|
||||||
- **remove:** the user supplies `<code>`. Use `--purge` only if they explicitly say "also remove customizations" or "purge".
|
- **remove:** the user supplies `<code>`. Use `--purge` only if they explicitly say "also remove customizations" or "purge".
|
||||||
- **list:** no args. Use `--json` if the user asks for machine-readable.
|
- **list:** no args. Use `--json` if the user asks for machine-readable.
|
||||||
|
|
||||||
|
|
@ -97,4 +97,8 @@ User: "What modules do I have installed?" → Run `… list`. No confirmation ne
|
||||||
|
|
||||||
User: "Update the devlog module to v0.5.0" → Confirm, then run `… update devlog --ref v0.5.0`.
|
User: "Update the devlog module to v0.5.0" → Confirm, then run `… update devlog --ref v0.5.0`.
|
||||||
|
|
||||||
|
User: "Install the studio module on the stable channel" → Confirm, then run `… install acme/acme-studio --channel stable` (resolves the latest release tag).
|
||||||
|
|
||||||
|
User: "Install the linter that lives in the tools/ folder of this monorepo" → Confirm, then run `… install https://github.com/acme/monorepo/tree/main/tools/linter` (ref + subdir are parsed from the URL).
|
||||||
|
|
||||||
User: "Remove the mdlint module and wipe its customizations too" → Confirm, then run `… remove mdlint --purge`.
|
User: "Remove the mdlint module and wipe its customizations too" → Confirm, then run `… remove mdlint --purge`.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
||||||
import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs';
|
import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import { parseSource, materializeSource } from './lib/source.mjs';
|
import { parseSource, materializeSource } from './lib/source.mjs';
|
||||||
|
import { resolveChannel } from './lib/channel-resolver.mjs';
|
||||||
import { readAndValidateManifest, validateManifestObject, hasBmadPluginJson } from './lib/plugin-json.mjs';
|
import { readAndValidateManifest, validateManifestObject, hasBmadPluginJson } from './lib/plugin-json.mjs';
|
||||||
import { resolveLegacyModule } from './lib/legacy-resolver.mjs';
|
import { resolveLegacyModule } from './lib/legacy-resolver.mjs';
|
||||||
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
|
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
|
||||||
|
|
@ -28,9 +29,11 @@ export async function runInstall(opts) {
|
||||||
}
|
}
|
||||||
await ensureConfigDir(bmadDir);
|
await ensureConfigDir(bmadDir);
|
||||||
|
|
||||||
// §2. Normalize + materialize source.
|
// §2. Normalize source, resolve the channel/ref to a concrete clone target,
|
||||||
|
// then materialize (clone into the shared cache and copy out a working tree).
|
||||||
const descriptor = parseSource(opts.source);
|
const descriptor = parseSource(opts.source);
|
||||||
const materialized = await materializeSource(descriptor, { ref: opts.ref || null });
|
const target = await resolveCloneTarget(descriptor, opts);
|
||||||
|
const materialized = await materializeSource(descriptor, { ref: target.ref });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// §3. Read + validate the manifest. New-spec modules carry a
|
// §3. Read + validate the manifest. New-spec modules carry a
|
||||||
|
|
@ -130,11 +133,14 @@ export async function runInstall(opts) {
|
||||||
|
|
||||||
// §7. Register in manifests.
|
// §7. Register in manifests.
|
||||||
await addModuleToManifest(bmadDir, code, {
|
await addModuleToManifest(bmadDir, code, {
|
||||||
version: manifest.bmad.moduleVersion || manifest.version,
|
// Git installs record the resolved channel version (tag for stable/pinned,
|
||||||
|
// 'main' for next) like the full installer; local installs keep the
|
||||||
|
// module's declared version since there is no clone ref.
|
||||||
|
version: descriptor.kind === 'git' ? target.version : manifest.bmad.moduleVersion || manifest.version,
|
||||||
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
|
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
|
||||||
sha: materialized.sha,
|
sha: materialized.sha,
|
||||||
ref: materialized.ref,
|
ref: materialized.ref,
|
||||||
channel: opts.channel || (opts.ref ? 'pinned' : descriptor.kind === 'git' ? 'next' : null),
|
channel: target.channel,
|
||||||
rawSource: descriptor.rawInput,
|
rawSource: descriptor.rawInput,
|
||||||
moduleName: manifest.name,
|
moduleName: manifest.name,
|
||||||
});
|
});
|
||||||
|
|
@ -189,6 +195,54 @@ export async function runInstall(opts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a parsed source + CLI flags into a concrete clone target:
|
||||||
|
// { ref, channel, version }
|
||||||
|
// ref — git ref to clone (null = default branch / local source)
|
||||||
|
// channel — manifest channel tag: 'stable' | 'pinned' | 'next' | null (local)
|
||||||
|
// version — manifest version string for git installs (tag for stable/pinned,
|
||||||
|
// 'main' for next); null for local (caller uses the module version).
|
||||||
|
//
|
||||||
|
// Mirrors the installer's channel semantics: an explicit --ref (or an @ref /
|
||||||
|
// /tree/<ref> parsed from the source) pins; --channel stable resolves the latest
|
||||||
|
// non-prerelease GitHub tag, falling back to next (with a warning) when there are
|
||||||
|
// no tags, the URL isn't a GitHub repo, or the tags API is unreachable.
|
||||||
|
export async function resolveCloneTarget(descriptor, opts) {
|
||||||
|
if (descriptor.kind !== 'git') {
|
||||||
|
return { ref: null, channel: null, version: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitRef = opts.ref ?? descriptor.ref ?? null;
|
||||||
|
let channel = opts.channel || (explicitRef ? 'pinned' : 'next');
|
||||||
|
|
||||||
|
if (channel === 'pinned') {
|
||||||
|
if (explicitRef) {
|
||||||
|
return { ref: explicitRef, channel: 'pinned', version: explicitRef };
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`[bmad-module] warning: --channel pinned needs a --ref; falling back to next.\n`);
|
||||||
|
channel = 'next';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'stable') {
|
||||||
|
try {
|
||||||
|
const r = await resolveChannel({ channel: 'stable', repoUrl: descriptor.url });
|
||||||
|
if (r.resolvedFallback) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[bmad-module] note: no stable release found for ${descriptor.displayName} (${r.reason}); tracking the default branch.\n`,
|
||||||
|
);
|
||||||
|
return { ref: null, channel: 'next', version: 'main' };
|
||||||
|
}
|
||||||
|
return { ref: r.ref, channel: 'stable', version: r.version };
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(`[bmad-module] warning: could not resolve stable channel (${e.message}); tracking the default branch.\n`);
|
||||||
|
return { ref: null, channel: 'next', version: 'main' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next
|
||||||
|
return { ref: null, channel: 'next', version: 'main' };
|
||||||
|
}
|
||||||
|
|
||||||
// Shared post-copy completion for install and update: install JS deps, generate
|
// Shared post-copy completion for install and update: install JS deps, generate
|
||||||
// the central config + agent roster, create declared working directories, and
|
// the central config + agent roster, create declared working directories, and
|
||||||
// rebuild the merged help catalog. Mirrors what the full installer does for a
|
// rebuild the merged help catalog. Mirrors what the full installer does for a
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||||
|
|
||||||
|
const execFileP = promisify(execFile);
|
||||||
|
const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
||||||
|
|
||||||
|
// Shared clone cache for community modules — mirrors
|
||||||
|
// tools/installer/modules/custom-module-manager.js (getCacheDir + cloneRepo) so
|
||||||
|
// a skill-driven install reuses the same on-disk cache the CLI installer
|
||||||
|
// maintains. node:-only (execFile, not execSync+fs-extra); npm deps are NOT
|
||||||
|
// installed here — the skill installs them in _bmad/<code>/ after the copy.
|
||||||
|
|
||||||
|
export function getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ref must be a tag/branch name git can take as a positional argument. Reject
|
||||||
|
// option-like values so a crafted `--upload-pack=…` ref can't reach git.
|
||||||
|
function assertSafeRef(ref) {
|
||||||
|
if (!/^[\w.+/][\w.\-+/]*$/.test(ref)) {
|
||||||
|
throw new BmadModuleError(EXIT.USAGE, `unsafe git ref: ${ref}`);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(p) {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonSafe(p) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(p, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function git(args, cwd) {
|
||||||
|
return execFileP('git', args, { cwd, env: GIT_ENV, timeout: 120_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revParseHead(cwd) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd });
|
||||||
|
return stdout.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultBranch(cwd) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileP('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd });
|
||||||
|
return stdout.trim().replace(/^origin\//, '') || 'main';
|
||||||
|
} catch {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the repo behind `descriptor` is cloned/refreshed in the shared cache at
|
||||||
|
// the requested `ref` (a tag/branch, or null for the default branch). Returns
|
||||||
|
// { repoDir, sha, ref }. Reuses an existing clone when its recorded version
|
||||||
|
// matches; re-clones on a version change; on a fetch failure against an existing
|
||||||
|
// clone, keeps the stale copy and warns (so installs work offline).
|
||||||
|
export async function ensureCachedRepo(descriptor, ref = null) {
|
||||||
|
if (descriptor.kind !== 'git') throw new BmadModuleError(EXIT.USAGE, `ensureCachedRepo requires a git source`);
|
||||||
|
if (!descriptor.cacheKey) throw new BmadModuleError(EXIT.USAGE, `git source has no cacheKey: ${descriptor.rawInput}`);
|
||||||
|
const effectiveRef = ref;
|
||||||
|
if (effectiveRef) assertSafeRef(effectiveRef);
|
||||||
|
|
||||||
|
const repoDir = path.join(getCacheDir(), ...descriptor.cacheKey.split('/'));
|
||||||
|
await fs.mkdir(path.dirname(repoDir), { recursive: true });
|
||||||
|
|
||||||
|
// Existing cache at a different version → re-clone from scratch.
|
||||||
|
if (await pathExists(repoDir)) {
|
||||||
|
const meta = await readJsonSafe(path.join(repoDir, '.bmad-source.json'));
|
||||||
|
const cachedVersion = meta?.version || null;
|
||||||
|
if (effectiveRef !== cachedVersion) {
|
||||||
|
await fs.rm(repoDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await pathExists(repoDir)) {
|
||||||
|
// Refresh the existing clone (same version as before).
|
||||||
|
try {
|
||||||
|
await git(['fetch', 'origin', '--depth', '1'], repoDir);
|
||||||
|
if (effectiveRef) {
|
||||||
|
await git(['fetch', '--depth', '1', 'origin', effectiveRef, '--no-tags'], repoDir);
|
||||||
|
await git(['checkout', '--quiet', 'FETCH_HEAD'], repoDir);
|
||||||
|
} else {
|
||||||
|
const branch = await defaultBranch(repoDir);
|
||||||
|
assertSafeRef(branch);
|
||||||
|
await git(['fetch', '--depth', '1', 'origin', branch], repoDir);
|
||||||
|
await git(['reset', '--hard', `origin/${branch}`], repoDir);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Remote unreachable — keep the cached copy so the install still works.
|
||||||
|
process.stderr.write(
|
||||||
|
`[bmad-module] warning: could not refresh ${descriptor.displayName} (${e.stderr || e.message}). Using cached copy.\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fresh clone.
|
||||||
|
const args = ['clone', '--depth', '1'];
|
||||||
|
if (effectiveRef) args.push('--branch', effectiveRef);
|
||||||
|
args.push(descriptor.url, repoDir);
|
||||||
|
try {
|
||||||
|
await git(args);
|
||||||
|
} catch (e) {
|
||||||
|
await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
const refSuffix = effectiveRef ? `@${effectiveRef}` : '';
|
||||||
|
throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed for ${descriptor.url}${refSuffix}: ${e.stderr || e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = await revParseHead(repoDir);
|
||||||
|
const branchForMeta = effectiveRef ? null : await defaultBranch(repoDir);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(repoDir, '.bmad-source.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
cloneUrl: descriptor.url,
|
||||||
|
cacheKey: descriptor.cacheKey,
|
||||||
|
displayName: descriptor.displayName,
|
||||||
|
version: effectiveRef || null,
|
||||||
|
rawInput: descriptor.rawInput,
|
||||||
|
sha,
|
||||||
|
clonedAt: now,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(repoDir, '.bmad-channel.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
channel: effectiveRef ? 'pinned' : 'next',
|
||||||
|
version: effectiveRef || branchForMeta || 'main',
|
||||||
|
sha,
|
||||||
|
writtenAt: now,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { repoDir, sha, ref: effectiveRef };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import https from 'node:https';
|
||||||
|
import { valid, prerelease, rcompare } from './semver-lite.mjs';
|
||||||
|
|
||||||
|
// Channel resolver for community modules — decides which ref of a module to
|
||||||
|
// clone when no explicit version is supplied:
|
||||||
|
// - stable: highest pure-semver git tag (excludes -alpha/-beta/-rc)
|
||||||
|
// - next: default-branch HEAD
|
||||||
|
// - pinned: an explicit user-supplied tag/branch
|
||||||
|
//
|
||||||
|
// node:-only port of tools/installer/modules/channel-resolver.js (which uses the
|
||||||
|
// `semver` package + node:https). Uses lib/semver-lite.mjs to stay registry-free
|
||||||
|
// — the skill ships without node_modules. Talks only to the GitHub tags API and
|
||||||
|
// does semver math; clone logic lives in source.mjs.
|
||||||
|
|
||||||
|
const GITHUB_API_BASE = 'https://api.github.com';
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
const USER_AGENT = 'bmad-method-installer';
|
||||||
|
|
||||||
|
// Per-process cache: 'owner/repo' => [{ tag, version }] sorted newest-first.
|
||||||
|
const tagCache = new Map();
|
||||||
|
|
||||||
|
// Parse a GitHub repo URL into { owner, repo }, or null for non-GitHub URLs.
|
||||||
|
export function parseGitHubRepo(url) {
|
||||||
|
if (!url || typeof url !== 'string') return null;
|
||||||
|
const trimmed = url
|
||||||
|
.trim()
|
||||||
|
.replace(/\.git$/, '')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/i);
|
||||||
|
if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
||||||
|
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/]+)$/i);
|
||||||
|
if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson(url, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
};
|
||||||
|
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get(url, { headers, timeout }, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => (body += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
const err = new Error(`GitHub API ${res.statusCode} for ${url}: ${body.slice(0, 200)}`);
|
||||||
|
err.statusCode = res.statusCode;
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body));
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse GitHub response: ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error(`GitHub API request timed out: ${url}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip a leading 'v' and return a valid non-prerelease semver, else null.
|
||||||
|
export function normalizeStableTag(tagName) {
|
||||||
|
if (typeof tagName !== 'string') return null;
|
||||||
|
const stripped = tagName.startsWith('v') ? tagName.slice(1) : tagName;
|
||||||
|
const v = valid(stripped);
|
||||||
|
if (!v) return null;
|
||||||
|
if (prerelease(v)) return null; // exclude prereleases
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch pure-semver tags (highest first) from a GitHub repo, cached per-process.
|
||||||
|
// Returns [{ tag, version }]: tag is the original ref (e.g. "v1.7.0"), version
|
||||||
|
// the cleaned semver ("1.7.0").
|
||||||
|
export async function fetchStableTags(owner, repo, { timeout } = {}) {
|
||||||
|
const cacheKey = `${owner}/${repo}`;
|
||||||
|
if (tagCache.has(cacheKey)) return tagCache.get(cacheKey);
|
||||||
|
|
||||||
|
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/tags?per_page=100`;
|
||||||
|
const raw = await fetchJson(url, { timeout });
|
||||||
|
if (!Array.isArray(raw)) throw new TypeError(`Unexpected response from ${url}`);
|
||||||
|
|
||||||
|
const stable = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const version = normalizeStableTag(entry?.name);
|
||||||
|
if (version) stable.push({ tag: entry.name, version });
|
||||||
|
}
|
||||||
|
stable.sort((a, b) => rcompare(a.version, b.version));
|
||||||
|
tagCache.set(cacheKey, stable);
|
||||||
|
return stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a channel into a git-clonable ref.
|
||||||
|
// ref: ref for `git clone --branch`, or null for default-branch HEAD (next)
|
||||||
|
// version: tag name for stable/pinned, 'main' for next
|
||||||
|
// Falls back to next-channel semantics with resolvedFallback=true when stable
|
||||||
|
// turns up no tags or the URL isn't a GitHub repo. Throws on a pinned channel
|
||||||
|
// with no pin, or on a GitHub API error during stable resolution.
|
||||||
|
export async function resolveChannel({ channel, pin, repoUrl, timeout }) {
|
||||||
|
if (channel === 'pinned') {
|
||||||
|
if (!pin) throw new Error('resolveChannel: pinned channel requires a pin value');
|
||||||
|
return { channel: 'pinned', ref: pin, version: pin, resolvedFallback: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'next') {
|
||||||
|
return { channel: 'next', ref: null, version: 'main', resolvedFallback: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'stable') {
|
||||||
|
const parsed = parseGitHubRepo(repoUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'not-a-github-url' };
|
||||||
|
}
|
||||||
|
const tags = await fetchStableTags(parsed.owner, parsed.repo, { timeout });
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'no-stable-tags' };
|
||||||
|
}
|
||||||
|
const top = tags[0];
|
||||||
|
return { channel: 'stable', ref: top.tag, version: top.tag, resolvedFallback: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`resolveChannel: unknown channel '${channel}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only: clear the per-process tag cache.
|
||||||
|
export function _clearTagCache() {
|
||||||
|
tagCache.clear();
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,71 @@ export function valid(version) {
|
||||||
return `${m[1]}.${m[2]}.${m[3]}${m[4] ? `-${m[4]}` : ''}`;
|
return `${m[1]}.${m[2]}.${m[3]}${m[4] ? `-${m[4]}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of `semver.prerelease()`: returns the array of dot-separated
|
||||||
|
* prerelease identifiers (numeric ones coerced to Number) for a valid version,
|
||||||
|
* or null when the version is invalid or has no prerelease. Used by the channel
|
||||||
|
* resolver to exclude `-alpha`/`-rc` tags from the stable channel.
|
||||||
|
*/
|
||||||
|
export function prerelease(version) {
|
||||||
|
if (typeof version !== 'string') return null;
|
||||||
|
const m = SEMVER_RE.exec(version.trim());
|
||||||
|
if (!m || !m[4]) return null;
|
||||||
|
return m[4].split('.').map((id) => (/^\d+$/.test(id) ? Number(id) : id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric compare of the three core fields, then prerelease precedence per
|
||||||
|
// SemVer §11. Returns -1, 0, or 1. Both inputs must be valid semver.
|
||||||
|
function compareValid(a, b) {
|
||||||
|
const ma = SEMVER_RE.exec(a);
|
||||||
|
const mb = SEMVER_RE.exec(b);
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const d = Number(ma[i]) - Number(mb[i]);
|
||||||
|
if (d !== 0) return d < 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
// A version WITH a prerelease has lower precedence than one without.
|
||||||
|
const pa = ma[4];
|
||||||
|
const pb = mb[4];
|
||||||
|
if (!pa && !pb) return 0;
|
||||||
|
if (!pa) return 1;
|
||||||
|
if (!pb) return -1;
|
||||||
|
const ia = pa.split('.');
|
||||||
|
const ib = pb.split('.');
|
||||||
|
const len = Math.min(ia.length, ib.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if (ia[i] === ib[i]) continue;
|
||||||
|
const na = /^\d+$/.test(ia[i]);
|
||||||
|
const nb = /^\d+$/.test(ib[i]);
|
||||||
|
if (na && nb) return Number(ia[i]) < Number(ib[i]) ? -1 : 1;
|
||||||
|
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
||||||
|
if (na) return -1;
|
||||||
|
if (nb) return 1;
|
||||||
|
return ia[i] < ib[i] ? -1 : 1;
|
||||||
|
}
|
||||||
|
// A larger set of prerelease fields wins when all preceding are equal.
|
||||||
|
return ia.length === ib.length ? 0 : ia.length < ib.length ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of `semver.compare()`: -1/0/1 for a<b / a==b / a>b. Returns null when
|
||||||
|
* either side is not valid semver (semver throws; callers here treat unknown as
|
||||||
|
* "don't reorder").
|
||||||
|
*/
|
||||||
|
export function compare(a, b) {
|
||||||
|
const va = valid(a);
|
||||||
|
const vb = valid(b);
|
||||||
|
if (va === null || vb === null) return null;
|
||||||
|
return compareValid(va, vb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of `semver.rcompare()`: reverse of compare(), for sorting newest-first.
|
||||||
|
*/
|
||||||
|
export function rcompare(a, b) {
|
||||||
|
const c = compare(a, b);
|
||||||
|
return c === null ? 0 : -c;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- range grammar -------------------------------------------------------
|
// ---- range grammar -------------------------------------------------------
|
||||||
// A "partial" is a 1–3 segment version where each segment may be a number or an
|
// A "partial" is a 1–3 segment version where each segment may be a number or an
|
||||||
// x-range wildcard (x/X/*), with optional prerelease/build on the full form.
|
// x-range wildcard (x/X/*), with optional prerelease/build on the full form.
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,236 @@
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { copyDir } from './fs-safe.mjs';
|
import { copyDir } from './fs-safe.mjs';
|
||||||
|
import { ensureCachedRepo } from './cache.mjs';
|
||||||
import { EXIT, BmadModuleError } from './exit.mjs';
|
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||||
|
|
||||||
const execFileP = promisify(execFile);
|
|
||||||
|
|
||||||
const GH_SHORT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/;
|
const GH_SHORT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/;
|
||||||
|
// A `@<tag-or-branch>` tail is ref-shaped (no `:` so we never eat the auth
|
||||||
|
// segment of `git@host:…`). Raw commit SHAs are not supported — `git clone
|
||||||
|
// --branch` can't take them; pass a tag/branch or check the SHA out manually.
|
||||||
|
const REF_TAIL_RE = /^[\w.\-+/]+$/;
|
||||||
|
|
||||||
// Normalize a `<source>` argument from the CLI into a descriptor:
|
// Normalize a `<source>` argument from the CLI into a descriptor:
|
||||||
// { kind: 'local' | 'git', path?, url?, displayName, rawInput }
|
// { kind: 'local' | 'git', path?, url?, subdir, ref, cacheKey, displayName, rawInput }
|
||||||
// Accepts:
|
// `ref` is a branch/tag extracted from an explicit `@<ref>` suffix or an
|
||||||
// - owner/repo → GitHub HTTPS
|
// embedded browser-URL path (`…/tree/<ref>`); `subdir` is a module location
|
||||||
// - https://… or git@… URL → as given
|
// inside the repo extracted from a deep-path / `?path=` URL. Accepts:
|
||||||
|
// - owner/repo[@ref] → GitHub HTTPS
|
||||||
|
// - https://…, http://…, git@…, ssh://, git:// → as given (ref/subdir parsed)
|
||||||
// - file://path → local
|
// - file://path → local
|
||||||
// - relative or absolute path → local (if it exists on disk)
|
// - relative or absolute path → local (if it exists on disk)
|
||||||
|
// Mirrors tools/installer/modules/custom-module-manager.js#parseSource, adapted
|
||||||
|
// to the skill's throw-on-invalid contract and node:-only deps; keeps the skill's
|
||||||
|
// `owner/repo` shorthand, which the installer (URL/marketplace-driven) lacks.
|
||||||
export function parseSource(input) {
|
export function parseSource(input) {
|
||||||
if (typeof input !== 'string' || !input.trim()) {
|
if (typeof input !== 'string' || !input.trim()) {
|
||||||
throw new BmadModuleError(EXIT.USAGE, `source is required`);
|
throw new BmadModuleError(EXIT.USAGE, `source is required`);
|
||||||
}
|
}
|
||||||
const raw = input.trim();
|
const rawInput = input.trim();
|
||||||
|
|
||||||
if (raw.startsWith('file://')) {
|
// Split off an optional @<ref> suffix, but only when the part before the `@`
|
||||||
const p = decodeURI(raw.slice('file://'.length));
|
// looks like a complete repo reference — so we don't disturb `git@host:…` or
|
||||||
return { kind: 'local', path: path.resolve(p), displayName: p, rawInput: raw };
|
// an `@` that's part of the path.
|
||||||
|
let body = rawInput;
|
||||||
|
let ref = null;
|
||||||
|
const lastAt = rawInput.lastIndexOf('@');
|
||||||
|
if (lastAt > 0) {
|
||||||
|
const candidate = rawInput.slice(lastAt + 1);
|
||||||
|
const before = rawInput.slice(0, lastAt);
|
||||||
|
if (REF_TAIL_RE.test(candidate) && !candidate.includes(':')) {
|
||||||
|
const beforeLooksLikeRepo =
|
||||||
|
before.startsWith('/') ||
|
||||||
|
before.startsWith('./') ||
|
||||||
|
before.startsWith('../') ||
|
||||||
|
before.startsWith('~') ||
|
||||||
|
before.startsWith('file://') ||
|
||||||
|
/^(?:https?|ssh|git):\/\//i.test(before) ||
|
||||||
|
/^git@[^:]+:.+/.test(before) ||
|
||||||
|
GH_SHORT_RE.test(before);
|
||||||
|
if (beforeLooksLikeRepo) {
|
||||||
|
ref = candidate;
|
||||||
|
body = before;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (body.startsWith('file://')) {
|
||||||
raw.startsWith('https://') ||
|
if (ref) throw new BmadModuleError(EXIT.USAGE, `local paths do not support @ref suffixes`);
|
||||||
raw.startsWith('http://') ||
|
const p = decodeURI(body.slice('file://'.length));
|
||||||
raw.startsWith('git@') ||
|
return localDescriptor(path.resolve(p), p, rawInput);
|
||||||
raw.startsWith('ssh://') ||
|
|
||||||
raw.startsWith('git://')
|
|
||||||
) {
|
|
||||||
return { kind: 'git', url: raw, displayName: raw, rawInput: raw };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GH_SHORT_RE.test(raw)) {
|
// Local path: starts with /, ./, ../, or ~.
|
||||||
const url = `https://github.com/${raw}`;
|
if (body.startsWith('/') || body.startsWith('./') || body.startsWith('../') || body.startsWith('~')) {
|
||||||
return { kind: 'git', url, displayName: raw, rawInput: raw };
|
if (ref) throw new BmadModuleError(EXIT.USAGE, `local paths do not support @ref suffixes`);
|
||||||
|
const expanded = body.startsWith('~') ? path.join(os.homedir(), body.slice(1)) : body;
|
||||||
|
return localDescriptor(path.resolve(expanded), body, rawInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treat anything else as a local path.
|
// SSH: git@host:owner/repo[.git]
|
||||||
return { kind: 'local', path: path.resolve(raw), displayName: raw, rawInput: raw };
|
const sshMatch = body.match(/^git@([^:]+):(.+?)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (sshMatch) {
|
||||||
|
const [, host, owner, repo] = sshMatch;
|
||||||
|
return {
|
||||||
|
kind: 'git',
|
||||||
|
url: body,
|
||||||
|
subdir: null,
|
||||||
|
ref,
|
||||||
|
cacheKey: `${host}/${owner}/${repo}`,
|
||||||
|
displayName: `${owner}/${repo}`,
|
||||||
|
rawInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP(S) / ssh:// / git:// URLs — parse with the URL API so any host, nested
|
||||||
|
// group, dotted repo name, or browse-link shape is handled host-agnostically.
|
||||||
|
if (/^(?:https?|ssh|git):\/\//i.test(body)) {
|
||||||
|
return parseUrlDescriptor(body, ref, rawInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner/repo shorthand → GitHub HTTPS.
|
||||||
|
if (GH_SHORT_RE.test(body)) {
|
||||||
|
return {
|
||||||
|
kind: 'git',
|
||||||
|
url: `https://github.com/${body}`,
|
||||||
|
subdir: null,
|
||||||
|
ref,
|
||||||
|
cacheKey: `github.com/${body}`,
|
||||||
|
displayName: body,
|
||||||
|
rawInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BmadModuleError(EXIT.USAGE, `not a valid module source (owner/repo, git URL, or local path): ${rawInput}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localDescriptor(absPath, displayName, rawInput) {
|
||||||
|
return { kind: 'local', path: absPath, subdir: null, ref: null, cacheKey: null, displayName, rawInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser-style deep paths that embed a ref (branch/tag/commit) and optional
|
||||||
|
// subdirectory, across hosts:
|
||||||
|
// GitHub /<repo>/tree|blob/<ref>[/<subdir>]
|
||||||
|
// GitLab /<repo>/-/tree|blob/<ref>[/<subdir>]
|
||||||
|
// Gitea /<repo>/src/[branch|commit|tag/]<ref>[/<subdir>]
|
||||||
|
// Group 1 = repo path prefix, 2 = ref, 3 = subdir (optional).
|
||||||
|
const DEEP_PATH_PATTERNS = [
|
||||||
|
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
|
||||||
|
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseUrlDescriptor(body, refFromSuffix, rawInput) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(body);
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
if (!url || !url.host) {
|
||||||
|
throw new BmadModuleError(EXIT.USAGE, `not a valid Git URL: ${rawInput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = url.host;
|
||||||
|
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
let subdir = null;
|
||||||
|
let urlRef = null;
|
||||||
|
|
||||||
|
for (const pattern of DEEP_PATH_PATTERNS) {
|
||||||
|
const m = repoPath.match(pattern);
|
||||||
|
if (m) {
|
||||||
|
repoPath = m[1];
|
||||||
|
if (m[2]) urlRef = m[2];
|
||||||
|
if (m[3]) {
|
||||||
|
const cleaned = m[3].replace(/\/+$/, '');
|
||||||
|
if (cleaned) subdir = cleaned;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some hosts use ?path=/subdir on browse links.
|
||||||
|
if (!subdir) {
|
||||||
|
const pathParam = url.searchParams.get('path');
|
||||||
|
if (pathParam) {
|
||||||
|
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
if (cleaned) subdir = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPathClean = repoPath.replace(/\.git$/i, '');
|
||||||
|
if (!repoPathClean) {
|
||||||
|
throw new BmadModuleError(EXIT.USAGE, `not a valid Git URL: ${rawInput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = repoPathClean.split('/').filter(Boolean);
|
||||||
|
const displayName = segments.length >= 2 ? `${segments.at(-2)}/${segments.at(-1)}` : segments.at(-1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'git',
|
||||||
|
url: `${url.protocol}//${host}/${repoPathClean}`,
|
||||||
|
subdir,
|
||||||
|
// Explicit @ref suffix wins over an embedded /tree/<ref> path segment.
|
||||||
|
ref: refFromSuffix || urlRef || null,
|
||||||
|
cacheKey: `${host}/${repoPathClean}`,
|
||||||
|
displayName,
|
||||||
|
rawInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files that should never be staged into _bmad/<code>/ from a source tree.
|
||||||
|
const STAGE_IGNORE = (rel) =>
|
||||||
|
rel === '.git' ||
|
||||||
|
rel.startsWith('.git/') ||
|
||||||
|
rel === 'node_modules' ||
|
||||||
|
rel.startsWith('node_modules/') ||
|
||||||
|
rel === '.bmad-source.json' ||
|
||||||
|
rel === '.bmad-channel.json';
|
||||||
|
|
||||||
// Resolve a parsed descriptor into a usable source directory on disk.
|
// Resolve a parsed descriptor into a usable source directory on disk.
|
||||||
// For local sources, copies into a fresh temp dir so installation can stage
|
|
||||||
// without touching the user's working tree. For git, shallow-clones into a
|
|
||||||
// temp dir (no shared cache for v1 — keeps install deterministic and avoids
|
|
||||||
// stale checkouts).
|
|
||||||
//
|
//
|
||||||
// Returns { dir, sha, ref, cleanup } where `sha` and `ref` are null for
|
// Always returns a throwaway temp working copy so the install pipeline can write
|
||||||
// local sources and `cleanup()` removes the temp dir.
|
// into it (e.g. synthesized module.yaml for legacy strategy 5) and stage from it
|
||||||
|
// without mutating the user's tree or the shared clone cache.
|
||||||
|
// - local: copies the directory into the temp working copy.
|
||||||
|
// - git: ensures the repo is in the shared cache (~/.bmad/cache/custom-modules),
|
||||||
|
// then copies the module root (the subdir if the source URL named one) out of
|
||||||
|
// the cache into the temp working copy.
|
||||||
|
//
|
||||||
|
// Returns { dir, sha, ref, cleanup } where `sha`/`ref` are null for local
|
||||||
|
// sources and `cleanup()` removes the temp working copy (never the cache).
|
||||||
export async function materializeSource(descriptor, opts = {}) {
|
export async function materializeSource(descriptor, opts = {}) {
|
||||||
const { ref = null } = opts;
|
|
||||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-'));
|
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-'));
|
||||||
|
const dir = path.join(tmpRoot, 'src');
|
||||||
|
const cleanup = () => fs.rm(tmpRoot, { recursive: true, force: true });
|
||||||
|
|
||||||
if (descriptor.kind === 'local') {
|
if (descriptor.kind === 'local') {
|
||||||
const srcStat = await fs.stat(descriptor.path).catch(() => null);
|
const srcStat = await fs.stat(descriptor.path).catch(() => null);
|
||||||
if (!srcStat || !srcStat.isDirectory()) {
|
if (!srcStat || !srcStat.isDirectory()) {
|
||||||
|
await cleanup();
|
||||||
throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`);
|
throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`);
|
||||||
}
|
}
|
||||||
const dir = path.join(tmpRoot, 'src');
|
await copyDir(descriptor.path, dir, STAGE_IGNORE);
|
||||||
await copyDir(
|
return { dir, sha: null, ref: null, cleanup };
|
||||||
descriptor.path,
|
|
||||||
dir,
|
|
||||||
(rel) => rel === '.git' || rel.startsWith('.git/') || rel === 'node_modules' || rel.startsWith('node_modules/'),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
dir,
|
|
||||||
sha: null,
|
|
||||||
ref: null,
|
|
||||||
cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// git
|
// git — explicit --ref/resolved channel wins over a ref parsed from the source.
|
||||||
const dir = path.join(tmpRoot, 'src');
|
const ref = opts.ref ?? descriptor.ref ?? null;
|
||||||
const args = ['clone', '--depth', '1'];
|
let cached;
|
||||||
if (ref) args.push('--branch', ref);
|
|
||||||
args.push(descriptor.url, dir);
|
|
||||||
try {
|
try {
|
||||||
await execFileP('git', args, { timeout: 120_000 });
|
cached = await ensureCachedRepo(descriptor, ref);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
await cleanup();
|
||||||
throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed: ${e.stderr || e.message}`);
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sha = null;
|
const moduleRoot = descriptor.subdir ? path.join(cached.repoDir, descriptor.subdir) : cached.repoDir;
|
||||||
try {
|
const rootStat = await fs.stat(moduleRoot).catch(() => null);
|
||||||
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: dir });
|
if (!rootStat || !rootStat.isDirectory()) {
|
||||||
sha = stdout.trim();
|
await cleanup();
|
||||||
} catch {
|
throw new BmadModuleError(EXIT.USAGE, `subdirectory "${descriptor.subdir}" not found in ${descriptor.displayName}`);
|
||||||
// sha unknown — non-fatal, manifest will show null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
await copyDir(moduleRoot, dir, STAGE_IGNORE);
|
||||||
dir,
|
return { dir, sha: cached.sha, ref: cached.ref, cleanup };
|
||||||
sha,
|
|
||||||
ref,
|
|
||||||
cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
readSkillCanonicalIdsForModule,
|
readSkillCanonicalIdsForModule,
|
||||||
} from './lib/manifest-ops.mjs';
|
} from './lib/manifest-ops.mjs';
|
||||||
import { distributeToIdes } from './lib/ide-sync.mjs';
|
import { distributeToIdes } from './lib/ide-sync.mjs';
|
||||||
import { finishModuleInstall } from './install.mjs';
|
import { finishModuleInstall, resolveCloneTarget } from './install.mjs';
|
||||||
|
|
||||||
// Update one installed module (or all when opts.all is true). v1 semantics:
|
// Update one installed module (or all when opts.all is true). v1 semantics:
|
||||||
// - Re-resolves the original source (or new --ref) and re-clones.
|
// - Re-resolves the original source (or new --ref) and re-clones.
|
||||||
|
|
@ -54,7 +54,14 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
|
||||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `module ${code} has no rawSource in manifest.yaml — cannot re-resolve`);
|
throw new BmadModuleError(EXIT.BAD_MANIFEST, `module ${code} has no rawSource in manifest.yaml — cannot re-resolve`);
|
||||||
}
|
}
|
||||||
const descriptor = parseSource(entry.rawSource);
|
const descriptor = parseSource(entry.rawSource);
|
||||||
const materialized = await materializeSource(descriptor, { ref: opts.ref || entry.ref || null });
|
// Re-resolve against the channel/ref the module was installed with, unless the
|
||||||
|
// CLI overrides either. A `stable` entry re-resolves to the latest tag; a
|
||||||
|
// pinned entry stays put unless `--ref` moves it.
|
||||||
|
const target = await resolveCloneTarget(descriptor, {
|
||||||
|
ref: opts.ref ?? entry.ref ?? null,
|
||||||
|
channel: opts.channel ?? entry.channel ?? null,
|
||||||
|
});
|
||||||
|
const materialized = await materializeSource(descriptor, { ref: target.ref });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// No-op fast path.
|
// No-op fast path.
|
||||||
|
|
@ -117,11 +124,11 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
|
||||||
await removeSkillManifestRows(bmadDir, code);
|
await removeSkillManifestRows(bmadDir, code);
|
||||||
await removeFilesManifestRows(bmadDir, code);
|
await removeFilesManifestRows(bmadDir, code);
|
||||||
await addModuleToManifest(bmadDir, code, {
|
await addModuleToManifest(bmadDir, code, {
|
||||||
version: manifest.bmad.moduleVersion || manifest.version,
|
version: descriptor.kind === 'git' ? target.version : manifest.bmad.moduleVersion || manifest.version,
|
||||||
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
|
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
|
||||||
sha: materialized.sha,
|
sha: materialized.sha,
|
||||||
ref: opts.ref || entry.ref,
|
ref: materialized.ref,
|
||||||
channel: opts.channel || (opts.ref ? 'pinned' : entry.channel || (descriptor.kind === 'git' ? 'next' : null)),
|
channel: target.channel,
|
||||||
rawSource: descriptor.rawInput,
|
rawSource: descriptor.rawInput,
|
||||||
moduleName: manifest.name,
|
moduleName: manifest.name,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* bmad-module skill — parseSource / semver-lite / channel-resolver unit tests.
|
||||||
|
*
|
||||||
|
* Covers the skill's pure (no-network, no-filesystem) install plumbing so the
|
||||||
|
* @ref / deep-path-URL / subdir parsing, the registry-free semver math, and the
|
||||||
|
* channel resolver stay at parity with the canonical installer
|
||||||
|
* (tools/installer/modules/custom-module-manager.js + channel-resolver.js, whose
|
||||||
|
* own behavior is pinned by test/test-parse-source-urls.js and
|
||||||
|
* test/test-installer-channels.js).
|
||||||
|
*
|
||||||
|
* Usage: node test/test-bmad-module-source.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseSource } from '../src/core-skills/bmad-module/scripts/lib/source.mjs';
|
||||||
|
import { valid, prerelease, compare, rcompare, validRange } from '../src/core-skills/bmad-module/scripts/lib/semver-lite.mjs';
|
||||||
|
import { parseGitHubRepo, normalizeStableTag } from '../src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs';
|
||||||
|
|
||||||
|
const colors = { reset: '[0m', green: '[32m', red: '[31m', cyan: '[36m', dim: '[2m' };
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function assert(cond, name, detail = '') {
|
||||||
|
if (cond) {
|
||||||
|
console.log(`${colors.green}✓${colors.reset} ${name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`${colors.red}✗${colors.reset} ${name}`);
|
||||||
|
if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const eq = (got, want, name) =>
|
||||||
|
assert(JSON.stringify(got) === JSON.stringify(want), name, `got ${JSON.stringify(got)} want ${JSON.stringify(want)}`);
|
||||||
|
function throws(fn, name) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
assert(false, name, 'expected a throw');
|
||||||
|
} catch {
|
||||||
|
assert(true, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── parseSource ────────────────────────────────────────────────────────────
|
||||||
|
console.log(`\n${colors.cyan}parseSource${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = parseSource('owner/repo');
|
||||||
|
eq(
|
||||||
|
[r.kind, r.url, r.ref, r.subdir, r.cacheKey],
|
||||||
|
['git', 'https://github.com/owner/repo', null, null, 'github.com/owner/repo'],
|
||||||
|
'owner/repo shorthand → GitHub HTTPS',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('acme/devlog@v1.2.3');
|
||||||
|
eq([r.url, r.ref], ['https://github.com/acme/devlog', 'v1.2.3'], 'owner/repo@ref strips suffix');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://github.com/owner/repo/tree/main/pkg/foo');
|
||||||
|
eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', 'pkg/foo'], 'GitHub /tree/<ref>/<subdir>');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://github.com/owner/repo/tree/main');
|
||||||
|
eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', null], 'GitHub /tree/<ref> without subdir strips ref');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://github.com/owner/repo/blob/v2.0.0/src');
|
||||||
|
eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'v2.0.0', 'src'], 'GitHub /blob/<ref>/<subdir>');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main/src/module');
|
||||||
|
eq([r.url, r.ref, r.subdir], ['https://gitlab.com/group/subgroup/repo', 'main', 'src/module'], 'GitLab nested-group /-/tree');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://gitea.example.com/owner/repo/src/branch/main');
|
||||||
|
eq([r.url, r.subdir], ['https://gitea.example.com/owner/repo', null], 'Gitea /src/branch/<ref> without subdir strips ref');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module?path=/src/skills');
|
||||||
|
eq(
|
||||||
|
[r.url, r.subdir, r.cacheKey],
|
||||||
|
['https://dev.azure.com/myorg/MyProject/_git/my-module', 'src/skills', 'dev.azure.com/myorg/MyProject/_git/my-module'],
|
||||||
|
'Azure DevOps ?path= + full-path cacheKey',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git');
|
||||||
|
eq(r.url, 'https://dev.azure.com/myorg/MyProject/_git/my-module', 'trailing .git stripped from cloneUrl');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('git@github.com:owner/repo.git');
|
||||||
|
eq(
|
||||||
|
[r.kind, r.url, r.cacheKey, r.displayName],
|
||||||
|
['git', 'git@github.com:owner/repo.git', 'github.com/owner/repo', 'owner/repo'],
|
||||||
|
'SSH URL preserved, @ not consumed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://git.example.com/owner/my.repo.name');
|
||||||
|
eq([r.url, r.displayName], ['https://git.example.com/owner/my.repo.name', 'owner/my.repo.name'], 'dotted repo name preserved');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const r = parseSource('https://git.example.com/myorg/MyProject/_git/my-module');
|
||||||
|
eq(
|
||||||
|
[r.cacheKey, r.displayName],
|
||||||
|
['git.example.com/myorg/MyProject/_git/my-module', '_git/my-module'],
|
||||||
|
'nested path cacheKey + last-two-segment displayName',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throws(() => parseSource('./local@v1'), 'local path + @ref throws');
|
||||||
|
throws(() => parseSource(' '), 'empty source throws');
|
||||||
|
throws(() => parseSource('not a source'), 'garbage source throws');
|
||||||
|
|
||||||
|
// ─── semver-lite ──────────────────────────────────────────────────────────────
|
||||||
|
console.log(`\n${colors.cyan}semver-lite${colors.reset}\n`);
|
||||||
|
|
||||||
|
eq(valid('v1.2.3'), '1.2.3', 'valid() strips leading v');
|
||||||
|
eq(valid('1.2'), null, 'valid() rejects partial');
|
||||||
|
eq(prerelease('1.0.0-rc.1'), ['rc', 1], 'prerelease() parses identifiers');
|
||||||
|
eq(prerelease('1.0.0'), null, 'prerelease() null for release');
|
||||||
|
eq(compare('1.0.0-alpha', '1.0.0'), -1, 'prerelease < release');
|
||||||
|
eq(compare('1.0.0-alpha.1', '1.0.0-alpha.beta'), -1, 'numeric id < alphanumeric id');
|
||||||
|
eq(compare('1.2.0', '1.10.0'), -1, 'numeric (not lexical) field compare');
|
||||||
|
eq(compare('2.0.0', '2.0.0'), 0, 'equal versions');
|
||||||
|
eq(compare('bad', '1.0.0'), null, 'compare() null on invalid');
|
||||||
|
{
|
||||||
|
const tags = [
|
||||||
|
{ tag: 'v1.0.0', version: '1.0.0' },
|
||||||
|
{ tag: 'v1.7.0', version: '1.7.0' },
|
||||||
|
{ tag: 'v1.2.0', version: '1.2.0' },
|
||||||
|
];
|
||||||
|
tags.sort((a, b) => rcompare(a.version, b.version));
|
||||||
|
eq(
|
||||||
|
tags.map((t) => t.tag),
|
||||||
|
['v1.7.0', 'v1.2.0', 'v1.0.0'],
|
||||||
|
'rcompare() sorts newest-first',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(validRange('>=6.0.0') !== null, 'validRange() accepts a real range');
|
||||||
|
|
||||||
|
// ─── channel-resolver (pure helpers) ──────────────────────────────────────────
|
||||||
|
console.log(`\n${colors.cyan}channel-resolver${colors.reset}\n`);
|
||||||
|
|
||||||
|
eq(parseGitHubRepo('https://github.com/o/r/tree/main'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from deep URL');
|
||||||
|
eq(parseGitHubRepo('git@github.com:o/r'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from SSH');
|
||||||
|
eq(parseGitHubRepo('https://gitlab.com/o/r'), null, 'parseGitHubRepo null for non-GitHub');
|
||||||
|
eq(
|
||||||
|
[normalizeStableTag('v1.7.0'), normalizeStableTag('1.0.0-rc.1'), normalizeStableTag('nope')],
|
||||||
|
['1.7.0', null, null],
|
||||||
|
'normalizeStableTag excludes prereleases/invalid',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Loading…
Reference in New Issue