@ulysses-ai/create-workspace 0.23.3-beta.0 → 0.24.1-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/package.json +2 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/_utils.mjs +70 -0
- package/template/_claude/hooks/session-start.mjs +21 -1
- package/template/_claude/rules/work-item-tracking.md +6 -3
- package/template/_claude/rules/workspace-structure.md +1 -1
- package/template/_claude/scripts/chat-record.mjs +43 -26
- package/template/_claude/scripts/classify-update.mjs +194 -32
- package/template/_claude/scripts/forges/github.mjs +51 -1
- package/template/_claude/scripts/forges/gitlab.mjs +69 -0
- package/template/_claude/scripts/forges/interface.mjs +19 -2
- package/template/_claude/scripts/maintenance-audit.mjs +325 -3
- package/template/_claude/scripts/merge-mode.mjs +22 -1
- package/template/_claude/scripts/release-leak-audit.mjs +480 -0
- package/template/_claude/scripts/task-pr.mjs +316 -72
- package/template/_claude/scripts/task-worktree.mjs +9 -1
- package/template/_claude/scripts/template-merge.mjs +21 -4
- package/template/_claude/scripts/template-modifications.mjs +152 -0
- package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
- package/template/_claude/scripts/trackers/interface.mjs +47 -0
- package/template/_claude/skills/braindump/SKILL.md +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +22 -12
- package/template/_claude/skills/handoff/SKILL.md +1 -1
- package/template/_claude/skills/maintenance/SKILL.md +8 -3
- package/template/_claude/skills/release/SKILL.md +20 -6
- package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
- package/template/_claude/skills/start-work/SKILL.md +23 -5
- package/template/_claude/skills/workspace-init/SKILL.md +51 -9
- package/template/_claude/skills/workspace-update/SKILL.md +23 -15
- package/template/workspace.json.tmpl +2 -1
- package/lib/init.test.mjs +0 -75
- package/lib/payload.test.mjs +0 -210
- package/lib/scaffold.test.mjs +0 -79
- package/lib/upgrade.test.mjs +0 -547
package/lib/upgrade.test.mjs
DELETED
|
@@ -1,547 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Unit tests for upgrade.mjs
|
|
3
|
-
// Run: node lib/upgrade.test.mjs
|
|
4
|
-
//
|
|
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), baseline reconstruction for pre-baseline workspaces (gh:186), and
|
|
10
|
-
// the .template-base/ merge-base staging every upgrade attempts (gh:193)
|
|
11
|
-
// — the tarball fetch is injected so the suite never touches the network.
|
|
12
|
-
import { upgradeWorkspace } from './upgrade.mjs';
|
|
13
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
15
|
-
import { tmpdir } from 'os';
|
|
16
|
-
import { execSync, spawnSync } from 'child_process';
|
|
17
|
-
import { createHash } from 'crypto';
|
|
18
|
-
import { gzipSync } from 'zlib';
|
|
19
|
-
|
|
20
|
-
let failed = 0;
|
|
21
|
-
let passed = 0;
|
|
22
|
-
function check(label, ok) {
|
|
23
|
-
if (ok) { passed++; } else {
|
|
24
|
-
failed++;
|
|
25
|
-
console.error(` FAIL: ${label}`);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function captureConsole(fn) {
|
|
30
|
-
const out = { log: [], error: [] };
|
|
31
|
-
const origLog = console.log;
|
|
32
|
-
const origError = console.error;
|
|
33
|
-
console.log = (...a) => { out.log.push(a.join(' ')); };
|
|
34
|
-
console.error = (...a) => { out.error.push(a.join(' ')); };
|
|
35
|
-
try {
|
|
36
|
-
return { result: await fn(), out };
|
|
37
|
-
} finally {
|
|
38
|
-
console.log = origLog;
|
|
39
|
-
console.error = origError;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function buildWorkspace({ templateVersion = '0.15.0', baseline = false } = {}) {
|
|
44
|
-
const root = mkdtempSync(join(tmpdir(), 'upgrade-test-'));
|
|
45
|
-
execSync('git init -q -b main', { cwd: root, stdio: 'pipe' });
|
|
46
|
-
execSync('git config user.email test@example.com', { cwd: root, stdio: 'pipe' });
|
|
47
|
-
execSync('git config user.name Test', { cwd: root, stdio: 'pipe' });
|
|
48
|
-
writeFileSync(join(root, 'workspace.json'), JSON.stringify({
|
|
49
|
-
workspace: { name: 'demo', initialized: true, templateVersion },
|
|
50
|
-
repos: {},
|
|
51
|
-
}, null, 2) + '\n');
|
|
52
|
-
if (baseline) {
|
|
53
|
-
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
54
|
-
writeFileSync(join(root, '.claude', '.template-baseline.json'), JSON.stringify({
|
|
55
|
-
templateVersion,
|
|
56
|
-
files: { '.claude/hooks/a.mjs': '0'.repeat(64) },
|
|
57
|
-
}) + '\n');
|
|
58
|
-
}
|
|
59
|
-
execSync('git add workspace.json', { cwd: root, stdio: 'pipe' });
|
|
60
|
-
execSync('git commit -q -m init', { cwd: root, stdio: 'pipe' });
|
|
61
|
-
return root;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function lsFilesPayload(root) {
|
|
65
|
-
return spawnSync('git', ['ls-files', '.workspace-update'], {
|
|
66
|
-
cwd: root,
|
|
67
|
-
encoding: 'utf-8',
|
|
68
|
-
}).stdout.split('\n').filter(Boolean);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---- fixture tarballs (old live-name layout and new inert layout) ----
|
|
72
|
-
|
|
73
|
-
function tarEntry(name, content, type = '0') {
|
|
74
|
-
const body = Buffer.from(content, 'utf8');
|
|
75
|
-
const header = Buffer.alloc(512);
|
|
76
|
-
header.write(name, 0, 100, 'utf8');
|
|
77
|
-
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
|
|
78
|
-
header.write(type, 156, 'ascii');
|
|
79
|
-
header.write('ustar', 257, 'ascii');
|
|
80
|
-
header.write('00', 263, 'ascii');
|
|
81
|
-
let sum = 0;
|
|
82
|
-
for (const byte of header) sum += byte;
|
|
83
|
-
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
|
|
84
|
-
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
|
|
85
|
-
return Buffer.concat([header, body, pad]);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function makeTarball(entries) {
|
|
89
|
-
return gzipSync(Buffer.concat([
|
|
90
|
-
...entries.map(([name, content, type]) => tarEntry(name, content, type)),
|
|
91
|
-
Buffer.alloc(1024),
|
|
92
|
-
]));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
96
|
-
// The fixtures are ASCII with LF endings, so a plain sha256 of the text
|
|
97
|
-
// equals template-baseline's CRLF-normalized hashBytes.
|
|
98
|
-
const hashFile = (p) => sha(readFileSync(p, 'utf8'));
|
|
99
|
-
|
|
100
|
-
// A v0.18-era package: every path rooted under package/ (npm's layout),
|
|
101
|
-
// template/ still carried the live names.
|
|
102
|
-
const OLD_LAYOUT_TARBALL = makeTarball([
|
|
103
|
-
['package/package.json', '{"name":"@ulysses-ai/create-workspace"}\n'],
|
|
104
|
-
['package/template/CLAUDE.md.tmpl', '## Workspace: {{project-name}}\n'],
|
|
105
|
-
['package/template/.claude/hooks/session-start.mjs', '// hooks v0.15\n'],
|
|
106
|
-
['package/template/.claude/scripts/old.mjs', '// script\n'],
|
|
107
|
-
['package/template/.mcp.json', '{"mcpServers":{}}\n'],
|
|
108
|
-
['package/template/.claudeignore', 'scratch/\n'],
|
|
109
|
-
]);
|
|
110
|
-
|
|
111
|
-
// A v0.19-on package: template/ stores the inert names.
|
|
112
|
-
const NEW_LAYOUT_TARBALL = makeTarball([
|
|
113
|
-
['package/template/_claude/hooks/session-start.mjs', '// hooks v0.19\n'],
|
|
114
|
-
['package/template/_claude/scripts/new.mjs', '// script\n'],
|
|
115
|
-
['package/template/_mcp.json', '{"mcpServers":{}}\n'],
|
|
116
|
-
['package/template/.claudeignore', 'scratch/\n'],
|
|
117
|
-
]);
|
|
118
|
-
|
|
119
|
-
// Injected fetch results: null means "not fetchable", a throwing fetch must
|
|
120
|
-
// never be reached.
|
|
121
|
-
const noTarball = async () => null;
|
|
122
|
-
const mustNotFetch = async () => { throw new Error('fetchTarball must not be called'); };
|
|
123
|
-
|
|
124
|
-
console.log('# upgrade');
|
|
125
|
-
|
|
126
|
-
// 1. Clean workspace with a baseline, offline: payload staged, no
|
|
127
|
-
// tracked-payload warning, the baseline left in place, no reconstruction,
|
|
128
|
-
// and no merge base — the failure warns but never fails the upgrade.
|
|
129
|
-
{
|
|
130
|
-
const root = buildWorkspace({ baseline: true });
|
|
131
|
-
try {
|
|
132
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
133
|
-
const stderr = out.error.join('\n');
|
|
134
|
-
check('no tracked-payload warning on a clean workspace',
|
|
135
|
-
!stderr.includes('git rm -r --cached'));
|
|
136
|
-
check('payload staged', existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
137
|
-
check('freshly staged payload is not tracked', lsFilesPayload(root).length === 0);
|
|
138
|
-
check('staging reported', out.log.join('\n').includes('Staged template payload'));
|
|
139
|
-
check('existing baseline named as left in place',
|
|
140
|
-
out.log.join('\n').includes('Template baseline already present'));
|
|
141
|
-
check('no reconstructed baseline staged when one exists',
|
|
142
|
-
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
143
|
-
check('offline warns about the missing merge base',
|
|
144
|
-
stderr.includes('no merge base staged for locally modified files'));
|
|
145
|
-
check('no template base staged offline',
|
|
146
|
-
!existsSync(join(root, '.workspace-update', '.template-base')));
|
|
147
|
-
} finally {
|
|
148
|
-
rmSync(root, { recursive: true, force: true });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 2. Workspace tracking .workspace-update/ from a pre-v0.19 upgrade: the
|
|
153
|
-
// warning fires and names the untrack command.
|
|
154
|
-
{
|
|
155
|
-
const root = buildWorkspace({ baseline: true });
|
|
156
|
-
try {
|
|
157
|
-
// Simulate the old behavior: a payload committed tracked.
|
|
158
|
-
const payload = join(root, '.workspace-update');
|
|
159
|
-
mkdirSync(payload, { recursive: true });
|
|
160
|
-
writeFileSync(join(payload, '.manifest.json'), '{"action":"upgrade"}\n');
|
|
161
|
-
execSync('git add -f .workspace-update', { cwd: root, stdio: 'pipe' });
|
|
162
|
-
execSync('git commit -q -m "track payload"', { cwd: root, stdio: 'pipe' });
|
|
163
|
-
check('fixture really tracks the payload', lsFilesPayload(root).length > 0);
|
|
164
|
-
|
|
165
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
166
|
-
const stderr = out.error.join('\n');
|
|
167
|
-
check('tracked-payload warning fires', stderr.includes('.workspace-update/') && stderr.includes('tracked by git'));
|
|
168
|
-
check('warning names the untrack command', stderr.includes('git rm -r --cached .workspace-update'));
|
|
169
|
-
} finally {
|
|
170
|
-
rmSync(root, { recursive: true, force: true });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 3. Not a git repo at all: staging still works, warning stays silent.
|
|
175
|
-
{
|
|
176
|
-
const root = mkdtempSync(join(tmpdir(), 'upgrade-nogit-'));
|
|
177
|
-
try {
|
|
178
|
-
writeFileSync(join(root, 'workspace.json'), JSON.stringify({
|
|
179
|
-
workspace: { name: 'demo', initialized: true, templateVersion: '0.15.0' },
|
|
180
|
-
repos: {},
|
|
181
|
-
}, null, 2) + '\n');
|
|
182
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
183
|
-
check('no warning outside a git repo', !out.error.join('\n').includes('tracked by git'));
|
|
184
|
-
check('payload staged outside a git repo', existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
185
|
-
} finally {
|
|
186
|
-
rmSync(root, { recursive: true, force: true });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// 4. --upgrade installs the payload's workspace-update skill, REPLACING
|
|
191
|
-
// whatever old copy the workspace carries. An old SKILL.md that matches
|
|
192
|
-
// neither the payload's copy nor any known-shipped copy is the operator's
|
|
193
|
-
// own edit — it is backed up before the swap (gh:186).
|
|
194
|
-
{
|
|
195
|
-
const root = buildWorkspace({ baseline: true });
|
|
196
|
-
try {
|
|
197
|
-
// An outdated installed skill with a stale extra file.
|
|
198
|
-
const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
|
|
199
|
-
mkdirSync(oldSkill, { recursive: true });
|
|
200
|
-
writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
|
|
201
|
-
writeFileSync(join(oldSkill, 'OLD-EXTRA.md'), 'stale\n');
|
|
202
|
-
|
|
203
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
204
|
-
const newSkill = readFileSync(join(oldSkill, 'SKILL.md'), 'utf8');
|
|
205
|
-
check('installed skill is the payload copy, not the old one',
|
|
206
|
-
newSkill === readFileSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md'), 'utf8')
|
|
207
|
-
&& newSkill !== '# old flow\n');
|
|
208
|
-
check('stale files inside the old skill do not survive', !existsSync(join(oldSkill, 'OLD-EXTRA.md')));
|
|
209
|
-
check('skill install reported', out.log.join('\n').includes('workspace-update skill'));
|
|
210
|
-
check('a locally modified old skill is backed up',
|
|
211
|
-
readFileSync(join(oldSkill, 'SKILL.md.local-backup'), 'utf8') === '# old flow\n');
|
|
212
|
-
check('backup reported', out.log.join('\n').includes('SKILL.md.local-backup'));
|
|
213
|
-
// The staged payload itself is unchanged: its skill directory stays.
|
|
214
|
-
check('payload still carries the skill directory',
|
|
215
|
-
existsSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
216
|
-
check('no staging directory left behind',
|
|
217
|
-
!existsSync(join(root, '.claude', 'skills', '.workspace-update.incoming')));
|
|
218
|
-
} finally {
|
|
219
|
-
rmSync(root, { recursive: true, force: true });
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// 4b. A PRISTINE old skill — its bytes match what the installed version
|
|
224
|
-
// shipped (the reconstructed baseline records the hash) — replaces
|
|
225
|
-
// without a backup: nothing of the operator's is at risk.
|
|
226
|
-
{
|
|
227
|
-
// The tarball of the installed version shipped exactly this skill.
|
|
228
|
-
const PRISTINE_TARBALL = makeTarball([
|
|
229
|
-
['package/template/.claude/skills/workspace-update/SKILL.md', '# pristine old flow\n'],
|
|
230
|
-
['package/template/.claudeignore', 'scratch/\n'],
|
|
231
|
-
]);
|
|
232
|
-
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
233
|
-
try {
|
|
234
|
-
const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
|
|
235
|
-
mkdirSync(oldSkill, { recursive: true });
|
|
236
|
-
writeFileSync(join(oldSkill, 'SKILL.md'), '# pristine old flow\n');
|
|
237
|
-
await captureConsole(() => upgradeWorkspace(root, { fetchTarball: async () => PRISTINE_TARBALL }));
|
|
238
|
-
check('pristine old skill replaced without a backup',
|
|
239
|
-
!existsSync(join(oldSkill, 'SKILL.md.local-backup'))
|
|
240
|
-
&& readFileSync(join(oldSkill, 'SKILL.md'), 'utf8') !== '# pristine old flow\n');
|
|
241
|
-
} finally {
|
|
242
|
-
rmSync(root, { recursive: true, force: true });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// The same workspace but with the operator's own edit on top: backed up.
|
|
246
|
-
const editedRoot = buildWorkspace({ templateVersion: '0.15.0' });
|
|
247
|
-
try {
|
|
248
|
-
const oldSkill = join(editedRoot, '.claude', 'skills', 'workspace-update');
|
|
249
|
-
mkdirSync(oldSkill, { recursive: true });
|
|
250
|
-
writeFileSync(join(oldSkill, 'SKILL.md'), '# my customized flow\n');
|
|
251
|
-
await captureConsole(() => upgradeWorkspace(editedRoot, { fetchTarball: async () => PRISTINE_TARBALL }));
|
|
252
|
-
check('a customized old skill is backed up when the shipped hash is known',
|
|
253
|
-
readFileSync(join(oldSkill, 'SKILL.md.local-backup'), 'utf8') === '# my customized flow\n');
|
|
254
|
-
} finally {
|
|
255
|
-
rmSync(editedRoot, { recursive: true, force: true });
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// 4c. A copy that fails partway leaves the installed skill in place — the
|
|
260
|
-
// swap happens only after the staging copy succeeded.
|
|
261
|
-
{
|
|
262
|
-
const root = buildWorkspace({ baseline: true });
|
|
263
|
-
try {
|
|
264
|
-
const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
|
|
265
|
-
mkdirSync(oldSkill, { recursive: true });
|
|
266
|
-
writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
|
|
267
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
268
|
-
fetchTarball: noTarball,
|
|
269
|
-
copySkill: () => { throw new Error('disk full'); },
|
|
270
|
-
}));
|
|
271
|
-
check('a failing copy keeps the installed skill',
|
|
272
|
-
readFileSync(join(oldSkill, 'SKILL.md'), 'utf8') === '# old flow\n');
|
|
273
|
-
check('a failing copy warns', out.error.join('\n').includes('could not install the current workspace-update skill'));
|
|
274
|
-
check('a failing copy leaves no staging directory',
|
|
275
|
-
!existsSync(join(root, '.claude', 'skills', '.workspace-update.incoming')));
|
|
276
|
-
} finally {
|
|
277
|
-
rmSync(root, { recursive: true, force: true });
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// 5. No baseline + old live-name tarball: reconstruction stages a baseline
|
|
282
|
-
// for the INSTALLED version INSIDE THE PAYLOAD — never the launcher —
|
|
283
|
-
// marked reconstructed (gh:186), and the same fetch stages the template
|
|
284
|
-
// content as the merge base under live names (gh:193).
|
|
285
|
-
{
|
|
286
|
-
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
287
|
-
try {
|
|
288
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
289
|
-
fetchTarball: async (version) => (version === '0.15.0' ? OLD_LAYOUT_TARBALL : null),
|
|
290
|
-
}));
|
|
291
|
-
const baselinePath = join(root, '.workspace-update', '.template-baseline.reconstructed.json');
|
|
292
|
-
check('reconstructed baseline staged in the payload', existsSync(baselinePath));
|
|
293
|
-
check('launcher gets no new baseline file',
|
|
294
|
-
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
295
|
-
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
296
|
-
check('baseline records the installed version', baseline.templateVersion === '0.15.0');
|
|
297
|
-
check('baseline is marked reconstructed', baseline.reconstructed === true);
|
|
298
|
-
check('live-name layout hashes map to installed keys',
|
|
299
|
-
baseline.files['.claude/hooks/session-start.mjs'] === sha('// hooks v0.15\n'));
|
|
300
|
-
check('.mcp.json baselined from the old live name',
|
|
301
|
-
baseline.files['.mcp.json'] === sha('{"mcpServers":{}}\n'));
|
|
302
|
-
check('non-verbatim payload roots are not baselined',
|
|
303
|
-
!Object.keys(baseline.files).some((k) => k.endsWith('.tmpl') || k.startsWith('package')));
|
|
304
|
-
check('reconstruction reported', out.log.join('\n').includes('Reconstructed template baseline'));
|
|
305
|
-
check('reconstruction names its staged location',
|
|
306
|
-
out.log.join('\n').includes('.template-baseline.reconstructed.json'));
|
|
307
|
-
check('the fetch is announced on stderr before it can stall',
|
|
308
|
-
out.error.join('\n').includes('Fetching the v0.15.0 template as the merge base'));
|
|
309
|
-
|
|
310
|
-
const base = join(root, '.workspace-update', '.template-base');
|
|
311
|
-
check('merge base staged in the payload',
|
|
312
|
-
existsSync(join(base, '.claude', 'hooks', 'session-start.mjs')));
|
|
313
|
-
check('merge base carries the tarball content',
|
|
314
|
-
readFileSync(join(base, '.claude', 'hooks', 'session-start.mjs'), 'utf8') === '// hooks v0.15\n');
|
|
315
|
-
check('merge base uses live names only',
|
|
316
|
-
!existsSync(join(base, '_claude')) && !existsSync(join(base, '_mcp.json')));
|
|
317
|
-
check('merge base keeps non-verbatim files out',
|
|
318
|
-
!existsSync(join(base, 'CLAUDE.md.tmpl')));
|
|
319
|
-
check('base staging reported',
|
|
320
|
-
out.log.join('\n').includes('.template-base'));
|
|
321
|
-
// The invariant template-merge.mjs relies on: what the baseline
|
|
322
|
-
// records is exactly what the base holds.
|
|
323
|
-
check('staged base hashes to the reconstructed baseline entry',
|
|
324
|
-
hashFile(join(base, '.claude', 'hooks', 'session-start.mjs')) === baseline.files['.claude/hooks/session-start.mjs']);
|
|
325
|
-
} finally {
|
|
326
|
-
rmSync(root, { recursive: true, force: true });
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// 6. No baseline + new inert-name tarball: same reconstruction through the
|
|
331
|
-
// _claude/_mcp.json layout, and the merge base lands under live names.
|
|
332
|
-
{
|
|
333
|
-
const root = buildWorkspace({ templateVersion: '0.19.0' });
|
|
334
|
-
try {
|
|
335
|
-
await captureConsole(() => upgradeWorkspace(root, {
|
|
336
|
-
fetchTarball: async () => NEW_LAYOUT_TARBALL,
|
|
337
|
-
}));
|
|
338
|
-
const baseline = JSON.parse(
|
|
339
|
-
readFileSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json'), 'utf8'));
|
|
340
|
-
check('inert-name layout maps to installed keys',
|
|
341
|
-
baseline.files['.claude/hooks/session-start.mjs'] === sha('// hooks v0.19\n'));
|
|
342
|
-
check('_mcp.json baselined as .mcp.json',
|
|
343
|
-
baseline.files['.mcp.json'] === sha('{"mcpServers":{}}\n'));
|
|
344
|
-
check('baseline marked reconstructed', baseline.reconstructed === true);
|
|
345
|
-
check('launcher still has no baseline of its own',
|
|
346
|
-
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
347
|
-
const base = join(root, '.workspace-update', '.template-base');
|
|
348
|
-
check('inert layout stages the base under live names',
|
|
349
|
-
readFileSync(join(base, '.claude', 'hooks', 'session-start.mjs'), 'utf8') === '// hooks v0.19\n');
|
|
350
|
-
check('_mcp.json staged into the base as .mcp.json',
|
|
351
|
-
readFileSync(join(base, '.mcp.json'), 'utf8') === '{"mcpServers":{}}\n');
|
|
352
|
-
check('no inert names inside the base',
|
|
353
|
-
!existsSync(join(base, '_claude')) && !existsSync(join(base, '_mcp.json')));
|
|
354
|
-
} finally {
|
|
355
|
-
rmSync(root, { recursive: true, force: true });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// 7. Fetch failure and unknown version: warn and continue — the upgrade
|
|
360
|
-
// itself never fails, and the payload plus skill still land.
|
|
361
|
-
{
|
|
362
|
-
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
363
|
-
try {
|
|
364
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
365
|
-
const stderr = out.error.join('\n');
|
|
366
|
-
check('fetch failure warns about the missing baseline',
|
|
367
|
-
stderr.includes('no template baseline written'));
|
|
368
|
-
check('fetch failure names the per-file fallback',
|
|
369
|
-
stderr.includes('ask about every changed file'));
|
|
370
|
-
check('no baseline written on fetch failure',
|
|
371
|
-
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
372
|
-
check('no reconstructed baseline staged on fetch failure',
|
|
373
|
-
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
374
|
-
check('no template base staged on fetch failure',
|
|
375
|
-
!existsSync(join(root, '.workspace-update', '.template-base')));
|
|
376
|
-
check('upgrade continues after fetch failure',
|
|
377
|
-
existsSync(join(root, '.workspace-update', '.manifest.json'))
|
|
378
|
-
&& existsSync(join(root, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
379
|
-
} finally {
|
|
380
|
-
rmSync(root, { recursive: true, force: true });
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// 8. A corrupt tarball is a warning, not a crash.
|
|
385
|
-
{
|
|
386
|
-
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
387
|
-
try {
|
|
388
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
389
|
-
fetchTarball: async () => Buffer.from('not a tarball at all'),
|
|
390
|
-
}));
|
|
391
|
-
check('corrupt tarball warns', out.error.join('\n').includes('no template baseline written'));
|
|
392
|
-
check('corrupt tarball does not fail the upgrade',
|
|
393
|
-
existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
394
|
-
} finally {
|
|
395
|
-
rmSync(root, { recursive: true, force: true });
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// 9. No usable templateVersion: named warning, no network attempt.
|
|
400
|
-
{
|
|
401
|
-
const root = buildWorkspace({ templateVersion: null });
|
|
402
|
-
try {
|
|
403
|
-
writeFileSync(join(root, 'workspace.json'), JSON.stringify({
|
|
404
|
-
workspace: { name: 'demo', initialized: true },
|
|
405
|
-
repos: {},
|
|
406
|
-
}, null, 2) + '\n');
|
|
407
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
|
|
408
|
-
check('missing templateVersion warns', out.error.join('\n').includes('no templateVersion'));
|
|
409
|
-
check('no baseline written without a version',
|
|
410
|
-
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
411
|
-
} finally {
|
|
412
|
-
rmSync(root, { recursive: true, force: true });
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// 10. An existing baseline is left alone: no rewrite, nothing reconstructed
|
|
417
|
-
// alongside it. The fetch still happens (the merge base needs the
|
|
418
|
-
// content) but its result never touches the baseline.
|
|
419
|
-
{
|
|
420
|
-
const root = buildWorkspace({ baseline: true });
|
|
421
|
-
try {
|
|
422
|
-
const before = readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8');
|
|
423
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
424
|
-
check('existing baseline untouched',
|
|
425
|
-
readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === before);
|
|
426
|
-
check('reconstruction not reported', !out.log.join('\n').includes('Reconstructed'));
|
|
427
|
-
check('no reconstructed baseline staged alongside',
|
|
428
|
-
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
429
|
-
} finally {
|
|
430
|
-
rmSync(root, { recursive: true, force: true });
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// 11. A CORRUPT baseline file is as good as none: reconstruction runs and
|
|
435
|
-
// stages its result in the payload; the broken launcher file itself is
|
|
436
|
-
// left exactly as it was (gh:186).
|
|
437
|
-
{
|
|
438
|
-
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
439
|
-
try {
|
|
440
|
-
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
441
|
-
writeFileSync(join(root, '.claude', '.template-baseline.json'), '{ not json at all\n');
|
|
442
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
443
|
-
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
444
|
-
}));
|
|
445
|
-
check('a corrupt baseline does not block reconstruction',
|
|
446
|
-
existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
447
|
-
check('the corrupt file is left as it was',
|
|
448
|
-
readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === '{ not json at all\n');
|
|
449
|
-
check('reconstruction reported despite the corrupt file',
|
|
450
|
-
out.log.join('\n').includes('Reconstructed template baseline'));
|
|
451
|
-
} finally {
|
|
452
|
-
rmSync(root, { recursive: true, force: true });
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// 12. A workspace WITH a baseline still gets the merge base: the fetch now
|
|
457
|
-
// serves the base staging alone, and no reconstruction happens (gh:193).
|
|
458
|
-
{
|
|
459
|
-
const root = buildWorkspace({ baseline: true, templateVersion: '0.15.0' });
|
|
460
|
-
try {
|
|
461
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
462
|
-
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
463
|
-
}));
|
|
464
|
-
const base = join(root, '.workspace-update', '.template-base');
|
|
465
|
-
check('merge base staged beside an existing baseline',
|
|
466
|
-
readFileSync(join(base, '.claude', 'hooks', 'session-start.mjs'), 'utf8') === '// hooks v0.15\n');
|
|
467
|
-
check('no reconstruction next to an existing baseline',
|
|
468
|
-
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json'))
|
|
469
|
-
&& !out.log.join('\n').includes('Reconstructed'));
|
|
470
|
-
check('existing baseline named as left in place',
|
|
471
|
-
out.log.join('\n').includes('Template baseline already present'));
|
|
472
|
-
} finally {
|
|
473
|
-
rmSync(root, { recursive: true, force: true });
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// 13. The offline skip with a baseline present: the upgrade completes, only
|
|
478
|
-
// the merge base is missing, and the warning says exactly that — not the
|
|
479
|
-
// pre-baseline fallback wording.
|
|
480
|
-
{
|
|
481
|
-
const root = buildWorkspace({ baseline: true, templateVersion: '0.15.0' });
|
|
482
|
-
try {
|
|
483
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
484
|
-
const stderr = out.error.join('\n');
|
|
485
|
-
check('offline with baseline warns about the merge base only',
|
|
486
|
-
stderr.includes('no merge base staged') && !stderr.includes('ask about every changed file'));
|
|
487
|
-
check('no template base staged', !existsSync(join(root, '.workspace-update', '.template-base')));
|
|
488
|
-
check('the upgrade still completes',
|
|
489
|
-
existsSync(join(root, '.workspace-update', '.manifest.json'))
|
|
490
|
-
&& existsSync(join(root, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
491
|
-
} finally {
|
|
492
|
-
rmSync(root, { recursive: true, force: true });
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// 14. Staging is best-effort end to end: a failing baseline build warns and
|
|
497
|
-
// still stages the merge base; a base copy that fails partway removes
|
|
498
|
-
// the partial base — never trusted as an ancestor — and the upgrade
|
|
499
|
-
// itself never fails (gh:193).
|
|
500
|
-
{
|
|
501
|
-
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
502
|
-
try {
|
|
503
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
504
|
-
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
505
|
-
buildBase: () => { throw new Error('EACCES: permission denied'); },
|
|
506
|
-
}));
|
|
507
|
-
const stderr = out.error.join('\n');
|
|
508
|
-
check('baseline build failure warns', stderr.includes('no template baseline written'));
|
|
509
|
-
check('no reconstructed baseline on a build failure',
|
|
510
|
-
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
511
|
-
check('the merge base still stages after a baseline failure',
|
|
512
|
-
existsSync(join(root, '.workspace-update', '.template-base', '.claude', 'hooks', 'session-start.mjs')));
|
|
513
|
-
} finally {
|
|
514
|
-
rmSync(root, { recursive: true, force: true });
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// 15. A base copy failing MID-WAY leaves a partial .template-base on disk
|
|
519
|
-
// for a moment — it must be removed, not left where template-merge
|
|
520
|
-
// could trust it.
|
|
521
|
-
{
|
|
522
|
-
const root = buildWorkspace({ baseline: true, templateVersion: '0.15.0' });
|
|
523
|
-
try {
|
|
524
|
-
let calls = 0;
|
|
525
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
526
|
-
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
527
|
-
copyBase: (src, dest, opts) => {
|
|
528
|
-
if (calls++ > 0) throw new Error('disk full');
|
|
529
|
-
cpSync(src, dest, opts); // the first root lands, the rest fail
|
|
530
|
-
},
|
|
531
|
-
}));
|
|
532
|
-
check('a mid-copy failure warns about the merge base',
|
|
533
|
-
out.error.join('\n').includes('could not stage the merge base'));
|
|
534
|
-
check('the partial base is removed',
|
|
535
|
-
!existsSync(join(root, '.workspace-update', '.template-base')));
|
|
536
|
-
check('the upgrade completes after a base-copy failure',
|
|
537
|
-
existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
538
|
-
} finally {
|
|
539
|
-
rmSync(root, { recursive: true, force: true });
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (failed > 0) {
|
|
544
|
-
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
545
|
-
process.exit(1);
|
|
546
|
-
}
|
|
547
|
-
console.log(`${passed} checks passed`);
|