@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/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 {
|
|
3
|
-
|
|
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
|
+
}
|
package/lib/payload.test.mjs
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
}
|
package/lib/scaffold.test.mjs
CHANGED
|
@@ -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
|
}
|