@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.
Files changed (30) hide show
  1. package/lib/init.mjs +9 -0
  2. package/lib/init.test.mjs +75 -0
  3. package/lib/payload.mjs +170 -2
  4. package/lib/payload.test.mjs +158 -3
  5. package/lib/scaffold.mjs +8 -0
  6. package/lib/scaffold.test.mjs +20 -0
  7. package/lib/upgrade.mjs +148 -6
  8. package/lib/upgrade.test.mjs +319 -15
  9. package/package.json +1 -1
  10. package/template/_claude/rules/forge-operations.md +27 -6
  11. package/template/_claude/scripts/chat-record.mjs +51 -4
  12. package/template/_claude/scripts/classify-update.mjs +474 -38
  13. package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
  14. package/template/_claude/scripts/forges/gitlab.mjs +450 -18
  15. package/template/_claude/scripts/forges/interface.mjs +39 -6
  16. package/template/_claude/scripts/maintenance-audit.mjs +0 -0
  17. package/template/_claude/scripts/merge-mode.mjs +96 -12
  18. package/template/_claude/scripts/migrate-sessions.mjs +232 -24
  19. package/template/_claude/scripts/task-pr.mjs +52 -13
  20. package/template/_claude/scripts/task-worktree.mjs +79 -10
  21. package/template/_claude/scripts/template-baseline.mjs +239 -0
  22. package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
  23. package/template/_claude/scripts/trackers/interface.mjs +3 -0
  24. package/template/_claude/skills/complete-work/SKILL.md +7 -4
  25. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
  26. package/template/_claude/skills/release/SKILL.md +24 -8
  27. package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
  28. package/template/_claude/skills/start-work/SKILL.md +15 -2
  29. package/template/_claude/skills/workspace-init/SKILL.md +6 -0
  30. package/template/_claude/skills/workspace-update/SKILL.md +63 -27
package/lib/init.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, readdirSync, statSync } from 'fs';
3
3
  import { join, basename } from 'path';
4
4
  import { stagePayload } from './payload.mjs';
5
+ import { writeBaseline } from '../template/_claude/scripts/template-baseline.mjs';
5
6
 
6
7
  function ensureDir(dir) {
7
8
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -127,6 +128,14 @@ export async function initWorkspace(targetDir) {
127
128
  }
128
129
  }
129
130
 
131
+ // Record the template baseline — the sha256 of every verbatim payload file —
132
+ // so the first /workspace-update can tell template changes from local edits
133
+ // three ways (gh:183). Entries always hold the payload's content, so the
134
+ // components /workspace-init installs verbatim are already covered;
135
+ // /workspace-init refreshes the baseline after the full install anyway.
136
+ writeBaseline(targetDir, payloadDir, { version: toVersion });
137
+ console.log(' Wrote template baseline (.claude/.template-baseline.json)');
138
+
130
139
  console.log(`
131
140
  Workspace initialized (v${toVersion}).
132
141
 
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // Test for init.mjs (--init scaffolding)
3
+ // Run: node lib/init.test.mjs
4
+ //
5
+ // --init stages the template payload, installs the bootstrap skills plus
6
+ // hooks/scripts/lib, and must leave a template baseline behind: the sha256 of
7
+ // every verbatim payload file, so the first /workspace-update classifies
8
+ // three ways instead of treating every template change as a local edit
9
+ // (gh:183).
10
+ import { initWorkspace } from './init.mjs';
11
+ import { createHash } from 'node:crypto';
12
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { tmpdir } from 'os';
15
+ import { fileURLToPath } from 'url';
16
+ import { dirname } from 'path';
17
+
18
+ let failed = 0;
19
+ let passed = 0;
20
+ function check(label, ok) {
21
+ if (ok) { passed++; } else {
22
+ failed++;
23
+ console.error(` FAIL: ${label}`);
24
+ }
25
+ }
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const pkgVersion = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
29
+ const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
30
+
31
+ const root = mkdtempSync(join(tmpdir(), 'init-test-'));
32
+
33
+ try {
34
+ await initWorkspace(root);
35
+
36
+ const baselinePath = join(root, '.claude', '.template-baseline.json');
37
+ check('baseline written at .claude/.template-baseline.json', existsSync(baselinePath));
38
+ const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
39
+
40
+ check(`baseline records the payload version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
41
+ check(
42
+ 'baseline covers the bootstrap skills --init installs',
43
+ typeof baseline.files['.claude/skills/workspace-update/SKILL.md'] === 'string'
44
+ && typeof baseline.files['.claude/skills/workspace-init/SKILL.md'] === 'string',
45
+ );
46
+ check(
47
+ 'baseline covers hooks and scripts',
48
+ typeof baseline.files['.claude/hooks/session-start.mjs'] === 'string'
49
+ && typeof baseline.files['.claude/scripts/classify-update.mjs'] === 'string',
50
+ );
51
+ check(
52
+ 'baseline covers the standalone verbatim files',
53
+ typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
54
+ );
55
+ check(
56
+ 'entry hash matches the installed file byte-for-byte',
57
+ baseline.files['.claude/scripts/classify-update.mjs'] === sha(join(root, '.claude', 'scripts', 'classify-update.mjs')),
58
+ );
59
+ check(
60
+ 'baseline never covers tests (the tarball ships none)',
61
+ Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs')),
62
+ );
63
+ check(
64
+ 'baseline never covers machine-local files',
65
+ !('.claude/settings.local.json' in baseline.files) && !('.claude/.active-session.json' in baseline.files),
66
+ );
67
+ } finally {
68
+ rmSync(root, { recursive: true, force: true });
69
+ }
70
+
71
+ if (failed > 0) {
72
+ console.error(`${failed} check(s) failed, ${passed} passed`);
73
+ process.exit(1);
74
+ }
75
+ console.log(`${passed} checks passed`);
package/lib/payload.mjs CHANGED
@@ -1,7 +1,13 @@
1
1
  // lib/payload.mjs
2
- import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
3
- import { join, dirname } from 'path';
2
+ import {
3
+ existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync,
4
+ mkdtempSync, readdirSync,
5
+ } from 'fs';
6
+ import { join, dirname, resolve, sep } from 'path';
4
7
  import { fileURLToPath } from 'url';
8
+ import { tmpdir } from 'os';
9
+ import { spawnSync } from 'child_process';
10
+ import { gunzipSync } from 'zlib';
5
11
 
6
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
13
  const TEMPLATE_DIR = join(__dirname, '..', 'template');
@@ -59,3 +65,165 @@ export function cleanPayload(targetDir) {
59
65
  rmSync(payloadDir, { recursive: true });
60
66
  }
61
67
  }
68
+
69
+ // ---------- published-tarball reader ----------
70
+ //
71
+ // Baseline reconstruction (lib/upgrade.mjs) needs the template tree of an
72
+ // OLDER published version, and the npm registry is the only place that
73
+ // still has it. Node ships no tar module and the package takes no
74
+ // dependencies, so what follows is a minimal reader for the tar variants
75
+ // npm actually writes: ustar headers, PAX extended headers ('x'), and GNU
76
+ // long names ('L'). Links and devices are skipped — the template is plain
77
+ // files and directories.
78
+
79
+ const TAR_BLOCK = 512;
80
+
81
+ function tarString(buffer, offset, length) {
82
+ const slice = buffer.subarray(offset, offset + length);
83
+ const nul = slice.indexOf(0);
84
+ return slice.subarray(0, nul === -1 ? length : nul).toString('utf8');
85
+ }
86
+
87
+ function tarSize(header) {
88
+ // GNU tar sets the high bit and switches to base-256 once a size no
89
+ // longer fits octal; npm's tarballs stay far below that, but reading
90
+ // the flag is two lines and keeps the reader from truncating silently.
91
+ if (header[124] & 0x80) {
92
+ let value = 0n;
93
+ for (let i = 125; i < 136; i++) value = (value << 8n) | BigInt(header[i]);
94
+ return Number(value);
95
+ }
96
+ const s = tarString(header, 124, 12).trim();
97
+ return s === '' ? 0 : parseInt(s, 8);
98
+ }
99
+
100
+ function ustarName(header) {
101
+ const name = tarString(header, 0, 100);
102
+ const prefix = tarString(header, 345, 155);
103
+ return prefix ? `${prefix}/${name}` : name;
104
+ }
105
+
106
+ // A version that can name a real published tarball. It comes from the
107
+ // workspace's own workspace.json, so anything that is not plain semver is
108
+ // rejected before it reaches a URL or an npm argument.
109
+ function isVersionString(version) {
110
+ return typeof version === 'string' && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version);
111
+ }
112
+
113
+ /**
114
+ * Extract every entry under `prefix` (e.g. 'template/') from a gzipped
115
+ * tarball into `destDir`, returning the number of files written. Entries
116
+ * that would escape destDir are skipped — absolute paths, '..' segments,
117
+ * and anything the resolved destination fails to prove is inside destDir.
118
+ * The tarball comes from the npm registry, but the guard costs a line and
119
+ * tar names written on Windows can carry '\' separators, which are treated
120
+ * as directory separators like '/'.
121
+ */
122
+ export function extractTarballEntries(tarballBytes, destDir, { prefix = '' } = {}) {
123
+ const tar = gunzipSync(tarballBytes);
124
+ const absDestDir = resolve(destDir);
125
+ let files = 0;
126
+ let pendingName = null; // set by a preceding GNU 'L' or PAX 'x' entry
127
+ for (let off = 0; off + TAR_BLOCK <= tar.length;) {
128
+ const header = tar.subarray(off, off + TAR_BLOCK);
129
+ if (header.every((b) => b === 0)) break; // end-of-archive marker
130
+ const size = tarSize(header);
131
+ const type = String.fromCharCode(header[156] || 0x30);
132
+ const dataStart = off + TAR_BLOCK;
133
+ const data = tar.subarray(dataStart, dataStart + size);
134
+ off = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
135
+
136
+ if (type === 'L') {
137
+ pendingName = tarString(data, 0, data.length);
138
+ } else if (type === 'x') {
139
+ // PAX records are "<length> key=value" lines; only the path override
140
+ // matters here.
141
+ const m = data.toString('utf8').match(/(?:^|\n)\d+ path=([^\n]+)/);
142
+ if (m) pendingName = m[1];
143
+ } else {
144
+ // Every other entry consumes or discards the pending name — an 'L'/'x'
145
+ // record naming a link or device must not leak onto the entry after it.
146
+ const raw = (pendingName !== null ? pendingName : ustarName(header)).replace(/\\/g, '/');
147
+ pendingName = null;
148
+ if (type === '5' || type === '0') {
149
+ if (!raw.startsWith(prefix)) continue;
150
+ const relParts = raw.slice(prefix.length).split('/');
151
+ const rel = relParts.join('/');
152
+ const dest = join(absDestDir, rel);
153
+ // Belt and braces beyond the '..' check: whatever the parts say, the
154
+ // resolved destination must land inside destDir.
155
+ const inside = rel !== '' && !relParts.includes('..')
156
+ && resolve(dest).startsWith(absDestDir + sep);
157
+ if (!inside) continue;
158
+ if (type === '5') {
159
+ mkdirSync(dest, { recursive: true });
160
+ } else {
161
+ mkdirSync(dirname(dest), { recursive: true });
162
+ writeFileSync(dest, data);
163
+ files++;
164
+ }
165
+ }
166
+ // Links, devices, fifos, global PAX never appear in an npm tarball of
167
+ // this package — skip them (their name, pending or not, is gone).
168
+ }
169
+ }
170
+ return files;
171
+ }
172
+
173
+ // Both network paths are bounded: a hung registry or npm must not stall an
174
+ // upgrade indefinitely — reconstruction is best-effort and falls back to
175
+ // asking per file on the next /workspace-update.
176
+ const FETCH_TIMEOUT_MS = 15000;
177
+ const NPM_PACK_TIMEOUT_MS = 30000;
178
+
179
+ /**
180
+ * Fetch the published package tarball for `version` as bytes, or null when
181
+ * it cannot be fetched. The registry URL needs no subprocess and works on
182
+ * every platform; `npm pack` is the fallback because it honors the user's
183
+ * configured registry and auth.
184
+ */
185
+ export async function fetchPackageTarball(version, { registry = process.env.NPM_CONFIG_REGISTRY } = {}) {
186
+ if (!isVersionString(version)) return null;
187
+ const base = (registry || 'https://registry.npmjs.org').replace(/\/+$/, '');
188
+ try {
189
+ const res = await fetch(`${base}/@ulysses-ai/create-workspace/-/create-workspace-${version}.tgz`, {
190
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
191
+ });
192
+ if (res.ok) return Buffer.from(await res.arrayBuffer());
193
+ } catch { /* offline, blocked host, timed out — try npm pack */ }
194
+ return npmPackTarball(version);
195
+ }
196
+
197
+ function npmPackTarball(version) {
198
+ const dest = mkdtempSync(join(tmpdir(), 'create-workspace-pack-'));
199
+ try {
200
+ const argv = [
201
+ 'pack', `@ulysses-ai/create-workspace@${version}`, '--pack-destination', dest, '--json',
202
+ ];
203
+ // Windows resolves `npm` to npm.cmd, which spawnSync cannot execute
204
+ // without a shell. The args are a validated semver spec and one temp
205
+ // path — quoting each keeps the joined command line honest even when
206
+ // the temp path contains spaces. Elsewhere argv runs shell-free.
207
+ const isWin = process.platform === 'win32';
208
+ const r = isWin
209
+ ? spawnSync('npm.cmd', argv.map((a) => `"${a}"`), {
210
+ shell: true, timeout: NPM_PACK_TIMEOUT_MS, encoding: 'utf-8',
211
+ })
212
+ : spawnSync('npm', argv, { timeout: NPM_PACK_TIMEOUT_MS, encoding: 'utf-8' });
213
+ if (r.error || r.status !== 0) return null;
214
+ let name = null;
215
+ try {
216
+ const parsed = JSON.parse(r.stdout);
217
+ name = Array.isArray(parsed) ? parsed[0]?.filename : parsed?.filename;
218
+ } catch { /* older npm prints a bare filename line — take the directory listing */ }
219
+ const candidates = name
220
+ ? [join(dest, name)]
221
+ : readdirSync(dest).filter((f) => f.endsWith('.tgz')).map((f) => join(dest, f));
222
+ for (const path of candidates) {
223
+ if (existsSync(path)) return readFileSync(path);
224
+ }
225
+ return null;
226
+ } finally {
227
+ rmSync(dest, { recursive: true, force: true });
228
+ }
229
+ }
@@ -6,11 +6,14 @@
6
6
  // the /workspace-update skill already installed in existing workspaces,
7
7
  // which reads .workspace-update/.claude/... and .workspace-update/.mcp.json.
8
8
  // These tests pin that layout even though template/ itself stores the
9
- // protected paths under the inert names _claude/ and _mcp.json.
10
- import { stagePayload, cleanPayload } from './payload.mjs';
11
- import { mkdtempSync, rmSync, existsSync } from 'fs';
9
+ // protected paths under the inert names _claude/ and _mcp.json. The
10
+ // published-tarball reader (baseline reconstruction's input) is covered
11
+ // here too, against fixture tarballs built in-memory — no network.
12
+ import { stagePayload, cleanPayload, extractTarballEntries, fetchPackageTarball } from './payload.mjs';
13
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
12
14
  import { join } from 'path';
13
15
  import { tmpdir } from 'os';
16
+ import { gzipSync } from 'zlib';
14
17
 
15
18
  let failed = 0;
16
19
  let passed = 0;
@@ -48,6 +51,158 @@ try {
48
51
  rmSync(root, { recursive: true, force: true });
49
52
  }
50
53
 
54
+ // ---- extractTarballEntries ----
55
+
56
+ // Minimal ustar writer: 512-byte header + padded body, matching what npm's
57
+ // tarballs carry for paths under 100 chars.
58
+ function tarEntry(name, content, type = '0') {
59
+ const body = Buffer.from(content, 'utf8');
60
+ const header = Buffer.alloc(512);
61
+ header.write(name, 0, 100, 'utf8');
62
+ header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
63
+ header.write(type, 156, 'ascii');
64
+ header.write('ustar', 257, 'ascii');
65
+ header.write('00', 263, 'ascii');
66
+ let sum = 0;
67
+ for (const byte of header) sum += byte;
68
+ header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
69
+ const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
70
+ return Buffer.concat([header, body, pad]);
71
+ }
72
+
73
+ function makeTarball(entries) {
74
+ return gzipSync(Buffer.concat([
75
+ ...entries.map(([name, content, type]) => tarEntry(name, content, type)),
76
+ Buffer.alloc(1024),
77
+ ]));
78
+ }
79
+
80
+ // A GNU long-name record ('L') followed by the entry it names.
81
+ function makeTarballWithLongName(longPath, content) {
82
+ const blocks = [
83
+ tarEntry('././@LongLink', longPath, 'L'),
84
+ tarEntry(longPath.slice(0, 20), content),
85
+ Buffer.alloc(1024),
86
+ ];
87
+ return gzipSync(Buffer.concat(blocks));
88
+ }
89
+
90
+ // A PAX extended header ('x') carrying a path override, then the entry.
91
+ function makeTarballWithPaxPath(path, content) {
92
+ const record = `${path.length + 20} path=${path}\n`;
93
+ const blocks = [
94
+ tarEntry('PaxHeaders/x', record, 'x'),
95
+ tarEntry('short-name', content),
96
+ Buffer.alloc(1024),
97
+ ];
98
+ return gzipSync(Buffer.concat(blocks));
99
+ }
100
+
101
+ {
102
+ const dest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
103
+ try {
104
+ const files = extractTarballEntries(makeTarball([
105
+ ['template/_claude/hooks/a.mjs', '// a\n'],
106
+ ['template/_claude/rules', '', '5'],
107
+ ['template/_mcp.json', '{"mcpServers":{}}\n'],
108
+ ['template/.claudeignore', 'scratch/\n'],
109
+ ['package/package.json', '{"version":"9.9.9"}\n'],
110
+ ['README.md', 'not under the prefix\n'],
111
+ ]), dest, { prefix: 'template/' });
112
+ check('extracts the three prefixed files', files === 3);
113
+ check('nested file content round-trips',
114
+ readFileSync(join(dest, '_claude', 'hooks', 'a.mjs'), 'utf8') === '// a\n');
115
+ check('directory entry materializes', existsSync(join(dest, '_claude', 'rules')));
116
+ check('entries outside the prefix are not extracted',
117
+ !existsSync(join(dest, '..', 'package')) && !existsSync(join(dest, 'package')));
118
+ check('unprefixed top-level files are not extracted', !existsSync(join(dest, 'README.md')));
119
+
120
+ // A traversal attempt inside the prefix is skipped, not written.
121
+ const guardDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
122
+ extractTarballEntries(makeTarball([
123
+ ['template/../escape.txt', 'nope\n'],
124
+ ['template/ok.txt', 'fine\n'],
125
+ ]), guardDest, { prefix: 'template/' });
126
+ check('.. paths are refused', !existsSync(join(guardDest, '..', 'escape.txt')));
127
+ check('sibling of .. entry still extracted', existsSync(join(guardDest, 'ok.txt')));
128
+ rmSync(guardDest, { recursive: true, force: true });
129
+
130
+ // GNU long names and PAX path overrides both resolve to the real name.
131
+ const longPath = `template/_claude/scripts/${'very-long-name-'.repeat(8)}.mjs`;
132
+ const longDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
133
+ extractTarballEntries(makeTarballWithLongName(longPath, '// long\n'), longDest, { prefix: 'template/' });
134
+ check('GNU long-name entries extract under their full path',
135
+ readFileSync(join(longDest, '_claude', 'scripts', `${'very-long-name-'.repeat(8)}.mjs`), 'utf8') === '// long\n');
136
+ rmSync(longDest, { recursive: true, force: true });
137
+
138
+ const paxDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
139
+ extractTarballEntries(makeTarballWithPaxPath('template/_claude/pax.mjs', '// pax\n'), paxDest, { prefix: 'template/' });
140
+ check('PAX path overrides extract under the overridden path',
141
+ readFileSync(join(paxDest, '_claude', 'pax.mjs'), 'utf8') === '// pax\n');
142
+ rmSync(paxDest, { recursive: true, force: true });
143
+
144
+ // A PAX override that belongs to a NON-file entry (a symlink) dies with
145
+ // it: the file after the link extracts under its own header name, never
146
+ // under the leaked override.
147
+ const leakDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
148
+ const record = `${'template/_claude/leaked.mjs'.length + 20} path=template/_claude/leaked.mjs\n`;
149
+ extractTarballEntries(makeTarball([
150
+ ['PaxHeaders/x', record, 'x'],
151
+ ['target', 'template/elsewhere.mjs', '2'],
152
+ ['template/_claude/own-name.mjs', '// own\n'],
153
+ ]), leakDest, { prefix: 'template/' });
154
+ check('a PAX name followed by a non-file entry does not leak',
155
+ !existsSync(join(leakDest, '_claude', 'leaked.mjs'))
156
+ && !existsSync(join(leakDest, '_claude', 'elsewhere.mjs')));
157
+ check('the entry after a discarded override uses its own name',
158
+ readFileSync(join(leakDest, '_claude', 'own-name.mjs'), 'utf8') === '// own\n');
159
+ rmSync(leakDest, { recursive: true, force: true });
160
+
161
+ // Tar names written on Windows carry '\' separators — treated as
162
+ // directory separators like '/'.
163
+ const bsDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
164
+ extractTarballEntries(makeTarball([
165
+ ['template\\_claude\\hooks\\win.mjs', '// win\n'],
166
+ ]), bsDest, { prefix: 'template/' });
167
+ check('backslash-separated entry names extract as directories',
168
+ readFileSync(join(bsDest, '_claude', 'hooks', 'win.mjs'), 'utf8') === '// win\n');
169
+ rmSync(bsDest, { recursive: true, force: true });
170
+ } finally {
171
+ rmSync(dest, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ // fetchPackageTarball rejects non-semver versions before any network or
176
+ // npm call — a workspace.json value is user input.
177
+ {
178
+ const bogus = await fetchPackageTarball('0.15.0; rm -rf /');
179
+ check('non-semver version fetches nothing', bogus === null);
180
+ const alsoBogus = await fetchPackageTarball('');
181
+ check('empty version fetches nothing', alsoBogus === null);
182
+ }
183
+
184
+ // The registry fetch is bounded: a hung connection must not stall an
185
+ // upgrade indefinitely (reconstruction falls back to asking per file).
186
+ {
187
+ const realFetch = globalThis.fetch;
188
+ const tarball = makeTarball([['package/template/.claudeignore', 'x/\n']]);
189
+ let seenInit = null;
190
+ globalThis.fetch = async (url, init) => {
191
+ seenInit = { url, init };
192
+ return { ok: true, arrayBuffer: async () => tarball };
193
+ };
194
+ try {
195
+ const bytes = await fetchPackageTarball('0.15.0');
196
+ check('registry fetch returns the tarball bytes', bytes !== null && bytes.equals(tarball));
197
+ check('registry fetch targets the versioned tarball URL',
198
+ seenInit !== null && seenInit.url.endsWith('/create-workspace-0.15.0.tgz'));
199
+ check('registry fetch carries an abort timeout signal',
200
+ seenInit !== null && seenInit.init?.signal instanceof AbortSignal);
201
+ } finally {
202
+ globalThis.fetch = realFetch;
203
+ }
204
+ }
205
+
51
206
  if (failed > 0) {
52
207
  console.error(`${failed} check(s) failed, ${passed} passed`);
53
208
  process.exit(1);
package/lib/scaffold.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, rmSync, statSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { writeBaseline, INERT_PAIRS } from '../template/_claude/scripts/template-baseline.mjs';
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const TEMPLATE_DIR = join(__dirname, '..', 'template');
@@ -98,5 +99,12 @@ export async function scaffold(answers) {
98
99
  }
99
100
  }
100
101
 
102
+ // Record the template baseline — the sha256 of every verbatim-installed
103
+ // file — so /workspace-update can classify three ways (workspace vs payload
104
+ // vs baseline) instead of treating every template change as a local edit
105
+ // (gh:183). The template tree carries the inert names; entries always hold
106
+ // the template's content, so hashes come straight from the source of truth.
107
+ writeBaseline(directory, TEMPLATE_DIR, { pairs: INERT_PAIRS, version: pkgJson.version });
108
+
101
109
  return directory;
102
110
  }
@@ -48,6 +48,26 @@ try {
48
48
  check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
49
49
  check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
50
50
  check('no _gitignore leftover', !existsSync(join(directory, '_gitignore')));
51
+
52
+ // Template baseline: hashes of the verbatim-installed files, for three-way
53
+ // update classification (gh:183)
54
+ const baselinePath = join(directory, '.claude', '.template-baseline.json');
55
+ check('template baseline written', existsSync(baselinePath));
56
+ const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
57
+ const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
58
+ check(`baseline records the package version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
59
+ check(
60
+ 'baseline covers rules installed verbatim from the template',
61
+ typeof baseline.files['.claude/rules/coherent-revisions.md'] === 'string',
62
+ );
63
+ check(
64
+ 'baseline covers .mcp.json via its inert source name',
65
+ typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
66
+ );
67
+ check(
68
+ 'baseline never covers tests or machine-local files',
69
+ Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs') && k !== '.claude/settings.local.json'),
70
+ );
51
71
  } finally {
52
72
  rmSync(directory, { recursive: true, force: true });
53
73
  }