@ulysses-ai/create-workspace 0.13.0-beta.2 → 0.14.0-beta.3
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 +15 -3
- package/package.json +1 -1
- package/template/.claude/hooks/_bash-output-advisory.test.mjs +88 -0
- package/template/.claude/hooks/bash-output-advisory.mjs +77 -0
- 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/rules/memory-guidance.md +47 -0
- package/template/.claude/rules/task-list-mirroring.md +69 -0
- package/template/.claude/rules/token-economics.md.skip +23 -8
- package/template/.claude/rules/workspace-structure.md +2 -0
- package/template/.claude/scripts/build-shared-context-index.mjs +212 -0
- package/template/.claude/scripts/build-shared-context-index.test.mjs +318 -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/sync-tasks.mjs +234 -0
- package/template/.claude/scripts/sync-tasks.test.mjs +350 -0
- package/template/.claude/settings.json +20 -9
- package/template/.claude/skills/braindump/SKILL.md +15 -0
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +4 -4
- package/template/.claude/skills/complete-work/SKILL.md +47 -55
- package/template/.claude/skills/handoff/SKILL.md +15 -0
- package/template/.claude/skills/maintenance/SKILL.md +49 -7
- package/template/.claude/skills/pause-work/SKILL.md +25 -4
- package/template/.claude/skills/release/SKILL.md +59 -43
- package/template/.claude/skills/start-work/SKILL.md +34 -2
- package/template/.claude/skills/workspace-update/SKILL.md +16 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/_gitignore +2 -3
- package/template/workspace.json.tmpl +1 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ukt-solutions/create-ulysses-workspace/main/docs/assets/logo-dark.png">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/ukt-solutions/create-ulysses-workspace/main/docs/assets/logo.png" alt="Ulysses Workspace" width="220">
|
|
5
|
+
</picture>
|
|
3
6
|
</p>
|
|
4
7
|
|
|
5
8
|
# @ulysses-ai/create-workspace
|
|
@@ -86,8 +89,8 @@ Four things, in the order you'll touch them:
|
|
|
86
89
|
A scaffolded workspace with:
|
|
87
90
|
|
|
88
91
|
- **14 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
|
|
89
|
-
- **
|
|
90
|
-
- **
|
|
92
|
+
- **7 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
|
|
93
|
+
- **10 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
|
|
91
94
|
- A **`shared-context/`** memory system with three visibility levels: locked (team truths), root (team-visible ephemerals), user-scoped (personal)
|
|
92
95
|
- Conventions for **multi-repo work sessions** with isolated git worktrees, parallelizable from separate terminals
|
|
93
96
|
|
|
@@ -131,6 +134,15 @@ The eleven [chapters](https://github.com/ukt-solutions/create-ulysses-workspace/
|
|
|
131
134
|
|
|
132
135
|
In active pre-1.0 development. Used as dogfood and validated against external workspaces. Conventions and CLI flags are stable; small refinements continue. v1.0 will mark a stability commitment.
|
|
133
136
|
|
|
137
|
+
## Feedback
|
|
138
|
+
|
|
139
|
+
Beta testers, early adopters, and anyone curious — feedback of any shape is genuinely wanted.
|
|
140
|
+
|
|
141
|
+
- **Bugs, rough edges, "this didn't do what I expected"** → [open an issue](https://github.com/ukt-solutions/create-ulysses-workspace/issues/new)
|
|
142
|
+
- **Design questions, "why does it work this way?", ideas for new skills or rules** → [start a discussion](https://github.com/ukt-solutions/create-ulysses-workspace/discussions)
|
|
143
|
+
|
|
144
|
+
First-hand reports of the scaffold-and-go-for-it experience are the single most useful thing at this stage — especially anything that surprised you, tripped you up, or felt like friction the CLI could have caught.
|
|
145
|
+
|
|
134
146
|
## License
|
|
135
147
|
|
|
136
148
|
MIT
|
package/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for bash-output-advisory.mjs pattern detection.
|
|
3
|
+
// Run: node .claude/hooks/_bash-output-advisory.test.mjs
|
|
4
|
+
import { detectNoisyPattern } from './bash-output-advisory.mjs';
|
|
5
|
+
|
|
6
|
+
let failed = 0;
|
|
7
|
+
let passed = 0;
|
|
8
|
+
|
|
9
|
+
function shouldWarn(command, label) {
|
|
10
|
+
const result = detectNoisyPattern(command);
|
|
11
|
+
if (result) {
|
|
12
|
+
passed++;
|
|
13
|
+
} else {
|
|
14
|
+
failed++;
|
|
15
|
+
console.error(` FAIL: ${label}\n command: ${command}\n expected an advisory, got null`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shouldNotWarn(command, label) {
|
|
20
|
+
const result = detectNoisyPattern(command);
|
|
21
|
+
if (!result) {
|
|
22
|
+
passed++;
|
|
23
|
+
} else {
|
|
24
|
+
failed++;
|
|
25
|
+
console.error(` FAIL: ${label}\n command: ${command}\n expected null, got: ${result}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// === Test runners ===
|
|
30
|
+
shouldWarn('npm test', 'bare npm test');
|
|
31
|
+
shouldWarn('npm run test', 'bare npm run test');
|
|
32
|
+
shouldWarn('yarn test', 'bare yarn test');
|
|
33
|
+
shouldWarn('pnpm test', 'bare pnpm test');
|
|
34
|
+
shouldWarn('bun test', 'bare bun test');
|
|
35
|
+
shouldWarn('cargo test', 'bare cargo test');
|
|
36
|
+
shouldWarn('npm test --coverage', 'npm test with non-scoping flag');
|
|
37
|
+
|
|
38
|
+
shouldNotWarn('npm test path/to/file.test.mjs', 'npm test with file path');
|
|
39
|
+
shouldNotWarn('npm test src/', 'npm test with directory');
|
|
40
|
+
shouldNotWarn('npm test -- --grep auth', 'npm test with -- args');
|
|
41
|
+
shouldNotWarn('npm test myFile.test.js', 'npm test with bare filename');
|
|
42
|
+
shouldNotWarn('npm test | grep FAIL', 'npm test piped to grep');
|
|
43
|
+
shouldNotWarn('npm test | head -50', 'npm test piped to head');
|
|
44
|
+
shouldNotWarn('cargo test auth_module', 'cargo test with module filter');
|
|
45
|
+
|
|
46
|
+
// === grep -r ===
|
|
47
|
+
shouldWarn('grep -r "pattern" .', 'bare grep -r');
|
|
48
|
+
shouldWarn('grep --recursive "pattern" src/', 'grep --recursive long form');
|
|
49
|
+
shouldWarn('grep -rn "TODO" .', 'grep -rn (recursive + line numbers)');
|
|
50
|
+
|
|
51
|
+
shouldNotWarn('grep -r --include="*.js" pattern .', 'grep -r with --include');
|
|
52
|
+
shouldNotWarn('grep -r pattern . --exclude="*.log"', 'grep -r with --exclude');
|
|
53
|
+
shouldNotWarn('grep "pattern" file.txt', 'non-recursive grep');
|
|
54
|
+
|
|
55
|
+
// === find on broad anchors ===
|
|
56
|
+
shouldWarn('find /', 'find on root');
|
|
57
|
+
shouldWarn('find ~', 'find on home tilde');
|
|
58
|
+
shouldWarn('find $HOME', 'find on $HOME');
|
|
59
|
+
|
|
60
|
+
shouldNotWarn('find / -name "*.log"', 'find / with -name');
|
|
61
|
+
shouldNotWarn('find ~ -path "*node_modules*" -prune', 'find ~ with -path');
|
|
62
|
+
shouldNotWarn('find . -type f', 'find on cwd');
|
|
63
|
+
shouldNotWarn('find ./src -name "*.ts"', 'find on relative subdir');
|
|
64
|
+
|
|
65
|
+
// === cat on log-shaped files ===
|
|
66
|
+
shouldWarn('cat server.log', 'cat .log file');
|
|
67
|
+
shouldWarn('cat events.jsonl', 'cat .jsonl file');
|
|
68
|
+
shouldWarn('cat trace.ndjson', 'cat .ndjson file');
|
|
69
|
+
shouldWarn('cat path/to/big.log', 'cat .log in subdir');
|
|
70
|
+
|
|
71
|
+
shouldNotWarn('cat package.json', 'cat package.json');
|
|
72
|
+
shouldNotWarn('cat README.md', 'cat README');
|
|
73
|
+
shouldNotWarn('cat src/index.ts', 'cat source file');
|
|
74
|
+
shouldNotWarn('cat server.log | tail -50', 'cat .log piped to tail');
|
|
75
|
+
|
|
76
|
+
// === redirected output (always allowed) ===
|
|
77
|
+
shouldNotWarn('npm test > /tmp/test-output.txt', 'npm test redirected to file');
|
|
78
|
+
shouldNotWarn('grep -r foo . > out.txt', 'grep -r redirected to file');
|
|
79
|
+
shouldNotWarn('find / 2>&1 | tee /tmp/find.txt', 'find piped to tee');
|
|
80
|
+
|
|
81
|
+
// === unrelated commands ===
|
|
82
|
+
shouldNotWarn('git status', 'git status');
|
|
83
|
+
shouldNotWarn('ls -la', 'ls');
|
|
84
|
+
shouldNotWarn('echo hello', 'echo');
|
|
85
|
+
shouldNotWarn('node --version', 'node version');
|
|
86
|
+
|
|
87
|
+
console.log(`Passed: ${passed}, Failed: ${failed}`);
|
|
88
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook — soft advisory for known-noisy bash commands.
|
|
3
|
+
//
|
|
4
|
+
// Does NOT modify the command. Emits a one-line additionalContext nudge so
|
|
5
|
+
// the model can choose to scope the command before executing. Patterns are
|
|
6
|
+
// deliberately narrow — false positives train the model to ignore the hook.
|
|
7
|
+
//
|
|
8
|
+
// Add patterns sparingly. The cost of a missed nudge is one bloated tool
|
|
9
|
+
// result; the cost of crying wolf is a permanently-ignored hook.
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { readStdin, respond } from './_utils.mjs';
|
|
12
|
+
|
|
13
|
+
// Only run the hook body when invoked directly (not when imported by tests).
|
|
14
|
+
const isEntry = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
15
|
+
if (isEntry) {
|
|
16
|
+
const input = await readStdin();
|
|
17
|
+
if (input.tool_name !== 'Bash') {
|
|
18
|
+
respond();
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const command = (input.tool_input?.command || '').trim();
|
|
23
|
+
if (!command) {
|
|
24
|
+
respond();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const advisory = detectNoisyPattern(command);
|
|
29
|
+
if (advisory) {
|
|
30
|
+
respond(`Bash advisory: ${advisory} Consider scoping the command (path, filter, or pipe to head/tail/grep) before running.`);
|
|
31
|
+
} else {
|
|
32
|
+
respond();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detectNoisyPattern(command) {
|
|
37
|
+
// Already piped to a bounding command — caller knows what they're doing.
|
|
38
|
+
if (/\|\s*(head|tail|grep|rg|wc|less|more)\b/.test(command)) return null;
|
|
39
|
+
// Output already redirected to a file — not consuming context.
|
|
40
|
+
if (/(?:^|\s)(?:>|>>|\|\s*tee)\s+\S/.test(command)) return null;
|
|
41
|
+
|
|
42
|
+
// 1. Bare test runners with no scope.
|
|
43
|
+
// Matches `npm test`, `npm run test`, `yarn test`, `pnpm test`, `bun test`, `cargo test`
|
|
44
|
+
// followed by nothing, or only by recognized flags that don't constrain output.
|
|
45
|
+
const testRunner = /^(?:npm(?:\s+run)?|yarn|pnpm|bun)\s+test\b(.*)$/.exec(command)
|
|
46
|
+
|| /^cargo\s+test\b(.*)$/.exec(command);
|
|
47
|
+
if (testRunner) {
|
|
48
|
+
const tail = testRunner[1];
|
|
49
|
+
// A scope is any non-flag positional arg (path, filename, or test-name filter).
|
|
50
|
+
// Flags that don't constrain output (--coverage, --watch, --verbose, --bail) don't count.
|
|
51
|
+
const hasScope = tail.split(/\s+/).some(arg => arg && !arg.startsWith('-'));
|
|
52
|
+
if (!hasScope) {
|
|
53
|
+
return 'Bare test-runner invocation will produce all-tests output.';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Recursive grep without an include filter.
|
|
58
|
+
// Note: ripgrep (`rg`) is recursive by default and respects .gitignore, so a
|
|
59
|
+
// bare `rg pattern .` is usually fine. We only flag classic `grep -r`.
|
|
60
|
+
if (/^grep\s+(?:-\w*r\w*|-r\b|--recursive\b)/.test(command)
|
|
61
|
+
&& !/--include[=\s]|--exclude[=\s]/.test(command)) {
|
|
62
|
+
return 'Recursive grep without --include can return thousands of matches.';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. find on a home/root anchor without a name/path constraint.
|
|
66
|
+
if (/^find\s+(?:\/|~|\$HOME|\$\{HOME\})(?:\s|$)/.test(command)
|
|
67
|
+
&& !/-(?:name|iname|path|ipath|regex)\b/.test(command)) {
|
|
68
|
+
return 'find on a broad anchor without -name/-path enumerates the whole tree.';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 4. cat on log-shaped files.
|
|
72
|
+
if (/^cat\s+\S*\.(?:log|jsonl|ndjson)\b/.test(command)) {
|
|
73
|
+
return 'Log-shaped files are often large.';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
@@ -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
|
+
}
|