@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.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/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- 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 +356 -28
- 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 +47 -8
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +322 -32
- 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 +33 -9
- 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 +28 -8
- 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-update/SKILL.md +51 -26
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/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
|
}
|