@ulysses-ai/create-workspace 0.14.0-beta.1 → 0.15.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/README.md +1 -1
- package/package.json +1 -1
- package/template/.claude/agents/reviewer.md +1 -1
- package/template/.claude/hooks/pre-compact.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +2 -2
- package/template/.claude/hooks/session-start.mjs +10 -7
- package/template/.claude/hooks/subagent-start.mjs +3 -3
- package/template/.claude/hooks/version-freshness-check.mjs +30 -0
- package/template/.claude/lib/freshness.mjs +75 -0
- package/template/.claude/lib/freshness.test.mjs +175 -0
- package/template/.claude/lib/registry-check.mjs +106 -0
- package/template/.claude/lib/registry-check.test.mjs +130 -0
- package/template/.claude/recipes/migrate-from-notion.md +6 -6
- package/template/.claude/rules/coherent-revisions.md +2 -2
- package/template/.claude/rules/local-dev-environment.md.skip +2 -2
- package/template/.claude/rules/memory-guidance.md +54 -1
- package/template/.claude/rules/token-economics.md.skip +2 -2
- package/template/.claude/rules/work-item-tracking.md +1 -1
- package/template/.claude/rules/workspace-structure.md +36 -13
- package/template/.claude/scripts/build-workspace-context.mjs +365 -0
- package/template/.claude/scripts/build-workspace-context.test.mjs +633 -0
- package/template/.claude/scripts/capture-context.mjs +217 -0
- package/template/.claude/scripts/capture-context.test.mjs +383 -0
- package/template/.claude/scripts/generate-claude-local.mjs +104 -0
- package/template/.claude/scripts/generate-claude-local.test.mjs +184 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +30 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +54 -0
- package/template/.claude/scripts/migrate-open-work.mjs +1 -1
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +325 -0
- package/template/.claude/scripts/sweep-references.mjs +177 -0
- package/template/.claude/scripts/sweep-references.test.mjs +184 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/aside/SKILL.md +49 -44
- package/template/.claude/skills/braindump/SKILL.md +25 -19
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +3 -3
- package/template/.claude/skills/handoff/SKILL.md +31 -30
- package/template/.claude/skills/maintenance/SKILL.md +57 -15
- package/template/.claude/skills/pause-work/SKILL.md +1 -1
- package/template/.claude/skills/promote/SKILL.md +18 -8
- package/template/.claude/skills/release/SKILL.md +17 -13
- package/template/.claude/skills/start-work/SKILL.md +1 -1
- package/template/.claude/skills/workspace-init/SKILL.md +12 -12
- package/template/.claude/skills/workspace-update/SKILL.md +16 -0
- package/template/CLAUDE.md.tmpl +5 -3
- package/template/_gitignore +3 -3
- package/template/workspace.json.tmpl +3 -2
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ A scaffolded workspace with:
|
|
|
90
90
|
|
|
91
91
|
- **14 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
|
|
92
92
|
- **7 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
|
|
93
|
-
- **
|
|
93
|
+
- **10 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
|
|
94
94
|
- A **`shared-context/`** memory system with three visibility levels: locked (team truths), root (team-visible ephemerals), user-scoped (personal)
|
|
95
95
|
- Conventions for **multi-repo work sessions** with isolated git worktrees, parallelizable from separate terminals
|
|
96
96
|
|
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@ You are reviewing code changes. Your job is to find problems, not to fix them.
|
|
|
27
27
|
3. **Edge cases** — are there scenarios not handled?
|
|
28
28
|
4. **Test coverage** — are tests covering the right scenarios? Are they testing behavior, not implementation?
|
|
29
29
|
5. **Security** — any injection, XSS, or auth issues?
|
|
30
|
-
6. **Consistency** — does it match existing patterns in
|
|
30
|
+
6. **Consistency** — does it match existing patterns in workspace-context/shared/locked/?
|
|
31
31
|
|
|
32
32
|
## What NOT to review
|
|
33
33
|
- Style preferences (defer to linters/formatters)
|
|
@@ -30,5 +30,5 @@ if (pointer) {
|
|
|
30
30
|
respond(`Context is about to be compacted. No session tracker found for "${pointer.name}". Use /handoff to capture progress before context is lost.`);
|
|
31
31
|
}
|
|
32
32
|
} else {
|
|
33
|
-
respond(`Context is about to be compacted — earlier conversation details will be lost.\n\nIf this session produced decisions or progress worth keeping:\n /braindump [name] — capture discussion and reasoning\n /handoff [name] — capture workstream state and next steps\n\nFiles in
|
|
33
|
+
respond(`Context is about to be compacted — earlier conversation details will be lost.\n\nIf this session produced decisions or progress worth keeping:\n /braindump [name] — capture discussion and reasoning\n /handoff [name] — capture workstream state and next steps\n\nFiles in workspace-context/ will persist. Conversation details won't.`);
|
|
34
34
|
}
|
|
@@ -94,9 +94,9 @@ if (toolName === 'Bash') {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// Check if this write targets repos/,
|
|
97
|
+
// Check if this write targets repos/, workspace-context/, work-sessions/, or template files
|
|
98
98
|
const isRepoWrite = /(?:^|[\s/])repos\//.test(paths) || paths.includes('work-sessions/');
|
|
99
|
-
const isContextWrite = paths.includes('
|
|
99
|
+
const isContextWrite = paths.includes('workspace-context/') && !basename(filePathArg).startsWith('local-only-');
|
|
100
100
|
const isTemplateWrite = paths.includes('.claude/') && !paths.includes(scratchpadName);
|
|
101
101
|
|
|
102
102
|
if (isRepoWrite || isContextWrite || isTemplateWrite) {
|
|
@@ -22,7 +22,8 @@ const input = await readStdin();
|
|
|
22
22
|
const config = readJSON(join(root, 'workspace.json'));
|
|
23
23
|
|
|
24
24
|
const chatId = input.session_id || null;
|
|
25
|
-
const contextDir = join(root, '
|
|
25
|
+
const contextDir = join(root, 'workspace-context');
|
|
26
|
+
const sharedDir = join(contextDir, 'shared');
|
|
26
27
|
const reposDir = join(root, 'repos');
|
|
27
28
|
const lines = [];
|
|
28
29
|
|
|
@@ -113,8 +114,11 @@ if (trackers.length > 0) {
|
|
|
113
114
|
lines.push('Use /start-work to resume a session or start new work.');
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
// Surface shared context (secondary)
|
|
117
|
-
|
|
117
|
+
// Surface team-shared workspace context (secondary)
|
|
118
|
+
// Only scan shared/ — locked/ is now a sub-dir of shared/ and is included
|
|
119
|
+
// naturally. team-member/ is per-user (loaded via CLAUDE.local.md).
|
|
120
|
+
// release-notes/ is operational, not knowledge.
|
|
121
|
+
if (existsSync(sharedDir)) {
|
|
118
122
|
const entries = [];
|
|
119
123
|
|
|
120
124
|
function scanDir(dir, depth = 0) {
|
|
@@ -123,11 +127,10 @@ if (existsSync(contextDir)) {
|
|
|
123
127
|
const fullPath = join(dir, entry);
|
|
124
128
|
const stat = statSync(fullPath);
|
|
125
129
|
if (stat.isDirectory()) {
|
|
126
|
-
if (entry === '
|
|
130
|
+
if (entry === '.keep') continue;
|
|
127
131
|
scanDir(fullPath, depth + 1);
|
|
128
132
|
} else if (entry.endsWith('.md') && entry !== '.keep' && !entry.startsWith('local-only-')) {
|
|
129
133
|
const relPath = relative(contextDir, fullPath);
|
|
130
|
-
if (relPath.startsWith('locked/')) continue;
|
|
131
134
|
const content = readFileSync(fullPath, 'utf-8');
|
|
132
135
|
const topicMatch = content.match(/^topic:\s*(.+)$/m);
|
|
133
136
|
const lifecycleMatch = content.match(/^lifecycle:\s*(.+)$/m);
|
|
@@ -139,10 +142,10 @@ if (existsSync(contextDir)) {
|
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
scanDir(
|
|
145
|
+
scanDir(sharedDir);
|
|
143
146
|
if (entries.length > 0) {
|
|
144
147
|
lines.push('');
|
|
145
|
-
lines.push('
|
|
148
|
+
lines.push('Workspace context:');
|
|
146
149
|
lines.push(...entries);
|
|
147
150
|
}
|
|
148
151
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// SubagentStart hook — inject
|
|
2
|
+
// SubagentStart hook — inject workspace-context/shared/locked/ into subagent context
|
|
3
3
|
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
4
4
|
import { join, basename } from 'path';
|
|
5
5
|
import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
|
|
6
6
|
|
|
7
7
|
const root = getWorkspaceRoot(import.meta.url);
|
|
8
8
|
const config = readJSON(join(root, 'workspace.json'));
|
|
9
|
-
const lockedDir = join(root, '
|
|
9
|
+
const lockedDir = join(root, 'workspace-context', 'shared', 'locked');
|
|
10
10
|
|
|
11
11
|
const maxBytes = config?.workspace?.subagentContextMaxBytes || 10240;
|
|
12
12
|
|
|
@@ -38,7 +38,7 @@ if (Buffer.byteLength(context) > maxBytes) {
|
|
|
38
38
|
return `- ${basename(f, '.md')}: ${firstLine}`;
|
|
39
39
|
}).join('\n');
|
|
40
40
|
|
|
41
|
-
context = `[Locked shared context exceeds ${maxBytes} byte limit (${Buffer.byteLength(context)} bytes). Summary of ${files.length} files:]\n${summary}\n[Read individual files from
|
|
41
|
+
context = `[Locked shared context exceeds ${maxBytes} byte limit (${Buffer.byteLength(context)} bytes). Summary of ${files.length} files:]\n${summary}\n[Read individual files from workspace-context/shared/locked/ if you need full content.]`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
respond(context);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook — refresh the template freshness banner if cache is stale.
|
|
3
|
+
// Gated on workspace.versionCheck.ambient (default true; will flip to false at v1.0).
|
|
4
|
+
// Always silent: surfaces only via the local-only-template-freshness.md banner,
|
|
5
|
+
// which CLAUDE.md includes via @ syntax. Never blocks session start.
|
|
6
|
+
import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
|
|
7
|
+
import { refreshIfStale } from '../lib/freshness.mjs';
|
|
8
|
+
|
|
9
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
10
|
+
|
|
11
|
+
const root = getWorkspaceRoot(import.meta.url);
|
|
12
|
+
const config = readJSON(`${root}/workspace.json`);
|
|
13
|
+
|
|
14
|
+
// Default lifecycle: ambient=true during beta, missing-block fallback flips
|
|
15
|
+
// to false at v1.0 by editing this default.
|
|
16
|
+
const AMBIENT_DEFAULT = true;
|
|
17
|
+
const ambient = config?.workspace?.versionCheck?.ambient ?? AMBIENT_DEFAULT;
|
|
18
|
+
|
|
19
|
+
if (!ambient) {
|
|
20
|
+
respond();
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await refreshIfStale({ workspaceRoot: root, ttlMs: TTL_MS });
|
|
26
|
+
} catch {
|
|
27
|
+
// Hooks must never block session start. The banner reflects last-known
|
|
28
|
+
// state; the user sees stale but never broken.
|
|
29
|
+
}
|
|
30
|
+
respond();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import './require-node.mjs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { compareVersions, getLatestVersion, readCache, writeCache } from './registry-check.mjs';
|
|
5
|
+
|
|
6
|
+
const BANNER_FILENAME = 'local-only-template-freshness.md';
|
|
7
|
+
const CACHE_FILENAME = '.version-check.json';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refresh the version cache if stale, then write or delete the banner file
|
|
11
|
+
* based on a comparison of workspace.templateVersion to the latest npm version.
|
|
12
|
+
*
|
|
13
|
+
* Returns:
|
|
14
|
+
* { status: 'outdated', current, latest, checkedAt }
|
|
15
|
+
* { status: 'current', current, latest, checkedAt }
|
|
16
|
+
* { status: 'unknown', current, latest: null, checkedAt: null }
|
|
17
|
+
* { skipped: 'uninitialized' }
|
|
18
|
+
*
|
|
19
|
+
* Pure I/O is parameterized via fetchFn / nowFn for testability.
|
|
20
|
+
*/
|
|
21
|
+
export async function refreshIfStale({
|
|
22
|
+
workspaceRoot,
|
|
23
|
+
ttlMs,
|
|
24
|
+
fetchFn = fetch,
|
|
25
|
+
nowFn = () => new Date(),
|
|
26
|
+
}) {
|
|
27
|
+
const wsConfigPath = join(workspaceRoot, 'workspace.json');
|
|
28
|
+
let wsConfig;
|
|
29
|
+
try {
|
|
30
|
+
wsConfig = JSON.parse(readFileSync(wsConfigPath, 'utf-8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return { skipped: 'no-workspace-json' };
|
|
33
|
+
}
|
|
34
|
+
const current = wsConfig?.workspace?.templateVersion;
|
|
35
|
+
if (!current || current === '0.0.0') {
|
|
36
|
+
return { skipped: 'uninitialized' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const scratchpadDir = wsConfig?.workspace?.scratchpadDir || 'workspace-scratchpad';
|
|
40
|
+
const cachePath = join(workspaceRoot, scratchpadDir, CACHE_FILENAME);
|
|
41
|
+
const bannerPath = join(workspaceRoot, BANNER_FILENAME);
|
|
42
|
+
|
|
43
|
+
let cache = readCache(cachePath);
|
|
44
|
+
const now = nowFn();
|
|
45
|
+
const cacheAgeMs = cache?.checkedAt ? now.getTime() - new Date(cache.checkedAt).getTime() : Infinity;
|
|
46
|
+
const stale = cacheAgeMs > ttlMs;
|
|
47
|
+
|
|
48
|
+
if (stale) {
|
|
49
|
+
const fresh = await getLatestVersion({ fetchFn });
|
|
50
|
+
if (fresh.version) {
|
|
51
|
+
cache = { latestVersion: fresh.version, checkedAt: now.toISOString() };
|
|
52
|
+
writeCache(cachePath, cache);
|
|
53
|
+
}
|
|
54
|
+
// On fetch error, keep whatever cache we already had (could be null).
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!cache) {
|
|
58
|
+
// No cache and offline (or fetch error). Leave banner alone — we have no
|
|
59
|
+
// basis to write or delete it.
|
|
60
|
+
return { status: 'unknown', current, latest: null, checkedAt: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const latest = cache.latestVersion;
|
|
64
|
+
const cmp = compareVersions(current, latest);
|
|
65
|
+
if (cmp < 0) {
|
|
66
|
+
writeFileSync(
|
|
67
|
+
bannerPath,
|
|
68
|
+
`## Template update available\n\nYour workspace is on template v${current}; latest on npm is v${latest}.\nRun \`/maintenance\` for details or \`npx @ulysses-ai/create-workspace --upgrade\` to apply.\n`,
|
|
69
|
+
);
|
|
70
|
+
return { status: 'outdated', current, latest, checkedAt: cache.checkedAt };
|
|
71
|
+
} else {
|
|
72
|
+
if (existsSync(bannerPath)) unlinkSync(bannerPath);
|
|
73
|
+
return { status: 'current', current, latest, checkedAt: cache.checkedAt };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for freshness.mjs
|
|
3
|
+
// Run: node template/.claude/lib/freshness.test.mjs
|
|
4
|
+
import { refreshIfStale } from './freshness.mjs';
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
|
|
9
|
+
let failed = 0;
|
|
10
|
+
let passed = 0;
|
|
11
|
+
function assertEq(actual, expected, msg) {
|
|
12
|
+
const a = JSON.stringify(actual);
|
|
13
|
+
const e = JSON.stringify(expected);
|
|
14
|
+
if (a === e) { passed++; } else {
|
|
15
|
+
failed++;
|
|
16
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setupWorkspace({ templateVersion, ambientBlock = '', cache = null } = {}) {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), 'freshness-test-'));
|
|
22
|
+
const wsConfig = {
|
|
23
|
+
workspace: {
|
|
24
|
+
name: 'test',
|
|
25
|
+
scratchpadDir: 'workspace-scratchpad',
|
|
26
|
+
templateVersion,
|
|
27
|
+
...(ambientBlock ? { versionCheck: { ambient: ambientBlock === 'on' } } : {}),
|
|
28
|
+
},
|
|
29
|
+
repos: {},
|
|
30
|
+
};
|
|
31
|
+
writeFileSync(join(root, 'workspace.json'), JSON.stringify(wsConfig));
|
|
32
|
+
if (cache) {
|
|
33
|
+
const cacheDir = join(root, 'workspace-scratchpad');
|
|
34
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
35
|
+
writeFileSync(join(cacheDir, '.version-check.json'), JSON.stringify(cache));
|
|
36
|
+
}
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fakeFetchOk(version) {
|
|
41
|
+
return async () => ({ ok: true, json: async () => ({ version }) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fakeFetchErr = async () => { throw new Error('offline'); };
|
|
45
|
+
|
|
46
|
+
console.log('# refreshIfStale');
|
|
47
|
+
|
|
48
|
+
// Outdated workspace, fresh fetch, banner written
|
|
49
|
+
{
|
|
50
|
+
const root = setupWorkspace({ templateVersion: '0.13.0' });
|
|
51
|
+
const result = await refreshIfStale({
|
|
52
|
+
workspaceRoot: root,
|
|
53
|
+
ttlMs: 86400000,
|
|
54
|
+
fetchFn: fakeFetchOk('0.14.0'),
|
|
55
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
56
|
+
});
|
|
57
|
+
assertEq(result.status, 'outdated', 'outdated status');
|
|
58
|
+
assertEq(result.current, '0.13.0', 'current version reported');
|
|
59
|
+
assertEq(result.latest, '0.14.0', 'latest version reported');
|
|
60
|
+
assertEq(existsSync(join(root, 'local-only-template-freshness.md')), true, 'banner file created');
|
|
61
|
+
const banner = readFileSync(join(root, 'local-only-template-freshness.md'), 'utf-8');
|
|
62
|
+
assertEq(banner.includes('v0.13.0'), true, 'banner mentions current');
|
|
63
|
+
assertEq(banner.includes('v0.14.0'), true, 'banner mentions latest');
|
|
64
|
+
rmSync(root, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Up-to-date workspace, banner deleted if present
|
|
68
|
+
{
|
|
69
|
+
const root = setupWorkspace({ templateVersion: '0.14.0' });
|
|
70
|
+
// Pre-existing banner from when it was outdated
|
|
71
|
+
writeFileSync(join(root, 'local-only-template-freshness.md'), '## stale banner');
|
|
72
|
+
await refreshIfStale({
|
|
73
|
+
workspaceRoot: root,
|
|
74
|
+
ttlMs: 86400000,
|
|
75
|
+
fetchFn: fakeFetchOk('0.14.0'),
|
|
76
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
77
|
+
});
|
|
78
|
+
assertEq(existsSync(join(root, 'local-only-template-freshness.md')), false, 'banner deleted when current');
|
|
79
|
+
rmSync(root, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fresh cache, no fetch happens
|
|
83
|
+
{
|
|
84
|
+
const root = setupWorkspace({
|
|
85
|
+
templateVersion: '0.13.0',
|
|
86
|
+
cache: { latestVersion: '0.14.0', checkedAt: '2026-04-24T20:00:00Z' },
|
|
87
|
+
});
|
|
88
|
+
let fetchCalled = false;
|
|
89
|
+
await refreshIfStale({
|
|
90
|
+
workspaceRoot: root,
|
|
91
|
+
ttlMs: 86400000,
|
|
92
|
+
fetchFn: async () => { fetchCalled = true; return { ok: true, json: async () => ({ version: '0.14.0' }) }; },
|
|
93
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'), // 1h after cache
|
|
94
|
+
});
|
|
95
|
+
assertEq(fetchCalled, false, 'fresh cache skips fetch');
|
|
96
|
+
assertEq(existsSync(join(root, 'local-only-template-freshness.md')), true, 'banner still written from cache');
|
|
97
|
+
rmSync(root, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stale cache triggers fetch
|
|
101
|
+
{
|
|
102
|
+
const root = setupWorkspace({
|
|
103
|
+
templateVersion: '0.13.0',
|
|
104
|
+
cache: { latestVersion: '0.13.5', checkedAt: '2026-04-20T20:00:00Z' },
|
|
105
|
+
});
|
|
106
|
+
let fetchCalled = false;
|
|
107
|
+
await refreshIfStale({
|
|
108
|
+
workspaceRoot: root,
|
|
109
|
+
ttlMs: 86400000,
|
|
110
|
+
fetchFn: async () => { fetchCalled = true; return { ok: true, json: async () => ({ version: '0.14.0' }) }; },
|
|
111
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
112
|
+
});
|
|
113
|
+
assertEq(fetchCalled, true, 'stale cache triggers fetch');
|
|
114
|
+
rmSync(root, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Stale cache + offline: keep cached value, return unknown only if no cache
|
|
118
|
+
{
|
|
119
|
+
const root = setupWorkspace({
|
|
120
|
+
templateVersion: '0.13.0',
|
|
121
|
+
cache: { latestVersion: '0.13.9', checkedAt: '2026-04-20T20:00:00Z' },
|
|
122
|
+
});
|
|
123
|
+
const result = await refreshIfStale({
|
|
124
|
+
workspaceRoot: root,
|
|
125
|
+
ttlMs: 86400000,
|
|
126
|
+
fetchFn: fakeFetchErr,
|
|
127
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
128
|
+
});
|
|
129
|
+
assertEq(result.status, 'outdated', 'falls back to cached value when offline');
|
|
130
|
+
assertEq(result.latest, '0.13.9', 'cached value used');
|
|
131
|
+
rmSync(root, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// No cache + offline: status unknown
|
|
135
|
+
{
|
|
136
|
+
const root = setupWorkspace({ templateVersion: '0.13.0' });
|
|
137
|
+
const result = await refreshIfStale({
|
|
138
|
+
workspaceRoot: root,
|
|
139
|
+
ttlMs: 86400000,
|
|
140
|
+
fetchFn: fakeFetchErr,
|
|
141
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
142
|
+
});
|
|
143
|
+
assertEq(result.status, 'unknown', 'unknown when no cache and offline');
|
|
144
|
+
rmSync(root, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Uninitialized workspace (templateVersion missing)
|
|
148
|
+
{
|
|
149
|
+
const root = mkdtempSync(join(tmpdir(), 'freshness-test-'));
|
|
150
|
+
writeFileSync(join(root, 'workspace.json'), JSON.stringify({ workspace: {}, repos: {} }));
|
|
151
|
+
const result = await refreshIfStale({
|
|
152
|
+
workspaceRoot: root,
|
|
153
|
+
ttlMs: 86400000,
|
|
154
|
+
fetchFn: fakeFetchOk('0.14.0'),
|
|
155
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
156
|
+
});
|
|
157
|
+
assertEq(result.skipped, 'uninitialized', 'uninitialized workspace skipped');
|
|
158
|
+
rmSync(root, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// templateVersion 0.0.0 treated as uninitialized
|
|
162
|
+
{
|
|
163
|
+
const root = setupWorkspace({ templateVersion: '0.0.0' });
|
|
164
|
+
const result = await refreshIfStale({
|
|
165
|
+
workspaceRoot: root,
|
|
166
|
+
ttlMs: 86400000,
|
|
167
|
+
fetchFn: fakeFetchOk('0.14.0'),
|
|
168
|
+
nowFn: () => new Date('2026-04-24T21:00:00Z'),
|
|
169
|
+
});
|
|
170
|
+
assertEq(result.skipped, 'uninitialized', '0.0.0 treated as uninitialized');
|
|
171
|
+
rmSync(root, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
175
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import './require-node.mjs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SemVer 2.0 comparison limited to the formats this scaffolder publishes:
|
|
7
|
+
* `x.y.z` and `x.y.z-prerelease.N`. Returns -1, 0, or 1.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Compare major, minor, patch numerically.
|
|
11
|
+
* - A pre-release version is older than the same x.y.z without a tag.
|
|
12
|
+
* - Pre-release identifiers compare per-identifier; numeric identifiers
|
|
13
|
+
* compare numerically (so `beta.10 > beta.2`), non-numeric lexically.
|
|
14
|
+
*/
|
|
15
|
+
export function compareVersions(a, b) {
|
|
16
|
+
if (a === b) return 0;
|
|
17
|
+
const [aBase, aPre] = a.split('-', 2);
|
|
18
|
+
const [bBase, bPre] = b.split('-', 2);
|
|
19
|
+
const aParts = aBase.split('.').map(Number);
|
|
20
|
+
const bParts = bBase.split('.').map(Number);
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
if ((aParts[i] || 0) < (bParts[i] || 0)) return -1;
|
|
23
|
+
if ((aParts[i] || 0) > (bParts[i] || 0)) return 1;
|
|
24
|
+
}
|
|
25
|
+
if (!aPre && !bPre) return 0;
|
|
26
|
+
if (!aPre && bPre) return 1;
|
|
27
|
+
if (aPre && !bPre) return -1;
|
|
28
|
+
const aIds = aPre.split('.');
|
|
29
|
+
const bIds = bPre.split('.');
|
|
30
|
+
const len = Math.max(aIds.length, bIds.length);
|
|
31
|
+
for (let i = 0; i < len; i++) {
|
|
32
|
+
const ai = aIds[i];
|
|
33
|
+
const bi = bIds[i];
|
|
34
|
+
if (ai === undefined) return -1;
|
|
35
|
+
if (bi === undefined) return 1;
|
|
36
|
+
const aNum = /^\d+$/.test(ai);
|
|
37
|
+
const bNum = /^\d+$/.test(bi);
|
|
38
|
+
if (aNum && bNum) {
|
|
39
|
+
const an = Number(ai), bn = Number(bi);
|
|
40
|
+
if (an < bn) return -1;
|
|
41
|
+
if (an > bn) return 1;
|
|
42
|
+
} else if (aNum && !bNum) {
|
|
43
|
+
return -1;
|
|
44
|
+
} else if (!aNum && bNum) {
|
|
45
|
+
return 1;
|
|
46
|
+
} else {
|
|
47
|
+
if (ai < bi) return -1;
|
|
48
|
+
if (ai > bi) return 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@ulysses-ai/create-workspace/latest';
|
|
55
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fetch the latest version of the scaffolder from the npm registry.
|
|
59
|
+
* Returns { version, error } — exactly one of them is non-null.
|
|
60
|
+
*
|
|
61
|
+
* Caller injects fetchFn for testing. Default uses global fetch (Node 18+).
|
|
62
|
+
*/
|
|
63
|
+
export async function getLatestVersion({ fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetchFn(REGISTRY_URL, { signal: controller.signal });
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
return { version: null, error: `registry returned ${res.status} ${res.statusText || ''}`.trim() };
|
|
70
|
+
}
|
|
71
|
+
const body = await res.json();
|
|
72
|
+
if (typeof body?.version !== 'string') {
|
|
73
|
+
return { version: null, error: 'registry response missing version field' };
|
|
74
|
+
}
|
|
75
|
+
return { version: body.version, error: null };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { version: null, error: err?.message || String(err) };
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the version cache file. Returns the parsed object if it has a string
|
|
85
|
+
* `latestVersion` field; otherwise null. Treats missing file, malformed JSON,
|
|
86
|
+
* and shape mismatches all as "no cache".
|
|
87
|
+
*/
|
|
88
|
+
export function readCache(path) {
|
|
89
|
+
if (!existsSync(path)) return null;
|
|
90
|
+
try {
|
|
91
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
92
|
+
if (typeof data?.latestVersion !== 'string') return null;
|
|
93
|
+
return data;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write the version cache file, creating parent directories as needed.
|
|
101
|
+
*/
|
|
102
|
+
export function writeCache(path, data) {
|
|
103
|
+
const dir = dirname(path);
|
|
104
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
106
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for registry-check.mjs
|
|
3
|
+
// Run: node template/.claude/lib/registry-check.test.mjs
|
|
4
|
+
import { compareVersions } from './registry-check.mjs';
|
|
5
|
+
|
|
6
|
+
let failed = 0;
|
|
7
|
+
let passed = 0;
|
|
8
|
+
|
|
9
|
+
function assertEq(actual, expected, msg) {
|
|
10
|
+
const a = JSON.stringify(actual);
|
|
11
|
+
const e = JSON.stringify(expected);
|
|
12
|
+
if (a === e) { passed++; } else {
|
|
13
|
+
failed++;
|
|
14
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log('# compareVersions');
|
|
19
|
+
assertEq(compareVersions('0.13.0', '0.14.0'), -1, 'older minor');
|
|
20
|
+
assertEq(compareVersions('0.14.0', '0.13.0'), 1, 'newer minor');
|
|
21
|
+
assertEq(compareVersions('0.13.0', '0.13.0'), 0, 'equal');
|
|
22
|
+
assertEq(compareVersions('1.0.0', '0.99.99'), 1, 'newer major');
|
|
23
|
+
assertEq(compareVersions('0.14.0-beta.1', '0.14.0'), -1, 'prerelease < release');
|
|
24
|
+
assertEq(compareVersions('0.14.0', '0.14.0-beta.1'), 1, 'release > prerelease');
|
|
25
|
+
assertEq(compareVersions('0.14.0-beta.1', '0.14.0-beta.2'), -1, 'prerelease numeric ordering');
|
|
26
|
+
assertEq(compareVersions('0.14.0-beta.10', '0.14.0-beta.2'), 1, 'prerelease numeric not lexical');
|
|
27
|
+
assertEq(compareVersions('0.14.0-beta.5', '0.14.0-beta.5'), 0, 'equal prerelease');
|
|
28
|
+
assertEq(compareVersions('0.14.0-alpha.1', '0.14.0-beta.1'),-1, 'alpha < beta lexical');
|
|
29
|
+
|
|
30
|
+
import { getLatestVersion } from './registry-check.mjs';
|
|
31
|
+
|
|
32
|
+
console.log('\n# getLatestVersion');
|
|
33
|
+
|
|
34
|
+
// Helper: build a fake fetch
|
|
35
|
+
function fakeFetch(response) {
|
|
36
|
+
return async () => response;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Success path
|
|
40
|
+
{
|
|
41
|
+
const fakeOk = fakeFetch({
|
|
42
|
+
ok: true,
|
|
43
|
+
json: async () => ({ version: '0.14.0', name: '@ulysses-ai/create-workspace' }),
|
|
44
|
+
});
|
|
45
|
+
const result = await getLatestVersion({ fetchFn: fakeOk });
|
|
46
|
+
assertEq(result, { version: '0.14.0', error: null }, 'success returns version');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Non-2xx response
|
|
50
|
+
{
|
|
51
|
+
const fake404 = fakeFetch({ ok: false, status: 404, statusText: 'Not Found' });
|
|
52
|
+
const result = await getLatestVersion({ fetchFn: fake404 });
|
|
53
|
+
assertEq(result.version, null, 'non-2xx version is null');
|
|
54
|
+
assertEq(typeof result.error, 'string', 'non-2xx returns error string');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Malformed body (no version field)
|
|
58
|
+
{
|
|
59
|
+
const fakeBad = fakeFetch({ ok: true, json: async () => ({ name: 'foo' }) });
|
|
60
|
+
const result = await getLatestVersion({ fetchFn: fakeBad });
|
|
61
|
+
assertEq(result.version, null, 'missing version field is null');
|
|
62
|
+
assertEq(typeof result.error, 'string', 'missing version returns error');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Network error (fetch throws)
|
|
66
|
+
{
|
|
67
|
+
const fakeThrow = async () => { throw new Error('ECONNREFUSED'); };
|
|
68
|
+
const result = await getLatestVersion({ fetchFn: fakeThrow });
|
|
69
|
+
assertEq(result.version, null, 'thrown error returns null version');
|
|
70
|
+
assertEq(result.error.includes('ECONNREFUSED'), true, 'thrown error message preserved');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
import { readCache, writeCache } from './registry-check.mjs';
|
|
74
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
75
|
+
import { join } from 'path';
|
|
76
|
+
import { tmpdir } from 'os';
|
|
77
|
+
|
|
78
|
+
console.log('\n# readCache / writeCache');
|
|
79
|
+
|
|
80
|
+
function withTempDir(fn) {
|
|
81
|
+
const dir = mkdtempSync(join(tmpdir(), 'reg-cache-test-'));
|
|
82
|
+
try { fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// readCache: missing file returns null
|
|
86
|
+
withTempDir((dir) => {
|
|
87
|
+
const result = readCache(join(dir, 'missing.json'));
|
|
88
|
+
assertEq(result, null, 'missing file returns null');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// readCache: malformed JSON returns null
|
|
92
|
+
withTempDir((dir) => {
|
|
93
|
+
const path = join(dir, 'bad.json');
|
|
94
|
+
writeFileSync(path, '{not json');
|
|
95
|
+
const result = readCache(path);
|
|
96
|
+
assertEq(result, null, 'malformed JSON returns null');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// readCache: valid file returns parsed object
|
|
100
|
+
withTempDir((dir) => {
|
|
101
|
+
const path = join(dir, 'good.json');
|
|
102
|
+
writeFileSync(path, JSON.stringify({ latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }));
|
|
103
|
+
const result = readCache(path);
|
|
104
|
+
assertEq(result, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }, 'valid file parsed');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// readCache: missing required fields returns null
|
|
108
|
+
withTempDir((dir) => {
|
|
109
|
+
const path = join(dir, 'partial.json');
|
|
110
|
+
writeFileSync(path, JSON.stringify({ checkedAt: '2026-04-24T21:00:00Z' }));
|
|
111
|
+
const result = readCache(path);
|
|
112
|
+
assertEq(result, null, 'missing latestVersion returns null');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// writeCache + readCache round-trip
|
|
116
|
+
withTempDir((dir) => {
|
|
117
|
+
const path = join(dir, 'rt.json');
|
|
118
|
+
writeCache(path, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' });
|
|
119
|
+
assertEq(readCache(path), { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }, 'round-trip equal');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// writeCache creates parent dir if missing
|
|
123
|
+
withTempDir((dir) => {
|
|
124
|
+
const path = join(dir, 'nested', 'deep', 'cache.json');
|
|
125
|
+
writeCache(path, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' });
|
|
126
|
+
assertEq(readCache(path)?.latestVersion, '0.14.0', 'parent dir created');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
130
|
+
process.exit(failed > 0 ? 1 : 0);
|