@ulysses-ai/create-workspace 0.21.0-beta.0 → 0.23.0-beta.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/lib/init.mjs +9 -0
- package/lib/init.test.mjs +75 -0
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +474 -38
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/maintenance-audit.mjs +0 -0
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +239 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +63 -27
package/lib/upgrade.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// lib/upgrade.mjs
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync, mkdtempSync, renameSync,
|
|
4
|
+
} from 'fs';
|
|
3
5
|
import { join } from 'path';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
4
7
|
import { spawnSync } from 'child_process';
|
|
5
|
-
import { stagePayload } from './payload.mjs';
|
|
8
|
+
import { stagePayload, extractTarballEntries, fetchPackageTarball } from './payload.mjs';
|
|
9
|
+
import {
|
|
10
|
+
buildBaseline, RECONSTRUCTED_BASELINE_NAME, LIVE_PAIRS, INERT_PAIRS,
|
|
11
|
+
readBaseline, readBaselineFile, hashBytes,
|
|
12
|
+
} from '../template/_claude/scripts/template-baseline.mjs';
|
|
6
13
|
|
|
7
14
|
// .workspace-update/ is a transient staging area and is gitignored from
|
|
8
15
|
// v0.19.0 on — but workspaces upgraded from older templates may still track
|
|
@@ -21,7 +28,126 @@ function warnIfPayloadTracked(targetDir) {
|
|
|
21
28
|
console.error(` Untrack it and commit:\n git rm -r --cached .workspace-update\n git commit -m "chore: untrack .workspace-update payload"\n`);
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
// The workspace runs its INSTALLED /workspace-update after the CLI exits,
|
|
32
|
+
// and an older installed copy predates the classifier/baseline/audit flow
|
|
33
|
+
// the new payload ships — the assistant would improvise the update. So
|
|
34
|
+
// --upgrade bootstraps the payload's copy into .claude/skills/ before
|
|
35
|
+
// finishing, the same way --init installs bootstrap skills. The directory
|
|
36
|
+
// is REPLACED, not merged: stale files inside the old skill must not
|
|
37
|
+
// survive next to the new flow. Two safety properties (gh:186):
|
|
38
|
+
// - a locally customised SKILL.md (matching neither the payload's copy
|
|
39
|
+
// nor the hash of what the installed template version shipped) is
|
|
40
|
+
// backed up as SKILL.md.local-backup before the swap, and
|
|
41
|
+
// - the new tree is copied to a sibling staging directory and swapped in
|
|
42
|
+
// only once the copy succeeded — a copy that fails partway leaves the
|
|
43
|
+
// old skill in place, never an empty directory.
|
|
44
|
+
function installWorkspaceUpdateSkill(targetDir, payloadDir, { copySkill = cpSync } = {}) {
|
|
45
|
+
const src = join(payloadDir, '.claude', 'skills', 'workspace-update');
|
|
46
|
+
if (!existsSync(src)) return false;
|
|
47
|
+
const skillsDir = join(targetDir, '.claude', 'skills');
|
|
48
|
+
const dest = join(skillsDir, 'workspace-update');
|
|
49
|
+
|
|
50
|
+
let backup = null;
|
|
51
|
+
const installedSkillMd = join(dest, 'SKILL.md');
|
|
52
|
+
if (existsSync(installedSkillMd)) {
|
|
53
|
+
const installedHash = hashBytes(readFileSync(installedSkillMd));
|
|
54
|
+
const payloadHash = hashBytes(readFileSync(join(src, 'SKILL.md')));
|
|
55
|
+
const oldBaseline = readBaselineFile(join(payloadDir, RECONSTRUCTED_BASELINE_NAME));
|
|
56
|
+
const shippedHash = oldBaseline ? oldBaseline.files['.claude/skills/workspace-update/SKILL.md'] : undefined;
|
|
57
|
+
if (installedHash !== payloadHash && shippedHash !== installedHash) {
|
|
58
|
+
backup = readFileSync(installedSkillMd);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const incoming = join(skillsDir, '.workspace-update.incoming');
|
|
63
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
64
|
+
rmSync(incoming, { recursive: true, force: true });
|
|
65
|
+
try {
|
|
66
|
+
copySkill(src, incoming, { recursive: true });
|
|
67
|
+
rmSync(dest, { recursive: true, force: true });
|
|
68
|
+
try {
|
|
69
|
+
renameSync(incoming, dest);
|
|
70
|
+
} catch {
|
|
71
|
+
// rename across filesystems or onto a slow handle — a plain copy of
|
|
72
|
+
// the already-verified staging tree still completes the install.
|
|
73
|
+
copySkill(incoming, dest, { recursive: true });
|
|
74
|
+
rmSync(incoming, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
rmSync(incoming, { recursive: true, force: true });
|
|
78
|
+
console.error(` Warning: could not install the current workspace-update skill (${err.message}) — the installed copy stays.`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (backup !== null) {
|
|
82
|
+
writeFileSync(join(dest, 'SKILL.md.local-backup'), backup);
|
|
83
|
+
console.log(' Existing workspace-update skill was modified locally — backed up to .claude/skills/workspace-update/SKILL.md.local-backup');
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A workspace upgraded from a pre-baseline template version has no
|
|
89
|
+
// .claude/.template-baseline.json, so its first /workspace-update reports
|
|
90
|
+
// every template change as a local edit — dozens of per-file questions
|
|
91
|
+
// with a handful of real edits among them. The template tree of the
|
|
92
|
+
// INSTALLED version still exists in exactly one place: its published npm
|
|
93
|
+
// tarball. Fetch it, hash what that version shipped (the same
|
|
94
|
+
// normalisation template-baseline.mjs applies everywhere), and stage a
|
|
95
|
+
// baseline for it marked "reconstructed": true INSIDE THE PAYLOAD, not the
|
|
96
|
+
// launcher: with a remote, /workspace-update classifies inside a task
|
|
97
|
+
// worktree that can only see what travels with the payload, and an
|
|
98
|
+
// untracked launcher baseline would dirty the launcher against the incoming
|
|
99
|
+
// PR. classify-update.mjs falls back to the staged file automatically.
|
|
100
|
+
// Any failure — unknown version, offline, unparsable tarball — warns and
|
|
101
|
+
// falls back to the ask-per-file behaviour; the upgrade itself never fails.
|
|
102
|
+
async function reconstructBaseline(payloadDir, fromVersion, { fetchTarball = fetchPackageTarball } = {}) {
|
|
103
|
+
const fallbackNote = ' The first /workspace-update will ask about every changed file individually.';
|
|
104
|
+
if (typeof fromVersion !== 'string' || fromVersion === 'unknown' || fromVersion === '') {
|
|
105
|
+
console.error(' Warning: workspace.json records no templateVersion — cannot reconstruct a template baseline.');
|
|
106
|
+
console.error(fallbackNote);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
let tarball = null;
|
|
110
|
+
try {
|
|
111
|
+
tarball = await fetchTarball(fromVersion);
|
|
112
|
+
} catch { /* a failing fetch is the same as no fetch */ }
|
|
113
|
+
if (tarball === null) {
|
|
114
|
+
console.error(` Warning: could not fetch the v${fromVersion} package tarball from npm — no template baseline written.`);
|
|
115
|
+
console.error(fallbackNote);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tmp = mkdtempSync(join(tmpdir(), 'create-workspace-baseline-'));
|
|
120
|
+
try {
|
|
121
|
+
// npm tarballs root every file under package/; the template itself
|
|
122
|
+
// ships under both layouts across versions: _claude/ and _mcp.json
|
|
123
|
+
// (v0.19.0 on, same as this package) and the older live-named .claude/
|
|
124
|
+
// and .mcp.json. buildBaseline maps either onto the installed names
|
|
125
|
+
// through its pairs.
|
|
126
|
+
const extracted = join(tmp, 'template');
|
|
127
|
+
let baseline = null;
|
|
128
|
+
try {
|
|
129
|
+
extractTarballEntries(tarball, extracted, { prefix: 'package/template/' });
|
|
130
|
+
const pairs = existsSync(join(extracted, '_claude')) ? INERT_PAIRS : LIVE_PAIRS;
|
|
131
|
+
baseline = buildBaseline(extracted, { pairs, version: fromVersion });
|
|
132
|
+
} catch { /* unreadable tarball or unhashable tree — the fallback below */ }
|
|
133
|
+
if (baseline === null || Object.keys(baseline.files).length === 0) {
|
|
134
|
+
console.error(` Warning: the v${fromVersion} package tarball carries no readable template files — no template baseline written.`);
|
|
135
|
+
console.error(fallbackNote);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
baseline.reconstructed = true;
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(payloadDir, RECONSTRUCTED_BASELINE_NAME),
|
|
141
|
+
JSON.stringify(baseline, null, 2) + '\n',
|
|
142
|
+
);
|
|
143
|
+
console.log(` Reconstructed template baseline for v${fromVersion} from the npm tarball (${Object.keys(baseline.files).length} files) — staged in the payload as ${RECONSTRUCTED_BASELINE_NAME}`);
|
|
144
|
+
return true;
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function upgradeWorkspace(targetDir, opts = {}) {
|
|
25
151
|
const workspaceJsonPath = join(targetDir, 'workspace.json');
|
|
26
152
|
|
|
27
153
|
console.log(`\n @ulysses-ai/create-workspace --upgrade`);
|
|
@@ -46,12 +172,28 @@ export async function upgradeWorkspace(targetDir) {
|
|
|
46
172
|
|
|
47
173
|
const fromVersion = config.workspace?.templateVersion || 'unknown';
|
|
48
174
|
|
|
49
|
-
// Stage payload
|
|
50
|
-
const { toVersion } = stagePayload(targetDir, { action: 'upgrade', fromVersion });
|
|
175
|
+
// Stage the payload first: a reconstructed baseline is staged INSIDE it.
|
|
176
|
+
const { payloadDir, toVersion } = stagePayload(targetDir, { action: 'upgrade', fromVersion });
|
|
177
|
+
|
|
178
|
+
// A workspace older than the baseline's introduction has no baseline —
|
|
179
|
+
// and a corrupt one is as good as none (readBaseline parses it). In
|
|
180
|
+
// either case reconstruct one for the installed version into the payload,
|
|
181
|
+
// so the update can tell template changes from local edits (gh:186).
|
|
182
|
+
if (readBaseline(targetDir) !== null) {
|
|
183
|
+
console.log(' Template baseline already present — leaving it in place.');
|
|
184
|
+
} else {
|
|
185
|
+
await reconstructBaseline(payloadDir, fromVersion, opts);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Bootstrap the current /workspace-update skill — the workspace runs its
|
|
189
|
+
// installed copy next, and the old one does not know this payload's flow.
|
|
190
|
+
if (installWorkspaceUpdateSkill(targetDir, payloadDir, opts)) {
|
|
191
|
+
console.log(' Installed the current workspace-update skill (.claude/skills/workspace-update/)');
|
|
192
|
+
}
|
|
51
193
|
|
|
52
194
|
if (fromVersion === toVersion) {
|
|
53
195
|
console.log(` Workspace is already on template v${toVersion}.`);
|
|
54
|
-
console.log(` Payload staged anyway — run /workspace-update to verify integrity
|
|
196
|
+
console.log(` Payload staged anyway — run /workspace-update to verify integrity.`);
|
|
55
197
|
} else {
|
|
56
198
|
console.log(` Staged template payload (v${fromVersion} → v${toVersion})`);
|
|
57
199
|
}
|
package/lib/upgrade.test.mjs
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
// Unit tests for upgrade.mjs
|
|
3
3
|
// Run: node lib/upgrade.test.mjs
|
|
4
4
|
//
|
|
5
|
-
// Covers the tracked-payload warning
|
|
6
|
-
// v0.19.0, but a workspace upgraded from an older template may still
|
|
7
|
-
// tracked payload
|
|
5
|
+
// Covers the tracked-payload warning (.workspace-update/ is gitignored
|
|
6
|
+
// from v0.19.0, but a workspace upgraded from an older template may still
|
|
7
|
+
// carry a tracked payload), the workspace-update skill bootstrap (--upgrade
|
|
8
|
+
// installs the payload's copy so the workspace never runs an outdated
|
|
9
|
+
// flow), and baseline reconstruction for pre-baseline workspaces (gh:186)
|
|
10
|
+
// — the tarball fetch is injected so the suite never touches the network.
|
|
8
11
|
import { upgradeWorkspace } from './upgrade.mjs';
|
|
9
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
|
|
10
13
|
import { join } from 'path';
|
|
11
14
|
import { tmpdir } from 'os';
|
|
12
15
|
import { execSync, spawnSync } from 'child_process';
|
|
16
|
+
import { createHash } from 'crypto';
|
|
17
|
+
import { gzipSync } from 'zlib';
|
|
13
18
|
|
|
14
19
|
let failed = 0;
|
|
15
20
|
let passed = 0;
|
|
@@ -20,29 +25,36 @@ function check(label, ok) {
|
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
function captureConsole(fn) {
|
|
28
|
+
async function captureConsole(fn) {
|
|
24
29
|
const out = { log: [], error: [] };
|
|
25
30
|
const origLog = console.log;
|
|
26
31
|
const origError = console.error;
|
|
27
32
|
console.log = (...a) => { out.log.push(a.join(' ')); };
|
|
28
33
|
console.error = (...a) => { out.error.push(a.join(' ')); };
|
|
29
34
|
try {
|
|
30
|
-
return { result: fn(), out };
|
|
35
|
+
return { result: await fn(), out };
|
|
31
36
|
} finally {
|
|
32
37
|
console.log = origLog;
|
|
33
38
|
console.error = origError;
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
function buildWorkspace() {
|
|
42
|
+
function buildWorkspace({ templateVersion = '0.15.0', baseline = false } = {}) {
|
|
38
43
|
const root = mkdtempSync(join(tmpdir(), 'upgrade-test-'));
|
|
39
44
|
execSync('git init -q -b main', { cwd: root, stdio: 'pipe' });
|
|
40
45
|
execSync('git config user.email test@example.com', { cwd: root, stdio: 'pipe' });
|
|
41
46
|
execSync('git config user.name Test', { cwd: root, stdio: 'pipe' });
|
|
42
47
|
writeFileSync(join(root, 'workspace.json'), JSON.stringify({
|
|
43
|
-
workspace: { name: 'demo', initialized: true, templateVersion
|
|
48
|
+
workspace: { name: 'demo', initialized: true, templateVersion },
|
|
44
49
|
repos: {},
|
|
45
50
|
}, null, 2) + '\n');
|
|
51
|
+
if (baseline) {
|
|
52
|
+
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
53
|
+
writeFileSync(join(root, '.claude', '.template-baseline.json'), JSON.stringify({
|
|
54
|
+
templateVersion,
|
|
55
|
+
files: { '.claude/hooks/a.mjs': '0'.repeat(64) },
|
|
56
|
+
}) + '\n');
|
|
57
|
+
}
|
|
46
58
|
execSync('git add workspace.json', { cwd: root, stdio: 'pipe' });
|
|
47
59
|
execSync('git commit -q -m init', { cwd: root, stdio: 'pipe' });
|
|
48
60
|
return root;
|
|
@@ -55,20 +67,75 @@ function lsFilesPayload(root) {
|
|
|
55
67
|
}).stdout.split('\n').filter(Boolean);
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
// ---- fixture tarballs (old live-name layout and new inert layout) ----
|
|
71
|
+
|
|
72
|
+
function tarEntry(name, content, type = '0') {
|
|
73
|
+
const body = Buffer.from(content, 'utf8');
|
|
74
|
+
const header = Buffer.alloc(512);
|
|
75
|
+
header.write(name, 0, 100, 'utf8');
|
|
76
|
+
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
|
|
77
|
+
header.write(type, 156, 'ascii');
|
|
78
|
+
header.write('ustar', 257, 'ascii');
|
|
79
|
+
header.write('00', 263, 'ascii');
|
|
80
|
+
let sum = 0;
|
|
81
|
+
for (const byte of header) sum += byte;
|
|
82
|
+
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
|
|
83
|
+
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
|
|
84
|
+
return Buffer.concat([header, body, pad]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeTarball(entries) {
|
|
88
|
+
return gzipSync(Buffer.concat([
|
|
89
|
+
...entries.map(([name, content, type]) => tarEntry(name, content, type)),
|
|
90
|
+
Buffer.alloc(1024),
|
|
91
|
+
]));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
95
|
+
|
|
96
|
+
// A v0.18-era package: every path rooted under package/ (npm's layout),
|
|
97
|
+
// template/ still carried the live names.
|
|
98
|
+
const OLD_LAYOUT_TARBALL = makeTarball([
|
|
99
|
+
['package/package.json', '{"name":"@ulysses-ai/create-workspace"}\n'],
|
|
100
|
+
['package/template/CLAUDE.md.tmpl', '## Workspace: {{project-name}}\n'],
|
|
101
|
+
['package/template/.claude/hooks/session-start.mjs', '// hooks v0.15\n'],
|
|
102
|
+
['package/template/.claude/scripts/old.mjs', '// script\n'],
|
|
103
|
+
['package/template/.mcp.json', '{"mcpServers":{}}\n'],
|
|
104
|
+
['package/template/.claudeignore', 'scratch/\n'],
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// A v0.19-on package: template/ stores the inert names.
|
|
108
|
+
const NEW_LAYOUT_TARBALL = makeTarball([
|
|
109
|
+
['package/template/_claude/hooks/session-start.mjs', '// hooks v0.19\n'],
|
|
110
|
+
['package/template/_claude/scripts/new.mjs', '// script\n'],
|
|
111
|
+
['package/template/_mcp.json', '{"mcpServers":{}}\n'],
|
|
112
|
+
['package/template/.claudeignore', 'scratch/\n'],
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
// Injected fetch results: null means "not fetchable", a throwing fetch must
|
|
116
|
+
// never be reached.
|
|
117
|
+
const noTarball = async () => null;
|
|
118
|
+
const mustNotFetch = async () => { throw new Error('fetchTarball must not be called'); };
|
|
119
|
+
|
|
58
120
|
console.log('# upgrade');
|
|
59
121
|
|
|
60
|
-
// 1. Clean workspace: payload staged, no tracked-payload
|
|
61
|
-
//
|
|
122
|
+
// 1. Clean workspace with a baseline: payload staged, no tracked-payload
|
|
123
|
+
// warning, no fetch, no reconstruction staged, and the freshly staged
|
|
124
|
+
// payload is not tracked.
|
|
62
125
|
{
|
|
63
|
-
const root = buildWorkspace();
|
|
126
|
+
const root = buildWorkspace({ baseline: true });
|
|
64
127
|
try {
|
|
65
|
-
const { out } = captureConsole(() => upgradeWorkspace(root));
|
|
128
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
66
129
|
const stderr = out.error.join('\n');
|
|
67
130
|
check('no tracked-payload warning on a clean workspace',
|
|
68
131
|
!stderr.includes('git rm -r --cached'));
|
|
69
132
|
check('payload staged', existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
70
133
|
check('freshly staged payload is not tracked', lsFilesPayload(root).length === 0);
|
|
71
134
|
check('staging reported', out.log.join('\n').includes('Staged template payload'));
|
|
135
|
+
check('skipped reconstruction says why',
|
|
136
|
+
out.log.join('\n').includes('Template baseline already present'));
|
|
137
|
+
check('no reconstructed baseline staged when one exists',
|
|
138
|
+
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
72
139
|
} finally {
|
|
73
140
|
rmSync(root, { recursive: true, force: true });
|
|
74
141
|
}
|
|
@@ -77,7 +144,7 @@ console.log('# upgrade');
|
|
|
77
144
|
// 2. Workspace tracking .workspace-update/ from a pre-v0.19 upgrade: the
|
|
78
145
|
// warning fires and names the untrack command.
|
|
79
146
|
{
|
|
80
|
-
const root = buildWorkspace();
|
|
147
|
+
const root = buildWorkspace({ baseline: true });
|
|
81
148
|
try {
|
|
82
149
|
// Simulate the old behavior: a payload committed tracked.
|
|
83
150
|
const payload = join(root, '.workspace-update');
|
|
@@ -87,7 +154,7 @@ console.log('# upgrade');
|
|
|
87
154
|
execSync('git commit -q -m "track payload"', { cwd: root, stdio: 'pipe' });
|
|
88
155
|
check('fixture really tracks the payload', lsFilesPayload(root).length > 0);
|
|
89
156
|
|
|
90
|
-
const { out } = captureConsole(() => upgradeWorkspace(root));
|
|
157
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
91
158
|
const stderr = out.error.join('\n');
|
|
92
159
|
check('tracked-payload warning fires', stderr.includes('.workspace-update/') && stderr.includes('tracked by git'));
|
|
93
160
|
check('warning names the untrack command', stderr.includes('git rm -r --cached .workspace-update'));
|
|
@@ -104,7 +171,7 @@ console.log('# upgrade');
|
|
|
104
171
|
workspace: { name: 'demo', initialized: true, templateVersion: '0.15.0' },
|
|
105
172
|
repos: {},
|
|
106
173
|
}, null, 2) + '\n');
|
|
107
|
-
const { out } = captureConsole(() => upgradeWorkspace(root));
|
|
174
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
108
175
|
check('no warning outside a git repo', !out.error.join('\n').includes('tracked by git'));
|
|
109
176
|
check('payload staged outside a git repo', existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
110
177
|
} finally {
|
|
@@ -112,6 +179,243 @@ console.log('# upgrade');
|
|
|
112
179
|
}
|
|
113
180
|
}
|
|
114
181
|
|
|
182
|
+
// 4. --upgrade installs the payload's workspace-update skill, REPLACING
|
|
183
|
+
// whatever old copy the workspace carries. An old SKILL.md that matches
|
|
184
|
+
// neither the payload's copy nor any known-shipped copy is the operator's
|
|
185
|
+
// own edit — it is backed up before the swap (gh:186).
|
|
186
|
+
{
|
|
187
|
+
const root = buildWorkspace({ baseline: true });
|
|
188
|
+
try {
|
|
189
|
+
// An outdated installed skill with a stale extra file.
|
|
190
|
+
const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
|
|
191
|
+
mkdirSync(oldSkill, { recursive: true });
|
|
192
|
+
writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
|
|
193
|
+
writeFileSync(join(oldSkill, 'OLD-EXTRA.md'), 'stale\n');
|
|
194
|
+
|
|
195
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
196
|
+
const newSkill = readFileSync(join(oldSkill, 'SKILL.md'), 'utf8');
|
|
197
|
+
check('installed skill is the payload copy, not the old one',
|
|
198
|
+
newSkill === readFileSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md'), 'utf8')
|
|
199
|
+
&& newSkill !== '# old flow\n');
|
|
200
|
+
check('stale files inside the old skill do not survive', !existsSync(join(oldSkill, 'OLD-EXTRA.md')));
|
|
201
|
+
check('skill install reported', out.log.join('\n').includes('workspace-update skill'));
|
|
202
|
+
check('a locally modified old skill is backed up',
|
|
203
|
+
readFileSync(join(oldSkill, 'SKILL.md.local-backup'), 'utf8') === '# old flow\n');
|
|
204
|
+
check('backup reported', out.log.join('\n').includes('SKILL.md.local-backup'));
|
|
205
|
+
// The staged payload itself is unchanged: its skill directory stays.
|
|
206
|
+
check('payload still carries the skill directory',
|
|
207
|
+
existsSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
208
|
+
check('no staging directory left behind',
|
|
209
|
+
!existsSync(join(root, '.claude', 'skills', '.workspace-update.incoming')));
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(root, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 4b. A PRISTINE old skill — its bytes match what the installed version
|
|
216
|
+
// shipped (the reconstructed baseline records the hash) — replaces
|
|
217
|
+
// without a backup: nothing of the operator's is at risk.
|
|
218
|
+
{
|
|
219
|
+
// The tarball of the installed version shipped exactly this skill.
|
|
220
|
+
const PRISTINE_TARBALL = makeTarball([
|
|
221
|
+
['package/template/.claude/skills/workspace-update/SKILL.md', '# pristine old flow\n'],
|
|
222
|
+
['package/template/.claudeignore', 'scratch/\n'],
|
|
223
|
+
]);
|
|
224
|
+
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
225
|
+
try {
|
|
226
|
+
const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
|
|
227
|
+
mkdirSync(oldSkill, { recursive: true });
|
|
228
|
+
writeFileSync(join(oldSkill, 'SKILL.md'), '# pristine old flow\n');
|
|
229
|
+
await captureConsole(() => upgradeWorkspace(root, { fetchTarball: async () => PRISTINE_TARBALL }));
|
|
230
|
+
check('pristine old skill replaced without a backup',
|
|
231
|
+
!existsSync(join(oldSkill, 'SKILL.md.local-backup'))
|
|
232
|
+
&& readFileSync(join(oldSkill, 'SKILL.md'), 'utf8') !== '# pristine old flow\n');
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(root, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The same workspace but with the operator's own edit on top: backed up.
|
|
238
|
+
const editedRoot = buildWorkspace({ templateVersion: '0.15.0' });
|
|
239
|
+
try {
|
|
240
|
+
const oldSkill = join(editedRoot, '.claude', 'skills', 'workspace-update');
|
|
241
|
+
mkdirSync(oldSkill, { recursive: true });
|
|
242
|
+
writeFileSync(join(oldSkill, 'SKILL.md'), '# my customized flow\n');
|
|
243
|
+
await captureConsole(() => upgradeWorkspace(editedRoot, { fetchTarball: async () => PRISTINE_TARBALL }));
|
|
244
|
+
check('a customized old skill is backed up when the shipped hash is known',
|
|
245
|
+
readFileSync(join(oldSkill, 'SKILL.md.local-backup'), 'utf8') === '# my customized flow\n');
|
|
246
|
+
} finally {
|
|
247
|
+
rmSync(editedRoot, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 4c. A copy that fails partway leaves the installed skill in place — the
|
|
252
|
+
// swap happens only after the staging copy succeeded.
|
|
253
|
+
{
|
|
254
|
+
const root = buildWorkspace({ baseline: true });
|
|
255
|
+
try {
|
|
256
|
+
const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
|
|
257
|
+
mkdirSync(oldSkill, { recursive: true });
|
|
258
|
+
writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
|
|
259
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
260
|
+
fetchTarball: mustNotFetch,
|
|
261
|
+
copySkill: () => { throw new Error('disk full'); },
|
|
262
|
+
}));
|
|
263
|
+
check('a failing copy keeps the installed skill',
|
|
264
|
+
readFileSync(join(oldSkill, 'SKILL.md'), 'utf8') === '# old flow\n');
|
|
265
|
+
check('a failing copy warns', out.error.join('\n').includes('could not install the current workspace-update skill'));
|
|
266
|
+
check('a failing copy leaves no staging directory',
|
|
267
|
+
!existsSync(join(root, '.claude', 'skills', '.workspace-update.incoming')));
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(root, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 5. No baseline + old live-name tarball: reconstruction stages a baseline
|
|
274
|
+
// for the INSTALLED version INSIDE THE PAYLOAD — never the launcher —
|
|
275
|
+
// marked reconstructed (gh:186).
|
|
276
|
+
{
|
|
277
|
+
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
278
|
+
try {
|
|
279
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
280
|
+
fetchTarball: async (version) => (version === '0.15.0' ? OLD_LAYOUT_TARBALL : null),
|
|
281
|
+
}));
|
|
282
|
+
const baselinePath = join(root, '.workspace-update', '.template-baseline.reconstructed.json');
|
|
283
|
+
check('reconstructed baseline staged in the payload', existsSync(baselinePath));
|
|
284
|
+
check('launcher gets no new baseline file',
|
|
285
|
+
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
286
|
+
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
287
|
+
check('baseline records the installed version', baseline.templateVersion === '0.15.0');
|
|
288
|
+
check('baseline is marked reconstructed', baseline.reconstructed === true);
|
|
289
|
+
check('live-name layout hashes map to installed keys',
|
|
290
|
+
baseline.files['.claude/hooks/session-start.mjs'] === sha('// hooks v0.15\n'));
|
|
291
|
+
check('.mcp.json baselined from the old live name',
|
|
292
|
+
baseline.files['.mcp.json'] === sha('{"mcpServers":{}}\n'));
|
|
293
|
+
check('non-verbatim payload roots are not baselined',
|
|
294
|
+
!Object.keys(baseline.files).some((k) => k.endsWith('.tmpl') || k.startsWith('package')));
|
|
295
|
+
check('reconstruction reported', out.log.join('\n').includes('Reconstructed template baseline'));
|
|
296
|
+
check('reconstruction names its staged location',
|
|
297
|
+
out.log.join('\n').includes('.template-baseline.reconstructed.json'));
|
|
298
|
+
} finally {
|
|
299
|
+
rmSync(root, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 6. No baseline + new inert-name tarball: same reconstruction through the
|
|
304
|
+
// _claude/_mcp.json layout, staged in the payload the same way.
|
|
305
|
+
{
|
|
306
|
+
const root = buildWorkspace({ templateVersion: '0.19.0' });
|
|
307
|
+
try {
|
|
308
|
+
await captureConsole(() => upgradeWorkspace(root, {
|
|
309
|
+
fetchTarball: async () => NEW_LAYOUT_TARBALL,
|
|
310
|
+
}));
|
|
311
|
+
const baseline = JSON.parse(
|
|
312
|
+
readFileSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json'), 'utf8'));
|
|
313
|
+
check('inert-name layout maps to installed keys',
|
|
314
|
+
baseline.files['.claude/hooks/session-start.mjs'] === sha('// hooks v0.19\n'));
|
|
315
|
+
check('_mcp.json baselined as .mcp.json',
|
|
316
|
+
baseline.files['.mcp.json'] === sha('{"mcpServers":{}}\n'));
|
|
317
|
+
check('baseline marked reconstructed', baseline.reconstructed === true);
|
|
318
|
+
check('launcher still has no baseline of its own',
|
|
319
|
+
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
320
|
+
} finally {
|
|
321
|
+
rmSync(root, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 7. Fetch failure and unknown version: warn and continue — the upgrade
|
|
326
|
+
// itself never fails, and the payload plus skill still land.
|
|
327
|
+
{
|
|
328
|
+
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
329
|
+
try {
|
|
330
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
331
|
+
const stderr = out.error.join('\n');
|
|
332
|
+
check('fetch failure warns about the missing baseline',
|
|
333
|
+
stderr.includes('no template baseline written'));
|
|
334
|
+
check('fetch failure names the per-file fallback',
|
|
335
|
+
stderr.includes('ask about every changed file'));
|
|
336
|
+
check('no baseline written on fetch failure',
|
|
337
|
+
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
338
|
+
check('no reconstructed baseline staged on fetch failure',
|
|
339
|
+
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
340
|
+
check('upgrade continues after fetch failure',
|
|
341
|
+
existsSync(join(root, '.workspace-update', '.manifest.json'))
|
|
342
|
+
&& existsSync(join(root, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
343
|
+
} finally {
|
|
344
|
+
rmSync(root, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 8. A corrupt tarball is a warning, not a crash.
|
|
349
|
+
{
|
|
350
|
+
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
351
|
+
try {
|
|
352
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
353
|
+
fetchTarball: async () => Buffer.from('not a tarball at all'),
|
|
354
|
+
}));
|
|
355
|
+
check('corrupt tarball warns', out.error.join('\n').includes('no template baseline written'));
|
|
356
|
+
check('corrupt tarball does not fail the upgrade',
|
|
357
|
+
existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
358
|
+
} finally {
|
|
359
|
+
rmSync(root, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 9. No usable templateVersion: named warning, no network attempt.
|
|
364
|
+
{
|
|
365
|
+
const root = buildWorkspace({ templateVersion: null });
|
|
366
|
+
try {
|
|
367
|
+
writeFileSync(join(root, 'workspace.json'), JSON.stringify({
|
|
368
|
+
workspace: { name: 'demo', initialized: true },
|
|
369
|
+
repos: {},
|
|
370
|
+
}, null, 2) + '\n');
|
|
371
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
372
|
+
check('missing templateVersion warns', out.error.join('\n').includes('no templateVersion'));
|
|
373
|
+
check('no baseline written without a version',
|
|
374
|
+
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
375
|
+
} finally {
|
|
376
|
+
rmSync(root, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 10. An existing baseline is left alone: no fetch, no rewrite, nothing
|
|
381
|
+
// staged in the payload.
|
|
382
|
+
{
|
|
383
|
+
const root = buildWorkspace({ baseline: true });
|
|
384
|
+
try {
|
|
385
|
+
const before = readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8');
|
|
386
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
387
|
+
check('existing baseline untouched',
|
|
388
|
+
readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === before);
|
|
389
|
+
check('reconstruction not reported', !out.log.join('\n').includes('Reconstructed'));
|
|
390
|
+
check('no reconstructed baseline staged alongside',
|
|
391
|
+
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
392
|
+
} finally {
|
|
393
|
+
rmSync(root, { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 11. A CORRUPT baseline file is as good as none: reconstruction runs and
|
|
398
|
+
// stages its result in the payload; the broken launcher file itself is
|
|
399
|
+
// left exactly as it was (gh:186).
|
|
400
|
+
{
|
|
401
|
+
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
402
|
+
try {
|
|
403
|
+
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
404
|
+
writeFileSync(join(root, '.claude', '.template-baseline.json'), '{ not json at all\n');
|
|
405
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
406
|
+
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
407
|
+
}));
|
|
408
|
+
check('a corrupt baseline does not block reconstruction',
|
|
409
|
+
existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
410
|
+
check('the corrupt file is left as it was',
|
|
411
|
+
readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === '{ not json at all\n');
|
|
412
|
+
check('reconstruction reported despite the corrupt file',
|
|
413
|
+
out.log.join('\n').includes('Reconstructed template baseline'));
|
|
414
|
+
} finally {
|
|
415
|
+
rmSync(root, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
115
419
|
if (failed > 0) {
|
|
116
420
|
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
117
421
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -25,11 +25,31 @@ adapter takes an injectable `spawnFn`, so tests mock subprocesses rather than ru
|
|
|
25
25
|
|
|
26
26
|
## Configuration
|
|
27
27
|
|
|
28
|
-
`workspace.json` → `workspace.forge`: `{ "type": "github" }`. `type` names the adapter
|
|
29
|
-
`github`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
`workspace.json` → `workspace.forge`: `{ "type": "github" | "gitlab" }`. `type` names the adapter
|
|
29
|
+
module — `github.mjs` wraps `gh`, `gitlab.mjs` wraps `glab`; both implement the same method
|
|
30
|
+
surface. With no `type`, the adapter is picked from the target repo's host: gitlab.com (or the
|
|
31
|
+
configured `host`) selects GitLab, anything else stays GitHub — which is how one workspace can
|
|
32
|
+
mix GitHub and GitLab repos, each origin naming its own forge. Optional `repo` is the project
|
|
33
|
+
slug (`owner/name` on GitHub, `group/sub/project` at any depth on GitLab); unset or `"auto"`
|
|
34
|
+
resolves from the git `origin` remote. A self-managed GitLab sets `host` — glab receives it as
|
|
35
|
+
`GITLAB_HOST` (and `--hostname` on `glab api`), always, so an exported foreign `GITLAB_HOST`
|
|
36
|
+
never leaks in. One host per workspace: the origin-based selection recognizes gitlab.com plus
|
|
37
|
+
exactly this host, so a workspace spanning two self-managed instances can't be expressed. An
|
|
38
|
+
origin with an explicit non-default port keeps the port in its host, so `host` carries it too
|
|
39
|
+
(`gitlab.example.com:8443`); default ports drop (`github.com:443` reads as github.com). An
|
|
40
|
+
absent `workspace.forge` is treated as `{ type: 'github' }`; setting it to `false` makes every
|
|
41
|
+
adapter method throw `FORGE_DISABLED`.
|
|
42
|
+
|
|
43
|
+
GitLab gaps, by design: `releaseCreate` with `generateNotes: true` throws `NOT_SUPPORTED` —
|
|
44
|
+
GitLab cannot generate notes from merged MRs, so callers (the `/release` skill does) assemble
|
|
45
|
+
the merged-MR list themselves and pass it as `notes` with `generateNotes: false`. Workflow runs
|
|
46
|
+
map onto pipelines: `workflowRunFind` ignores the workflow name (one pipeline per ref) and
|
|
47
|
+
`workflowRunWatch` polls instead of streaming, treating a `manual` job as still running —
|
|
48
|
+
approving it resumes the pipeline. `reviewDecision` is always empty, and `mergeStateStatus`
|
|
49
|
+
carries GitLab's `detailed_merge_status` vocabulary (`not_approvable`, `ci_still_running`, …),
|
|
50
|
+
not GitHub's — treat it as opaque rather than comparing GitHub strings. `prMerge` disables
|
|
51
|
+
glab's auto-merge (which queues the merge behind a passing pipeline and still exits 0) and
|
|
52
|
+
verifies the MR actually landed, throwing `MergeRejected` otherwise.
|
|
33
53
|
|
|
34
54
|
## Deliberate exceptions — do not "fix" these
|
|
35
55
|
|
|
@@ -40,7 +60,8 @@ alone:
|
|
|
40
60
|
See `work-item-tracking.md`. The two abstractions are intentionally separate.
|
|
41
61
|
- **`/setup-tracker` repo configuration** — `gh repo view --json hasIssuesEnabled` and
|
|
42
62
|
`gh api repos/{slug} -X PATCH -f has_issues=true` are GitHub-API-specific setup, not
|
|
43
|
-
cross-cutting operations.
|
|
63
|
+
cross-cutting operations. GitLab projects have issues on by default, so its setup flow
|
|
64
|
+
needs no equivalent check.
|
|
44
65
|
- **`gh repo view` as a remote-type probe** — a one-line capability check, not an operation.
|
|
45
66
|
- **`gh repo create`** — an interactive one-off when a workspace has no remote.
|
|
46
67
|
- **Manual recovery prose** — `gh run rerun`, `gh run view`, `gh release view` in `/release`
|