mandrel 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +74 -32
- package/.agents/docs/SDLC.md +18 -12
- package/.agents/docs/configuration.md +61 -4
- package/.agents/docs/quality-gates.md +796 -0
- package/.agents/docs/workflows.md +3 -4
- package/.agents/runtime-deps.json +2 -2
- package/.agents/scripts/README.md +1 -1
- package/.agents/scripts/agents-bootstrap-github.js +23 -119
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
- package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
- package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/detect-package-manager.js +72 -0
- package/.agents/scripts/lib/errors/index.js +4 -4
- package/.agents/scripts/lib/label-taxonomy.js +2 -2
- package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
- package/.agents/scripts/lib/onboard/init-tail.js +218 -0
- package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
- package/.agents/workflows/agents-update.md +14 -29
- package/.agents/workflows/deliver.md +87 -26
- package/.agents/workflows/helpers/agents-sync-config.md +3 -2
- package/.agents/workflows/helpers/deliver-epic.md +12 -5
- package/.agents/workflows/helpers/deliver-stories.md +13 -7
- package/.agents/workflows/plan.md +48 -4
- package/README.md +18 -30
- package/bin/mandrel.js +235 -16
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/doctor.js +45 -3
- package/lib/cli/init.js +66 -7
- package/lib/cli/registry.js +42 -146
- package/lib/cli/sync.js +122 -23
- package/lib/cli/uninstall.js +42 -7
- package/lib/cli/update.js +257 -198
- package/lib/cli/version-helpers.js +59 -0
- package/package.json +6 -6
- package/.agents/workflows/onboard.md +0 -208
- package/lib/cli/__tests__/migrate.test.js +0 -268
- package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
- package/lib/cli/__tests__/sync.test.js +0 -372
- package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
- package/lib/cli/__tests__/update-major.test.js +0 -217
- package/lib/cli/__tests__/update-reexec.test.js +0 -513
- package/lib/cli/__tests__/update.test.js +0 -696
- package/lib/cli/__tests__/version-check.test.js +0 -398
- package/lib/migrations/__tests__/index.test.js +0 -216
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// lib/cli/version-helpers.js
|
|
2
|
+
/**
|
|
3
|
+
* Shared semver-ish parse and compare helpers used by both
|
|
4
|
+
* `lib/cli/update.js` and `lib/cli/registry.js`.
|
|
5
|
+
*
|
|
6
|
+
* Both files previously defined local copies of `parseVersion` and
|
|
7
|
+
* `compareVersions`; this module is the single authoritative
|
|
8
|
+
* implementation (Story #4048 B3 — multiplied helpers).
|
|
9
|
+
*
|
|
10
|
+
* Builtins only — this module is imported from both the CLI surface
|
|
11
|
+
* (`lib/cli/`) and the doctor registry which runs before third-party
|
|
12
|
+
* packages are guaranteed to be present.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
|
|
17
|
+
* missing segments coerce to 0 so a partial version still compares sanely.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} version
|
|
20
|
+
* @returns {[number, number, number]}
|
|
21
|
+
*/
|
|
22
|
+
export function parseVersion(version) {
|
|
23
|
+
const [major, minor, patch] = String(version).split('.');
|
|
24
|
+
return [
|
|
25
|
+
Number.parseInt(major, 10) || 0,
|
|
26
|
+
Number.parseInt(minor, 10) || 0,
|
|
27
|
+
Number.parseInt(patch, 10) || 0,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compare two version strings. Negative when `a < b`, zero when equal,
|
|
33
|
+
* positive when `a > b` (the standard `Array.sort` comparator contract).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} a
|
|
36
|
+
* @param {string} b
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function compareVersions(a, b) {
|
|
40
|
+
const pa = parseVersion(a);
|
|
41
|
+
const pb = parseVersion(b);
|
|
42
|
+
for (let i = 0; i < 3; i += 1) {
|
|
43
|
+
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when `target`'s major axis is strictly greater than `current`'s —
|
|
50
|
+
* the gated "crosses a major boundary" condition used by the update
|
|
51
|
+
* orchestrator.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} current
|
|
54
|
+
* @param {string} target
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function crossesMajor(current, target) {
|
|
58
|
+
return parseVersion(target)[0] > parseVersion(current)[0];
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.62.0",
|
|
4
4
|
"description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
|
|
5
|
-
"main": "index.js",
|
|
6
5
|
"files": [
|
|
7
6
|
".agents/",
|
|
8
7
|
"bin/",
|
|
9
8
|
"docs/CHANGELOG.md",
|
|
10
|
-
"lib/"
|
|
9
|
+
"lib/",
|
|
10
|
+
"!lib/**/__tests__"
|
|
11
11
|
],
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public",
|
|
@@ -84,7 +84,8 @@
|
|
|
84
84
|
"knip": "^6.14.0",
|
|
85
85
|
"lint-staged": "^17.0.4",
|
|
86
86
|
"markdownlint-cli2": "^0.18.1",
|
|
87
|
-
"memfs": "^4.57.2"
|
|
87
|
+
"memfs": "^4.57.2",
|
|
88
|
+
"typescript": ">=5.0.0"
|
|
88
89
|
},
|
|
89
90
|
"dependencies": {
|
|
90
91
|
"ajv": "^8.20.0",
|
|
@@ -93,7 +94,6 @@
|
|
|
93
94
|
"minimatch": "^10.0.0",
|
|
94
95
|
"picomatch": "^4.0.4",
|
|
95
96
|
"string-argv": "^0.3.2",
|
|
96
|
-
"typescript": ">=5.0.0",
|
|
97
97
|
"typhonjs-escomplex": "^0.1.0"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
},
|
|
102
102
|
"peerDependenciesMeta": {
|
|
103
103
|
"typescript": {
|
|
104
|
-
"optional":
|
|
104
|
+
"optional": true
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: >-
|
|
3
|
-
Guided first-run onboarding for a freshly installed Mandrel. Detects the
|
|
4
|
-
consumer stack, offers to scaffold any missing docsContextFiles, runs
|
|
5
|
-
`mandrel doctor` as a readiness gate, and hands off to a started /plan.
|
|
6
|
-
The whole path is designed to take about 15 minutes from a clean checkout to
|
|
7
|
-
a planned Epic.
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
# /onboard
|
|
11
|
-
|
|
12
|
-
## Role
|
|
13
|
-
|
|
14
|
-
Onboarding guide. You walk a first-time operator from a freshly installed
|
|
15
|
-
Mandrel to their first planned Epic, composing the building blocks shipped by
|
|
16
|
-
the guided-onboard Feature (#3514): stack detection, docs scaffolding, the
|
|
17
|
-
`mandrel doctor` readiness check, and a started `/plan` handoff.
|
|
18
|
-
|
|
19
|
-
## Overview
|
|
20
|
-
|
|
21
|
-
`/onboard` is the **guided first-successful-run path**. It sequences four
|
|
22
|
-
phases that each lean on an already-shipped, independently tested building
|
|
23
|
-
block, then hands the operator off to planning:
|
|
24
|
-
|
|
25
|
-
```text
|
|
26
|
-
/onboard
|
|
27
|
-
→ Phase 1 — Detect stack (lib/onboard/detect-stack.js#detectStack)
|
|
28
|
-
→ Phase 2 — Offer docs scaffolding (lib/onboard/scaffold-docs.js#scaffoldDocs)
|
|
29
|
-
→ Phase 3 — Readiness gate (mandrel doctor → lib/cli/doctor.js)
|
|
30
|
-
→ Phase 4 — Handoff to /plan (started, not auto-run)
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Each phase is **advisory and resumable**: re-running `/onboard` on an
|
|
34
|
-
already-onboarded project re-detects, re-checks, and offers the same handoff
|
|
35
|
-
without duplicating any scaffolding (the scaffolder only writes files that are
|
|
36
|
-
genuinely missing, and `mandrel doctor` is read-only).
|
|
37
|
-
|
|
38
|
-
### When to use `/onboard`
|
|
39
|
-
|
|
40
|
-
| Scenario | Command |
|
|
41
|
-
| --- | --- |
|
|
42
|
-
| First run after installing Mandrel into a project | `/onboard` |
|
|
43
|
-
| Plan a new Epic once onboarded | `/plan <epicId>` or `/plan --idea "<seed>"` |
|
|
44
|
-
| Deliver Epic-attached Stories | `/deliver <epicId>` |
|
|
45
|
-
|
|
46
|
-
## Prerequisites
|
|
47
|
-
|
|
48
|
-
1. Mandrel installed and bootstrapped into the project. The zero-to-installed
|
|
49
|
-
path is `npx mandrel init`, which installs the `mandrel`
|
|
50
|
-
package (when absent), runs `mandrel sync` to materialize the `.agents/`
|
|
51
|
-
bundle, and then — on the **configure now** prompt option — execs
|
|
52
|
-
`.agents/scripts/bootstrap.js` to provision the project (labels,
|
|
53
|
-
board, `.agentrc.json` seed). `/onboard` runs **after** that bootstrap
|
|
54
|
-
completes — it does not invoke `bootstrap.js` itself, so the coupling is
|
|
55
|
-
indirect: bootstrap owns first-time provisioning, `/onboard` owns the
|
|
56
|
-
guided first-successful-run. By the time you reach `/onboard`, the
|
|
57
|
-
`.agents/` bundle is present and `mandrel` resolves on the `PATH`.
|
|
58
|
-
2. `GITHUB_TOKEN` available in the project's `.env` (Phase 3 checks this; the
|
|
59
|
-
token value is never echoed).
|
|
60
|
-
|
|
61
|
-
## The ~15-minute first-successful-run path
|
|
62
|
-
|
|
63
|
-
`/onboard` is tuned so a brand-new operator can go from a clean checkout to a
|
|
64
|
-
planned Epic in roughly **15 minutes**. The budget breaks down as:
|
|
65
|
-
|
|
66
|
-
| Step | Phase | Rough budget |
|
|
67
|
-
| --- | --- | --- |
|
|
68
|
-
| Detect the stack and confirm the report | Phase 1 | ~1 min |
|
|
69
|
-
| Review the missing-docs offer and accept the scaffold | Phase 2 | ~3 min |
|
|
70
|
-
| Run `mandrel doctor` and clear any ✘ checks | Phase 3 | ~5 min |
|
|
71
|
-
| Start `/plan` and describe the first Epic idea | Phase 4 | ~6 min |
|
|
72
|
-
|
|
73
|
-
If any single phase blows its budget — most often a Phase 3 remedy such as
|
|
74
|
-
authenticating `gh` or installing runtime deps — clear that one check and
|
|
75
|
-
re-run `/onboard`; the earlier phases are cheap and idempotent, so re-running
|
|
76
|
-
costs seconds.
|
|
77
|
-
|
|
78
|
-
### Sample-repo pointer
|
|
79
|
-
|
|
80
|
-
If you do not have a project to onboard yet and just want to see the path
|
|
81
|
-
end-to-end, point `/onboard` at the **stack-detection sample-repo fixture**
|
|
82
|
-
that ships with the framework. `detectStack` is fixture-driven by design (its
|
|
83
|
-
filesystem facade reads a real directory), and the unit suite exercises it
|
|
84
|
-
against an on-disk sample repo — see
|
|
85
|
-
[`tests/onboard/detect-stack.test.js`](../../tests/onboard/detect-stack.test.js),
|
|
86
|
-
which builds a sample repo with a lockfile, a `package.json`, and source files
|
|
87
|
-
and asserts the detected package manager, test runner, and primary language.
|
|
88
|
-
Use that fixture (or any small throwaway repo with a `package.json` and a few
|
|
89
|
-
source files) as the target for a dry first run before onboarding a real
|
|
90
|
-
project.
|
|
91
|
-
|
|
92
|
-
## Phase 1 — Detect the stack
|
|
93
|
-
|
|
94
|
-
Inspect the consumer repository root and report what Mandrel inferred before
|
|
95
|
-
touching anything. Use the detection helper shipped by Story #3520:
|
|
96
|
-
|
|
97
|
-
```bash
|
|
98
|
-
node -e "import('./.agents/scripts/lib/onboard/detect-stack.js').then(m => console.log(JSON.stringify(m.detectStack(process.cwd()), null, 2)))"
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
`detectStack(root)` returns `{ packageManager, testRunner, primaryLanguage }`,
|
|
102
|
-
each inferred from on-disk signals (lockfiles, `package.json`, source-file
|
|
103
|
-
extensions) and `null` when no signal is found. Relay the report to the
|
|
104
|
-
operator so they can confirm Mandrel understood the project. Detection is
|
|
105
|
-
**read-only** — it never writes to disk — so a wrong guess is harmless and the
|
|
106
|
-
operator can simply correct course in their `.agentrc.json` later.
|
|
107
|
-
|
|
108
|
-
## Phase 2 — Offer to scaffold missing `docsContextFiles`
|
|
109
|
-
|
|
110
|
-
Mandrel agents perform a **mandatory read** of every file listed in
|
|
111
|
-
`project.docsContextFiles` before each task; a missing entry degrades every
|
|
112
|
-
downstream run. Detect which are absent and offer to scaffold stubs, using the
|
|
113
|
-
helper shipped by Story #3519:
|
|
114
|
-
|
|
115
|
-
1. **Preview (no writes).** Detect the missing set first:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
node -e "import('./.agents/scripts/lib/onboard/scaffold-docs.js').then(m => console.log(JSON.stringify(m.scaffoldDocs({ write: false }), null, 2)))"
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
`scaffoldDocs({ write: false })` returns `{ docsRoot, docsContextFiles,
|
|
122
|
-
missing, present, created }` without creating any files.
|
|
123
|
-
|
|
124
|
-
2. **Offer.** Show the operator the `missing` list and ask whether to scaffold
|
|
125
|
-
stubs. If the list is empty, report "all docsContextFiles present" and skip
|
|
126
|
-
to Phase 3.
|
|
127
|
-
|
|
128
|
-
3. **Scaffold (on acceptance).** When the operator accepts, write the stubs:
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
node -e "import('./.agents/scripts/lib/onboard/scaffold-docs.js').then(m => console.log(JSON.stringify(m.scaffoldDocs({ write: true }), null, 2)))"
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Each missing file is seeded from a dedicated template under
|
|
135
|
-
`.agents/templates/docs/<name>` when one ships, otherwise from a generic
|
|
136
|
-
placeholder stub. The operator replaces the stub content with real docs
|
|
137
|
-
later; the point of the scaffold is that the mandatory-read never resolves
|
|
138
|
-
to a missing file.
|
|
139
|
-
|
|
140
|
-
This phase is **idempotent**: the scaffolder only writes files that are
|
|
141
|
-
actually absent, so re-running `/onboard` after a partial scaffold creates
|
|
142
|
-
only the still-missing stubs.
|
|
143
|
-
|
|
144
|
-
## Phase 3 — Readiness gate (`mandrel doctor`)
|
|
145
|
-
|
|
146
|
-
Run the doctor as a **readiness gate** before handing off to planning:
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
mandrel doctor
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
`mandrel doctor` (see [`lib/cli/doctor.js`](../../lib/cli/doctor.js)) runs
|
|
153
|
-
every check in the registry in order — `node-version`, `git-available`,
|
|
154
|
-
`gh-available`, `github-token`, `gh-auth`, `commands-in-sync`, `runtime-deps`,
|
|
155
|
-
`agents-materialized`, `agents-drift`, `version-current` — and prints a
|
|
156
|
-
`✔`/`✘` line per check. Every failing check prints a `→ <remedy>` line, and
|
|
157
|
-
the command exits:
|
|
158
|
-
|
|
159
|
-
- **0** with `✅ Ready (N/N checks passed)` — proceed to Phase 4.
|
|
160
|
-
- **non-zero** with `❌ Not ready (M/N checks failed)` — **stop**. Work
|
|
161
|
-
through the `→` remedies (e.g. authenticate `gh`, set `GITHUB_TOKEN`,
|
|
162
|
-
install runtime deps), then re-run `mandrel doctor` until it is green.
|
|
163
|
-
|
|
164
|
-
Do **not** hand off to `/plan` while the doctor is red — planning needs a
|
|
165
|
-
working `gh` / `GITHUB_TOKEN` and a materialized `.agents/` bundle, exactly
|
|
166
|
-
what the gate verifies. The `github-token` check never echoes the token value
|
|
167
|
-
(security baseline § Secrets Management).
|
|
168
|
-
|
|
169
|
-
## Phase 4 — Handoff to a started `/plan`
|
|
170
|
-
|
|
171
|
-
With a green readiness gate, hand the operator off to planning. `/onboard`
|
|
172
|
-
**starts** the handoff — it surfaces the entry point and the idea-refinement
|
|
173
|
-
path — but does **not** auto-run `/plan`, because Epic planning authors
|
|
174
|
-
GitHub artifacts and must stay under explicit operator control.
|
|
175
|
-
|
|
176
|
-
Present the operator with the two `/plan` entry shapes:
|
|
177
|
-
|
|
178
|
-
- **From an idea** (no Epic exists yet):
|
|
179
|
-
|
|
180
|
-
```text
|
|
181
|
-
/plan --idea "<one-line description of the first thing to build>"
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
This enters [`/plan`](helpers/plan-epic.md) at Phase 1 (Idea Refinement),
|
|
185
|
-
which refines the seed into a PRD, Tech Spec, and a decomposed
|
|
186
|
-
Epic → Story backlog.
|
|
187
|
-
|
|
188
|
-
- **From an existing Epic** (a `type::epic` issue already exists):
|
|
189
|
-
|
|
190
|
-
```text
|
|
191
|
-
/plan <epicId>
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Stop here and let the operator invoke `/plan` themselves. Once they have
|
|
195
|
-
a planned Epic, the natural next step is `/deliver <epicId>` to execute
|
|
196
|
-
it — but that is beyond the onboarding path.
|
|
197
|
-
|
|
198
|
-
## Constraints
|
|
199
|
-
|
|
200
|
-
- **Read-before-write.** Phases 1 and 3 are read-only; Phase 2 writes only
|
|
201
|
-
files that are genuinely missing and only on explicit operator acceptance.
|
|
202
|
-
- **Do not auto-run `/plan`.** Phase 4 starts the handoff; the operator
|
|
203
|
-
invokes planning. Planning authors GitHub artifacts and stays under human
|
|
204
|
-
control.
|
|
205
|
-
- **Never echo secrets.** The `github-token` check and any token-related
|
|
206
|
-
remedy must not print the token value.
|
|
207
|
-
- **Stop on a red doctor.** A non-zero `mandrel doctor` exit blocks the
|
|
208
|
-
handoff until the operator clears the failing checks.
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
// lib/cli/__tests__/migrate.test.js
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for lib/cli/migrate.js — the standalone `mandrel migrate`
|
|
4
|
-
* subcommand (Story #3505, Epic #3437).
|
|
5
|
-
*
|
|
6
|
-
* Every test drives runMigrate through injectable seams (argv, runMigrations,
|
|
7
|
-
* registry, ctx, write, writeErr, exit). No real filesystem I/O, no real
|
|
8
|
-
* network call, and no shared mutable module state occur (testing-standards
|
|
9
|
-
* § Unit: all external I/O MUST be mocked; pure-logic assertions only).
|
|
10
|
-
*
|
|
11
|
-
* Coverage contract (Story #3505 AC):
|
|
12
|
-
* - Module shape: runMigrate named export + default function export.
|
|
13
|
-
* - A live run parses --from/--to out of argv and forwards them to
|
|
14
|
-
* runMigrations.
|
|
15
|
-
* - --dry-run reports the steps that WOULD run and invokes no step's apply
|
|
16
|
-
* and no runMigrations call (writes nothing to disk).
|
|
17
|
-
* - Missing --from or --to is a usage error (non-zero exit).
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import assert from 'node:assert/strict';
|
|
21
|
-
import { describe, it } from 'node:test';
|
|
22
|
-
|
|
23
|
-
import migrate, { runMigrate } from '../migrate.js';
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Capture + seam helpers
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
/** Capture stdout/stderr writes and the exit code. */
|
|
30
|
-
function makeCapture() {
|
|
31
|
-
const out = [];
|
|
32
|
-
const err = [];
|
|
33
|
-
let exitCode = null;
|
|
34
|
-
return {
|
|
35
|
-
out,
|
|
36
|
-
err,
|
|
37
|
-
get exitCode() {
|
|
38
|
-
return exitCode;
|
|
39
|
-
},
|
|
40
|
-
write: (s) => out.push(s),
|
|
41
|
-
writeErr: (s) => err.push(s),
|
|
42
|
-
exit: (code) => {
|
|
43
|
-
exitCode = code;
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Build a fixture registry with `detect`/`apply` recorders so the dry-run
|
|
50
|
-
* (detect only, no apply) and live (detect → apply) paths are both
|
|
51
|
-
* observable. `appliedNeeded` lists the versions whose detect returns true
|
|
52
|
-
* (still needs applying); every other step is treated as already-present.
|
|
53
|
-
*/
|
|
54
|
-
function makeFixtureRegistry({ appliedNeeded = ['1.4.0', '1.5.0'] } = {}) {
|
|
55
|
-
const calls = [];
|
|
56
|
-
const registry = [
|
|
57
|
-
{ version: '1.3.0', description: 'pre-range step' },
|
|
58
|
-
{ version: '1.4.0', description: 'rename foo to bar' },
|
|
59
|
-
{ version: '1.5.0', description: 'move baseline file' },
|
|
60
|
-
{ version: '1.6.0', description: 'post-range step' },
|
|
61
|
-
].map((step) => ({
|
|
62
|
-
...step,
|
|
63
|
-
detect: (_ctx) => {
|
|
64
|
-
calls.push(`detect:${step.version}`);
|
|
65
|
-
return appliedNeeded.includes(step.version);
|
|
66
|
-
},
|
|
67
|
-
apply: (_ctx) => {
|
|
68
|
-
calls.push(`apply:${step.version}`);
|
|
69
|
-
},
|
|
70
|
-
}));
|
|
71
|
-
return { registry, calls };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Module shape
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
describe('migrate module exports', () => {
|
|
79
|
-
it('exports runMigrate as a named export', () => {
|
|
80
|
-
assert.equal(typeof runMigrate, 'function');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('exports a default function for bin/mandrel.js dispatch', () => {
|
|
84
|
-
assert.equal(typeof migrate, 'function');
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// AC — live run forwards parsed --from/--to to runMigrations
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
describe('runMigrate — live run', () => {
|
|
93
|
-
it('parses --from/--to and forwards them to runMigrations', () => {
|
|
94
|
-
const cap = makeCapture();
|
|
95
|
-
const calls = [];
|
|
96
|
-
const runMigrations = ({ fromVersion, toVersion }) => {
|
|
97
|
-
calls.push(`runMigrations:${fromVersion}->${toVersion}`);
|
|
98
|
-
return { applied: ['1.4.0'], skipped: [] };
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const result = runMigrate({
|
|
102
|
-
argv: ['--from', '1.3.0', '--to', '1.5.0'],
|
|
103
|
-
runMigrations,
|
|
104
|
-
registry: [],
|
|
105
|
-
write: cap.write,
|
|
106
|
-
writeErr: cap.writeErr,
|
|
107
|
-
exit: cap.exit,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
assert.deepEqual(calls, ['runMigrations:1.3.0->1.5.0']);
|
|
111
|
-
assert.equal(result.ok, true);
|
|
112
|
-
assert.equal(result.action, 'migrated');
|
|
113
|
-
assert.deepEqual(result.applied, ['1.4.0']);
|
|
114
|
-
assert.equal(cap.exitCode, null);
|
|
115
|
-
assert.match(cap.out.join(''), /Applied 1 migration/);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('accepts the --from=value / --to=value spelling', () => {
|
|
119
|
-
const cap = makeCapture();
|
|
120
|
-
const calls = [];
|
|
121
|
-
const runMigrations = ({ fromVersion, toVersion }) => {
|
|
122
|
-
calls.push(`runMigrations:${fromVersion}->${toVersion}`);
|
|
123
|
-
return { applied: [], skipped: [] };
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
runMigrate({
|
|
127
|
-
argv: ['--from=1.2.0', '--to=1.9.0'],
|
|
128
|
-
runMigrations,
|
|
129
|
-
registry: [],
|
|
130
|
-
write: cap.write,
|
|
131
|
-
writeErr: cap.writeErr,
|
|
132
|
-
exit: cap.exit,
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
assert.deepEqual(calls, ['runMigrations:1.2.0->1.9.0']);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('reports a no-op when no migrations applied', () => {
|
|
139
|
-
const cap = makeCapture();
|
|
140
|
-
const runMigrations = () => ({ applied: [], skipped: ['1.4.0'] });
|
|
141
|
-
|
|
142
|
-
const result = runMigrate({
|
|
143
|
-
argv: ['--from', '1.3.0', '--to', '1.5.0'],
|
|
144
|
-
runMigrations,
|
|
145
|
-
registry: [],
|
|
146
|
-
write: cap.write,
|
|
147
|
-
writeErr: cap.writeErr,
|
|
148
|
-
exit: cap.exit,
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
assert.equal(result.ok, true);
|
|
152
|
-
assert.deepEqual(result.applied, []);
|
|
153
|
-
assert.match(cap.out.join(''), /no migrations to apply/);
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
// AC — --dry-run reports the plan, invokes no apply, writes nothing
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
describe('runMigrate — --dry-run', () => {
|
|
162
|
-
it('reports the in-range steps that would apply / be skipped and never applies', () => {
|
|
163
|
-
const cap = makeCapture();
|
|
164
|
-
const { registry, calls } = makeFixtureRegistry({
|
|
165
|
-
// 1.5.0 is in range but already present (detect → false ⇒ would skip).
|
|
166
|
-
appliedNeeded: ['1.4.0'],
|
|
167
|
-
});
|
|
168
|
-
let runnerCalled = false;
|
|
169
|
-
|
|
170
|
-
const result = runMigrate({
|
|
171
|
-
argv: ['--from', '1.3.0', '--to', '1.5.0', '--dry-run'],
|
|
172
|
-
runMigrations: () => {
|
|
173
|
-
runnerCalled = true;
|
|
174
|
-
return { applied: [], skipped: [] };
|
|
175
|
-
},
|
|
176
|
-
registry,
|
|
177
|
-
write: cap.write,
|
|
178
|
-
writeErr: cap.writeErr,
|
|
179
|
-
exit: cap.exit,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// Range filter is fromVersion < v <= toVersion → only 1.4.0 and 1.5.0.
|
|
183
|
-
assert.deepEqual(result.wouldApply, ['1.4.0']);
|
|
184
|
-
assert.deepEqual(result.wouldSkip, ['1.5.0']);
|
|
185
|
-
assert.equal(result.action, 'dry-run');
|
|
186
|
-
assert.equal(result.ok, true);
|
|
187
|
-
|
|
188
|
-
// Dry-run probes detect on in-range steps only — never apply, never the
|
|
189
|
-
// live runner.
|
|
190
|
-
assert.deepEqual(calls, ['detect:1.4.0', 'detect:1.5.0']);
|
|
191
|
-
assert.equal(runnerCalled, false);
|
|
192
|
-
assert.ok(!calls.some((c) => c.startsWith('apply:')));
|
|
193
|
-
|
|
194
|
-
// Operator-facing plan output.
|
|
195
|
-
const stdout = cap.out.join('');
|
|
196
|
-
assert.match(stdout, /dry run v1\.3\.0 → v1\.5\.0/);
|
|
197
|
-
assert.match(stdout, /would apply {2}1\.4\.0: rename foo to bar/);
|
|
198
|
-
assert.match(stdout, /would skip {3}1\.5\.0: move baseline file/);
|
|
199
|
-
assert.match(stdout, /no migrations applied, nothing written/);
|
|
200
|
-
assert.equal(cap.exitCode, null);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('reports an empty plan when no steps fall in range', () => {
|
|
204
|
-
const cap = makeCapture();
|
|
205
|
-
const { registry, calls } = makeFixtureRegistry();
|
|
206
|
-
|
|
207
|
-
const result = runMigrate({
|
|
208
|
-
// 1.6.0 < v <= 1.6.0 leaves only the 1.6.0 step out (exclusive lower);
|
|
209
|
-
// 9.9.0 → 9.9.0 range catches nothing.
|
|
210
|
-
argv: ['--from', '9.9.0', '--to', '9.9.0', '--dry-run'],
|
|
211
|
-
runMigrations: () => ({ applied: [], skipped: [] }),
|
|
212
|
-
registry,
|
|
213
|
-
write: cap.write,
|
|
214
|
-
writeErr: cap.writeErr,
|
|
215
|
-
exit: cap.exit,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
assert.deepEqual(result.wouldApply, []);
|
|
219
|
-
assert.deepEqual(result.wouldSkip, []);
|
|
220
|
-
assert.deepEqual(calls, []);
|
|
221
|
-
assert.match(cap.out.join(''), /no migration steps in range/);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// AC — missing bounds are a usage error
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
describe('runMigrate — usage validation', () => {
|
|
230
|
-
it('exits non-zero when --to is missing', () => {
|
|
231
|
-
const cap = makeCapture();
|
|
232
|
-
let runnerCalled = false;
|
|
233
|
-
|
|
234
|
-
const result = runMigrate({
|
|
235
|
-
argv: ['--from', '1.3.0'],
|
|
236
|
-
runMigrations: () => {
|
|
237
|
-
runnerCalled = true;
|
|
238
|
-
return { applied: [], skipped: [] };
|
|
239
|
-
},
|
|
240
|
-
registry: [],
|
|
241
|
-
write: cap.write,
|
|
242
|
-
writeErr: cap.writeErr,
|
|
243
|
-
exit: cap.exit,
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
assert.equal(result.ok, false);
|
|
247
|
-
assert.equal(result.action, 'usage-error');
|
|
248
|
-
assert.equal(cap.exitCode, 1);
|
|
249
|
-
assert.equal(runnerCalled, false);
|
|
250
|
-
assert.match(cap.err.join(''), /both --from .* and --to .* are required/);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('exits non-zero when --from is missing', () => {
|
|
254
|
-
const cap = makeCapture();
|
|
255
|
-
|
|
256
|
-
const result = runMigrate({
|
|
257
|
-
argv: ['--to', '1.5.0'],
|
|
258
|
-
runMigrations: () => ({ applied: [], skipped: [] }),
|
|
259
|
-
registry: [],
|
|
260
|
-
write: cap.write,
|
|
261
|
-
writeErr: cap.writeErr,
|
|
262
|
-
exit: cap.exit,
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
assert.equal(result.action, 'usage-error');
|
|
266
|
-
assert.equal(cap.exitCode, 1);
|
|
267
|
-
});
|
|
268
|
-
});
|