@ulysses-ai/create-workspace 0.23.3-beta.0 → 0.24.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +70 -0
  4. package/template/_claude/hooks/session-start.mjs +21 -1
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  15. package/template/_claude/scripts/task-pr.mjs +316 -72
  16. package/template/_claude/scripts/task-worktree.mjs +9 -1
  17. package/template/_claude/scripts/template-merge.mjs +21 -4
  18. package/template/_claude/scripts/template-modifications.mjs +152 -0
  19. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  20. package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
  21. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  22. package/template/_claude/skills/braindump/SKILL.md +1 -1
  23. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  24. package/template/_claude/skills/handoff/SKILL.md +1 -1
  25. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  26. package/template/_claude/skills/release/SKILL.md +20 -6
  27. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  28. package/template/_claude/skills/start-work/SKILL.md +23 -5
  29. package/template/_claude/skills/workspace-init/SKILL.md +51 -9
  30. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  31. package/template/workspace.json.tmpl +2 -1
  32. package/lib/init.test.mjs +0 -75
  33. package/lib/payload.test.mjs +0 -210
  34. package/lib/scaffold.test.mjs +0 -79
  35. package/lib/upgrade.test.mjs +0 -547
@@ -12,7 +12,8 @@
12
12
  "greeting": "Welcome back to {{project-name}}.",
13
13
  "sessionModel": "session",
14
14
  "tracker": null,
15
- "forge": { "type": "github" }
15
+ "forge": { "type": "github" },
16
+ "mergeApproval": "ask"
16
17
  },
17
18
  "repos": {}
18
19
  }
package/lib/init.test.mjs DELETED
@@ -1,75 +0,0 @@
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`);
@@ -1,210 +0,0 @@
1
- #!/usr/bin/env node
2
- // Unit tests for payload.mjs
3
- // Run: node lib/payload.test.mjs
4
- //
5
- // The staged .workspace-update/ payload is an on-disk format shared with
6
- // the /workspace-update skill already installed in existing workspaces,
7
- // which reads .workspace-update/.claude/... and .workspace-update/.mcp.json.
8
- // These tests pin that layout even though template/ itself stores the
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';
14
- import { join } from 'path';
15
- import { tmpdir } from 'os';
16
- import { gzipSync } from 'zlib';
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 root = mkdtempSync(join(tmpdir(), 'payload-test-'));
28
-
29
- try {
30
- const { payloadDir, toVersion } = stagePayload(root, { action: 'init' });
31
-
32
- check('payload staged at .workspace-update/', existsSync(payloadDir));
33
- check('manifest written', existsSync(join(payloadDir, '.manifest.json')));
34
- check('returns the package version', typeof toVersion === 'string' && toVersion.length > 0);
35
-
36
- // The payload keeps the live names older workspaces' skills expect
37
- check('.claude/ staged under live name', existsSync(join(payloadDir, '.claude')));
38
- check('.claude/skills/workspace-update/SKILL.md staged', existsSync(join(payloadDir, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
39
- check('.claude/settings.json staged', existsSync(join(payloadDir, '.claude', 'settings.json')));
40
- check('.mcp.json staged under live name', existsSync(join(payloadDir, '.mcp.json')));
41
- check('_gitignore stays inert (skills merge it under that name)', existsSync(join(payloadDir, '_gitignore')));
42
-
43
- // The template's inert names must not leak into the payload
44
- check('no _claude/ in payload', !existsSync(join(payloadDir, '_claude')));
45
- check('no _mcp.json in payload', !existsSync(join(payloadDir, '_mcp.json')));
46
-
47
- // cleanPayload removes the staging directory
48
- cleanPayload(root);
49
- check('cleanPayload removes .workspace-update/', !existsSync(payloadDir));
50
- } finally {
51
- rmSync(root, { recursive: true, force: true });
52
- }
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
-
206
- if (failed > 0) {
207
- console.error(`${failed} check(s) failed, ${passed} passed`);
208
- process.exit(1);
209
- }
210
- console.log(`${passed} checks passed`);
@@ -1,79 +0,0 @@
1
- #!/usr/bin/env node
2
- // End-to-end test for scaffold.mjs
3
- // Run: node lib/scaffold.test.mjs
4
- //
5
- // The template stores .gitignore, .claude/, and .mcp.json under the inert
6
- // names _gitignore, _claude/, and _mcp.json (Claude Code protects the live
7
- // names from headless edits). scaffold() must install every one under its
8
- // live name and leave no inert leftovers behind.
9
- import { scaffold } from './scaffold.mjs';
10
- import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
11
- import { join } from 'path';
12
- import { tmpdir } from 'os';
13
-
14
- let failed = 0;
15
- let passed = 0;
16
- function check(label, ok) {
17
- if (ok) { passed++; } else {
18
- failed++;
19
- console.error(` FAIL: ${label}`);
20
- }
21
- }
22
-
23
- const directory = mkdtempSync(join(tmpdir(), 'scaffold-test-'));
24
-
25
- try {
26
- await scaffold({
27
- name: 'demo',
28
- directory,
29
- repos: [],
30
- userName: 'tester',
31
- activateRules: [],
32
- });
33
-
34
- // Live names installed
35
- check('.claude/ directory installed', existsSync(join(directory, '.claude')));
36
- check('.claude/settings.json installed', existsSync(join(directory, '.claude', 'settings.json')));
37
- check('.claude/settings.local.json written', existsSync(join(directory, '.claude', 'settings.local.json')));
38
- check('.claude/rules/ installed', existsSync(join(directory, '.claude', 'rules')));
39
- check('.claude/rules/coherent-revisions.md installed', existsSync(join(directory, '.claude', 'rules', 'coherent-revisions.md')));
40
- check('.mcp.json installed', existsSync(join(directory, '.mcp.json')));
41
- check('.gitignore installed', existsSync(join(directory, '.gitignore')));
42
-
43
- // .mcp.json is the template's payload, unmodified
44
- const mcp = JSON.parse(readFileSync(join(directory, '.mcp.json'), 'utf8'));
45
- check('.mcp.json parses with mcpServers object', typeof mcp.mcpServers === 'object' && mcp.mcpServers !== null);
46
-
47
- // Inert names are gone
48
- check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
49
- check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
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
- );
71
- } finally {
72
- rmSync(directory, { recursive: true, force: true });
73
- }
74
-
75
- if (failed > 0) {
76
- console.error(`${failed} check(s) failed, ${passed} passed`);
77
- process.exit(1);
78
- }
79
- console.log(`${passed} checks passed`);