@ulysses-ai/create-workspace 0.22.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/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/upgrade.mjs CHANGED
@@ -1,8 +1,15 @@
1
1
  // lib/upgrade.mjs
2
- import { existsSync, readFileSync } from 'fs';
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
- export async function upgradeWorkspace(targetDir) {
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.\n`);
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
  }