wavework 0.0.0-stage → 0.1.1
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/wavework.mjs +7 -0
- package/examples/shipir.wavework.json +54 -0
- package/package.json +35 -4
- package/src/cli/args.mjs +28 -0
- package/src/cli/dashboard.mjs +41 -0
- package/src/cli/diff-stat.mjs +41 -0
- package/src/cli/hooks.mjs +109 -0
- package/src/cli/init.mjs +104 -0
- package/src/cli/safe-path.mjs +63 -0
- package/src/cli/sync.mjs +302 -0
- package/src/cli/update.mjs +24 -0
- package/src/cli.mjs +78 -0
- package/src/config.mjs +313 -0
- package/src/context.mjs +133 -0
- package/src/render.mjs +148 -0
- package/src/targets.mjs +100 -0
- package/templates/agents/implementer.md +16 -0
- package/templates/dashboard/README.md +96 -0
- package/templates/dashboard/hooks/session-start.mjs +305 -0
- package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
- package/templates/dashboard/lib/ci.mjs +52 -0
- package/templates/dashboard/lib/derive.mjs +105 -0
- package/templates/dashboard/lib/settings.mjs +34 -0
- package/templates/dashboard/lib/tokens.mjs +21 -0
- package/templates/dashboard/pricing.json +11 -0
- package/templates/dashboard/public/app.css +134 -0
- package/templates/dashboard/public/app.js +260 -0
- package/templates/dashboard/public/default-theme.css +86 -0
- package/templates/dashboard/public/format.mjs +138 -0
- package/templates/dashboard/public/index.html +20 -0
- package/templates/dashboard/record.mjs +144 -0
- package/templates/dashboard/server.mjs +254 -0
- package/templates/dashboard-config.json +1 -0
- package/templates/scripts/red-green-check.sh +287 -0
- package/templates/scripts/red-green-check.test.sh +151 -0
- package/templates/settings/hooks.json +31 -0
- package/templates/skills/next-task/SKILL.md +77 -0
- package/templates/skills/next-task-batch/SKILL.md +27 -0
- package/templates/skills/next-task-beads/SKILL.md +29 -0
- package/templates/skills/next-task-branch/SKILL.md +19 -0
- package/templates/skills/next-task-code/SKILL.md +22 -0
- package/templates/skills/next-task-deps/SKILL.md +31 -0
- package/templates/skills/next-task-e2e/SKILL.md +35 -0
- package/templates/skills/next-task-findings/SKILL.md +65 -0
- package/templates/skills/next-task-finish/SKILL.md +43 -0
- package/templates/skills/next-task-implement/SKILL.md +34 -0
- package/templates/skills/next-task-plan/SKILL.md +52 -0
- package/templates/skills/next-task-review/SKILL.md +61 -0
- package/templates/skills/next-task-test/SKILL.md +30 -0
- package/templates/skills/next-task-write-tests/SKILL.md +34 -0
- package/templates/skills/text-to-task/SKILL.md +78 -0
- package/templates/skills/watch-ci/SKILL.md +42 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { lstatSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Files inside .claude/ that wavework manages itself; no template or lock entry may name them. */
|
|
5
|
+
const RESERVED = new Set(['wavework.json', 'wavework.lock.json', 'settings.json']);
|
|
6
|
+
|
|
7
|
+
function unsafe(rel, why) {
|
|
8
|
+
return new Error(`unsafe path ${JSON.stringify(rel)}: ${why}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks a path that must stay inside `<repo>/.claude/` and returns its absolute form.
|
|
13
|
+
*
|
|
14
|
+
* Lexical rules (strict, no normalization): a non-empty POSIX-style relative path whose segments
|
|
15
|
+
* are all real names (no "", ".", ".."), no backslash, no drive letter, no NUL.
|
|
16
|
+
* File system rules: `.claude` and every existing component on the way (also the final one) must
|
|
17
|
+
* not be a symlink, even when it points inside `.claude`; intermediate components must be directories.
|
|
18
|
+
* Nothing is ever followed or created here.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} claudeDir absolute path of `<repo>/.claude`
|
|
21
|
+
* @param {unknown} rel path relative to `claudeDir`
|
|
22
|
+
* @param {{ allowReserved?: boolean }} [options]
|
|
23
|
+
* @returns {string} absolute path
|
|
24
|
+
*/
|
|
25
|
+
export function safeResolve(claudeDir, rel, { allowReserved = false } = {}) {
|
|
26
|
+
if (typeof rel !== 'string' || rel === '') throw new Error(`invalid path: ${JSON.stringify(rel)} (expected a non-empty string)`);
|
|
27
|
+
if (rel.includes('\0')) throw unsafe(rel, 'contains a NUL byte');
|
|
28
|
+
if (rel.includes('\\')) throw unsafe(rel, 'contains a backslash');
|
|
29
|
+
if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) throw unsafe(rel, 'must be relative to .claude/');
|
|
30
|
+
const segments = rel.split('/');
|
|
31
|
+
if (segments.some((s) => s === '' || s === '.' || s === '..')) {
|
|
32
|
+
throw unsafe(rel, 'empty, "." or ".." segment');
|
|
33
|
+
}
|
|
34
|
+
if (!allowReserved && RESERVED.has(rel.toLowerCase())) {
|
|
35
|
+
throw unsafe(rel, 'reserved file (managed by wavework itself)');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const abs = join(claudeDir, ...segments);
|
|
39
|
+
if (!resolve(abs).startsWith(resolve(claudeDir) + sep)) throw unsafe(rel, 'resolves outside .claude/');
|
|
40
|
+
|
|
41
|
+
refuseSymlinks(claudeDir, rel, '.claude');
|
|
42
|
+
let current = claudeDir;
|
|
43
|
+
segments.forEach((segment, i) => {
|
|
44
|
+
current = join(current, segment);
|
|
45
|
+
const last = i === segments.length - 1;
|
|
46
|
+
const st = refuseSymlinks(current, rel, segment);
|
|
47
|
+
if (st && !last && !st.isDirectory()) throw unsafe(rel, `"${segment}" exists and is not a directory`);
|
|
48
|
+
});
|
|
49
|
+
return abs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** lstat without following; throws on a symlink, returns the stats (or undefined when missing). */
|
|
53
|
+
function refuseSymlinks(path, rel, name) {
|
|
54
|
+
let st;
|
|
55
|
+
try {
|
|
56
|
+
st = lstatSync(path);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code === 'ENOENT') return undefined;
|
|
59
|
+
throw unsafe(rel, `cannot inspect "${name}": ${e.code ?? e.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (st.isSymbolicLink()) throw unsafe(rel, `"${name}" is a symlink`);
|
|
62
|
+
return st;
|
|
63
|
+
}
|
package/src/cli/sync.mjs
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { buildContext } from '../context.mjs';
|
|
8
|
+
import { render } from '../render.mjs';
|
|
9
|
+
import { safeResolve } from './safe-path.mjs';
|
|
10
|
+
import { mergeHooks, parseHookDefs } from './hooks.mjs';
|
|
11
|
+
import { lineDiffStat } from './diff-stat.mjs';
|
|
12
|
+
|
|
13
|
+
export const LOCK_PATH = 'wavework.lock.json';
|
|
14
|
+
export const SETTINGS_PATH = 'settings.json';
|
|
15
|
+
export const NEW_SUFFIX = '.wavework-new';
|
|
16
|
+
export const PACKAGE_TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
|
|
17
|
+
const LOCK_FORMAT = 1;
|
|
18
|
+
|
|
19
|
+
export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
20
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
21
|
+
|
|
22
|
+
/** Optional features whose hooks (`features.<name>` in hooks.json) the config switches on. */
|
|
23
|
+
export const enabledFeatures = (config) => (config.dashboard?.enabled ? ['dashboard'] : []);
|
|
24
|
+
|
|
25
|
+
/** Hook definitions: injected object, or templates/settings/hooks.json of the package; feature hooks only when the config enables them. */
|
|
26
|
+
export function loadHookDefs(injected, config) {
|
|
27
|
+
const json = injected ?? JSON.parse(readFileSync(join(PACKAGE_TEMPLATES, 'settings', 'hooks.json'), 'utf8'));
|
|
28
|
+
return parseHookDefs(json, config ? enabledFeatures(config) : []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The default target provider is src/targets.mjs, imported lazily so init/update can be tested without it. */
|
|
32
|
+
export async function defaultTargets() {
|
|
33
|
+
try {
|
|
34
|
+
return (await import('../targets.mjs')).templateTargets;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code === 'ERR_MODULE_NOT_FOUND' && /targets\.mjs/.test(e.message)) {
|
|
37
|
+
throw new Error('this wavework installation is missing src/targets.mjs');
|
|
38
|
+
}
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TARGET_PREFIX = '.claude/';
|
|
44
|
+
const TMP_SUFFIX = '.wavework-tmp';
|
|
45
|
+
|
|
46
|
+
/** `<file>.wavework-new` and the temp files are wavework's own; no target or lock entry may use those names. */
|
|
47
|
+
function refuseInternalSuffix(rel) {
|
|
48
|
+
if (rel.endsWith(NEW_SUFFIX) || rel.endsWith(TMP_SUFFIX)) {
|
|
49
|
+
throw new Error(`unsafe path ${JSON.stringify(rel)}: names ending in ${NEW_SUFFIX} or ${TMP_SUFFIX} are reserved`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renders every target. Does not touch `.claude/`. The provider's `target` is relative to the repo root
|
|
55
|
+
* and must start with `.claude/`; it is stored (lock, reports) relative to `.claude/`. `vars` are merged
|
|
56
|
+
* over buildContext(config) (src/targets.mjs contract); `verbatim: true` skips rendering.
|
|
57
|
+
* @returns {{ target: string, content: string, hash: string, mode: number }[]}
|
|
58
|
+
*/
|
|
59
|
+
export function renderTargets(config, provider, templatesDir, repoName) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const root = resolve(templatesDir);
|
|
62
|
+
const context = buildContext(config, { repoName });
|
|
63
|
+
return provider(config).map((t) => {
|
|
64
|
+
if (typeof t?.template !== 'string' || typeof t?.target !== 'string') throw new Error('invalid target definition (template and target must be strings)');
|
|
65
|
+
if (!t.target.startsWith(TARGET_PREFIX)) throw new Error(`unsafe path ${JSON.stringify(t.target)}: targets must be inside ${TARGET_PREFIX}`);
|
|
66
|
+
const target = t.target.slice(TARGET_PREFIX.length);
|
|
67
|
+
refuseInternalSuffix(target);
|
|
68
|
+
if (seen.has(target.toLowerCase())) throw new Error(`duplicate target ${JSON.stringify(t.target)} (names are compared case-insensitively)`);
|
|
69
|
+
seen.add(target.toLowerCase());
|
|
70
|
+
const file = resolve(root, t.template);
|
|
71
|
+
if (!file.startsWith(root + sep)) throw new Error(`unsafe template path ${JSON.stringify(t.template)}`);
|
|
72
|
+
// `verbatim` targets (the dashboard runtime) are copied byte for byte: they are JavaScript and CSS, not templates.
|
|
73
|
+
const text = readFileSync(file, 'utf8');
|
|
74
|
+
const content = t.verbatim === true ? text : render(text, { ...context, ...(t.vars ?? {}) }, t.template);
|
|
75
|
+
return { target, content, hash: sha256(content), mode: t.mode ?? 0o644 };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads and validates the lock. Every path in it is checked like a template target: a tampered lock
|
|
81
|
+
* must not make update touch anything outside `.claude/`.
|
|
82
|
+
* @returns {{ version: string, files: Record<string, string>, hooks: { event: string, entry: object }[] } | null} null when absent
|
|
83
|
+
*/
|
|
84
|
+
export function readLock(claudeDir) {
|
|
85
|
+
const file = safeResolve(claudeDir, LOCK_PATH, { allowReserved: true });
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readFileSync(file, 'utf8');
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (e.code === 'ENOENT') return null;
|
|
91
|
+
throw new Error(`cannot read lock file ${file}: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
const bad = (why) => new Error(`invalid lock file ${file}: ${why}`);
|
|
94
|
+
let lock;
|
|
95
|
+
try {
|
|
96
|
+
lock = JSON.parse(text);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
throw bad(`not valid JSON (${e.message})`);
|
|
99
|
+
}
|
|
100
|
+
if (!isObject(lock)) throw bad('expected an object');
|
|
101
|
+
if (lock.lockVersion !== LOCK_FORMAT) throw bad(`unsupported lockVersion ${JSON.stringify(lock.lockVersion)}`);
|
|
102
|
+
if (typeof lock.version !== 'string') throw bad('"version" must be a string');
|
|
103
|
+
if (!isObject(lock.files)) throw bad('"files" must be an object');
|
|
104
|
+
for (const [rel, hash] of Object.entries(lock.files)) {
|
|
105
|
+
if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) throw bad(`files.${rel}: expected a sha256 hex digest`);
|
|
106
|
+
try {
|
|
107
|
+
refuseInternalSuffix(rel);
|
|
108
|
+
safeResolve(claudeDir, rel);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
throw bad(e.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(lock.hooks)) throw bad('"hooks" must be an array');
|
|
114
|
+
lock.hooks.forEach((h, i) => {
|
|
115
|
+
if (!isObject(h) || typeof h.event !== 'string' || !isObject(h.entry)) throw bad(`hooks[${i}]: expected { event, entry }`);
|
|
116
|
+
});
|
|
117
|
+
return lock;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** sha256 of the regular file at `abs`, undefined when missing. */
|
|
121
|
+
function diskHash(abs, rel) {
|
|
122
|
+
let st;
|
|
123
|
+
try {
|
|
124
|
+
st = lstatSync(abs);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e.code === 'ENOENT') return undefined;
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
if (!st.isFile()) throw new Error(`${rel} exists but is not a regular file`);
|
|
130
|
+
return sha256(readFileSync(abs));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const exists = (abs) => {
|
|
134
|
+
try {
|
|
135
|
+
lstatSync(abs);
|
|
136
|
+
return true;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e.code === 'ENOENT') return false;
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decides what to do for every file and for settings.json. Reads, never writes.
|
|
145
|
+
* Throws on any unsafe path or invalid input, so a failing plan leaves the repo untouched.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} p
|
|
148
|
+
* @param {string} p.claudeDir
|
|
149
|
+
* @param {ReturnType<typeof renderTargets>} p.rendered
|
|
150
|
+
* @param {{ files: Record<string, string>, hooks: object[] }} p.lock `{ files: {}, hooks: [] }` for init
|
|
151
|
+
* @param {{ event: string, entry: object }[]} p.defs
|
|
152
|
+
* @param {boolean} [p.adopt] `init --adopt`: existing files that differ are kept, their lock entry is the RENDERED hash,
|
|
153
|
+
* and the result carries `adoption` (identical / differing with diff stats / created). Nothing else changes.
|
|
154
|
+
*/
|
|
155
|
+
export function plan({ claudeDir, rendered, lock, defs, adopt = false }) {
|
|
156
|
+
const actions = [];
|
|
157
|
+
const adoption = { identical: [], differing: [], created: [] };
|
|
158
|
+
const files = {};
|
|
159
|
+
const wanted = new Set(rendered.map((t) => t.target));
|
|
160
|
+
|
|
161
|
+
for (const t of rendered) {
|
|
162
|
+
const abs = safeResolve(claudeDir, t.target);
|
|
163
|
+
const disk = diskHash(abs, t.target);
|
|
164
|
+
const locked = lock.files[t.target];
|
|
165
|
+
const write = (kind) => { actions.push({ kind, target: t.target, abs, content: t.content, mode: t.mode }); files[t.target] = t.hash; };
|
|
166
|
+
if (disk === undefined) {
|
|
167
|
+
write(locked === undefined ? 'create' : 'restore');
|
|
168
|
+
adoption.created.push(t.target);
|
|
169
|
+
} else if (disk === t.hash) {
|
|
170
|
+
files[t.target] = t.hash; // already what we would write: adopt
|
|
171
|
+
adoption.identical.push(t.target);
|
|
172
|
+
if ((lstatSync(abs).mode & 0o111) !== (t.mode & 0o111)) actions.push({ kind: 'mode', target: t.target, abs, mode: t.mode });
|
|
173
|
+
} else if (locked !== undefined && disk === locked) {
|
|
174
|
+
write('update'); // untouched since the last run
|
|
175
|
+
} else {
|
|
176
|
+
// Edited locally (or an unknown file sits there). The old lock hash stays, so it is reported until resolved.
|
|
177
|
+
if (locked !== undefined) files[t.target] = locked;
|
|
178
|
+
const newAbs = safeResolve(claudeDir, `${t.target}${NEW_SUFFIX}`);
|
|
179
|
+
const review = { kind: 'review', target: t.target, newTarget: `${t.target}${NEW_SUFFIX}`, newAbs, content: t.content };
|
|
180
|
+
if (adopt) {
|
|
181
|
+
// Adopted: the lock records the rendered hash, so `update` reports the file for as long as its .wavework-new exists.
|
|
182
|
+
files[t.target] = t.hash;
|
|
183
|
+
review.stat = lineDiffStat(readFileSync(abs, 'utf8'), t.content);
|
|
184
|
+
adoption.differing.push({ target: t.target, ...review.stat });
|
|
185
|
+
} else if (locked !== undefined && t.hash === locked && !exists(newAbs)) {
|
|
186
|
+
continue; // nothing new upstream, nothing to merge (a still existing .wavework-new means it is not resolved yet)
|
|
187
|
+
}
|
|
188
|
+
actions.push(review);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const [rel, locked] of Object.entries(lock.files)) {
|
|
193
|
+
if (wanted.has(rel)) continue;
|
|
194
|
+
const abs = safeResolve(claudeDir, rel);
|
|
195
|
+
const disk = diskHash(abs, rel);
|
|
196
|
+
if (disk === undefined) continue; // gone already
|
|
197
|
+
if (disk === locked) {
|
|
198
|
+
actions.push({ kind: 'delete', target: rel, abs });
|
|
199
|
+
} else {
|
|
200
|
+
files[rel] = locked;
|
|
201
|
+
actions.push({ kind: 'review-removed', target: rel });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const settingsAbs = safeResolve(claudeDir, SETTINGS_PATH, { allowReserved: true });
|
|
206
|
+
let current = {};
|
|
207
|
+
let existed = true;
|
|
208
|
+
try {
|
|
209
|
+
const text = readFileSync(settingsAbs, 'utf8');
|
|
210
|
+
try {
|
|
211
|
+
current = JSON.parse(text);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
throw new Error(`${settingsAbs} is not valid JSON (${e.message}); fix it or remove it, nothing was changed`);
|
|
214
|
+
}
|
|
215
|
+
if (!isObject(current)) throw new Error(`${settingsAbs}: expected a JSON object`);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (e.code !== 'ENOENT') throw e;
|
|
218
|
+
existed = false;
|
|
219
|
+
}
|
|
220
|
+
const merged = mergeHooks(current, defs, lock.hooks);
|
|
221
|
+
const notes = merged.lost.map((h) => `hook ${h.event}: owned entry changed locally, not re-added`);
|
|
222
|
+
const changed = JSON.stringify(merged.settings) !== JSON.stringify(current) || (!existed && Object.keys(merged.settings).length > 0);
|
|
223
|
+
if (changed) actions.push({ kind: 'settings', abs: settingsAbs, content: `${JSON.stringify(merged.settings, null, 2)}\n` });
|
|
224
|
+
|
|
225
|
+
return { actions, notes, files, adoption, hooks: merged.owned, review: actions.filter((a) => a.kind.startsWith('review')).length };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Writes via a temp file and rename, so a symlink that appeared meanwhile is replaced, never written through. */
|
|
229
|
+
function writeAtomic(abs, content, mode) {
|
|
230
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
231
|
+
const tmp = join(dirname(abs), `.${basename(abs)}${TMP_SUFFIX}`);
|
|
232
|
+
try {
|
|
233
|
+
unlinkSync(tmp);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
if (e.code !== 'ENOENT') throw e;
|
|
236
|
+
}
|
|
237
|
+
writeFileSync(tmp, content, { flag: 'wx', mode });
|
|
238
|
+
chmodSync(tmp, mode);
|
|
239
|
+
renameSync(tmp, abs);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function pruneEmptyDirs(claudeDir, from) {
|
|
243
|
+
let dir = dirname(from);
|
|
244
|
+
while (dir.startsWith(resolve(claudeDir) + sep)) {
|
|
245
|
+
try {
|
|
246
|
+
rmdirSync(dir);
|
|
247
|
+
} catch {
|
|
248
|
+
return; // not empty (or gone): stop
|
|
249
|
+
}
|
|
250
|
+
dir = dirname(dir);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Executes a plan and writes the lock last. */
|
|
255
|
+
export function apply(claudeDir, result, version) {
|
|
256
|
+
for (const a of result.actions) {
|
|
257
|
+
if (a.kind === 'create' || a.kind === 'update' || a.kind === 'restore') writeAtomic(a.abs, a.content, a.mode);
|
|
258
|
+
else if (a.kind === 'mode') chmodSync(a.abs, a.mode);
|
|
259
|
+
else if (a.kind === 'review') writeAtomic(a.newAbs, a.content, 0o644);
|
|
260
|
+
else if (a.kind === 'delete') {
|
|
261
|
+
unlinkSync(a.abs);
|
|
262
|
+
pruneEmptyDirs(claudeDir, a.abs);
|
|
263
|
+
} else if (a.kind === 'settings') writeAtomic(a.abs, a.content, 0o644);
|
|
264
|
+
}
|
|
265
|
+
const files = Object.fromEntries(Object.entries(result.files).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
266
|
+
const lock = { lockVersion: LOCK_FORMAT, version, files, hooks: result.hooks };
|
|
267
|
+
writeAtomic(safeResolve(claudeDir, LOCK_PATH, { allowReserved: true }), `${JSON.stringify(lock, null, 2)}\n`, 0o644);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const LABEL = {
|
|
271
|
+
create: 'create ', update: 'update ', restore: 'restore', delete: 'delete ', settings: 'update ', mode: 'mode ',
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** Prints one short line per change; returns the exit code (2 when files need review). */
|
|
275
|
+
export function report(result, stdout, { dryRun, done, adopt = false }) {
|
|
276
|
+
const prefix = dryRun ? 'dry-run: ' : '';
|
|
277
|
+
const counts = {};
|
|
278
|
+
for (const a of result.actions) {
|
|
279
|
+
counts[a.kind] = (counts[a.kind] ?? 0) + 1;
|
|
280
|
+
if (a.kind === 'review') {
|
|
281
|
+
const st = a.stat;
|
|
282
|
+
const note = st?.approx ? ', upper bound' : st?.whitespaceOnly ? ', whitespace/newline only' : '';
|
|
283
|
+
const stat = st ? ` (+${st.added} -${st.removed}${note})` : '';
|
|
284
|
+
const why = a.stat ? 'differs from the rendered version, kept untouched; rendered version in' : 'edited locally, kept; new version in';
|
|
285
|
+
stdout.write(`${prefix}review ${a.target}: ${why} ${a.newTarget}${stat}\n`);
|
|
286
|
+
} else if (a.kind === 'review-removed') {
|
|
287
|
+
stdout.write(`${prefix}review ${a.target}: removed upstream but edited locally, kept\n`);
|
|
288
|
+
} else {
|
|
289
|
+
stdout.write(`${prefix}${LABEL[a.kind]} ${a.kind === 'settings' ? 'settings.json (hooks)' : a.target}\n`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const note of result.notes) stdout.write(`${prefix}${note}\n`);
|
|
293
|
+
if (adopt) {
|
|
294
|
+
const { identical, differing, created } = result.adoption;
|
|
295
|
+
stdout.write(`${prefix}adopt: ${identical.length} identical, ${differing.length} differing, ${created.length} new\n`);
|
|
296
|
+
if (differing.length > 0) stdout.write(`${prefix}for a full diff run: diff <file> <file>${NEW_SUFFIX} (+N -M = lines the rendered version adds / the kept file has extra)\n`);
|
|
297
|
+
}
|
|
298
|
+
if (result.review > 0) stdout.write(`${prefix}${result.review} file(s) edited locally need review: merge the .wavework-new files, then delete them\n`);
|
|
299
|
+
if (result.actions.length === 0 && result.notes.length === 0) stdout.write(`${prefix}nothing to do\n`);
|
|
300
|
+
else if (!dryRun) stdout.write(`${done}\n`);
|
|
301
|
+
return result.review > 0 ? 2 : 0;
|
|
302
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { basename, join, resolve } from 'node:path';
|
|
2
|
+
import { loadConfig } from '../config.mjs';
|
|
3
|
+
import {
|
|
4
|
+
apply, defaultTargets, loadHookDefs, PACKAGE_TEMPLATES, plan, readLock, renderTargets, report,
|
|
5
|
+
} from './sync.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `wavework update [--dry-run]`: re-renders with the installed version and the current config and
|
|
9
|
+
* syncs `.claude/` per the lock (see docs/lock.md). Never modifies `.claude/wavework.json`.
|
|
10
|
+
*
|
|
11
|
+
* @returns {Promise<number>} exit code
|
|
12
|
+
*/
|
|
13
|
+
export async function update(args, ctx) {
|
|
14
|
+
const claudeDir = join(ctx.cwd, '.claude');
|
|
15
|
+
const lock = readLock(claudeDir);
|
|
16
|
+
if (!lock) throw new Error('no .claude/wavework.lock.json: run `wavework init` first');
|
|
17
|
+
const config = loadConfig(ctx.cwd);
|
|
18
|
+
const provider = ctx.targets ?? await defaultTargets();
|
|
19
|
+
const rendered = renderTargets(config, provider, ctx.templatesDir ?? PACKAGE_TEMPLATES, basename(resolve(ctx.cwd)));
|
|
20
|
+
const result = plan({ claudeDir, rendered, lock, defs: loadHookDefs(ctx.hooks, config) });
|
|
21
|
+
const dryRun = args['--dry-run'] === true;
|
|
22
|
+
if (!dryRun) apply(claudeDir, result, ctx.version);
|
|
23
|
+
return report(result, ctx.stdout, { dryRun, done: `wavework ${ctx.version}: updated .claude/` });
|
|
24
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { init } from './cli/init.mjs';
|
|
4
|
+
import { update } from './cli/update.mjs';
|
|
5
|
+
import { dashboard } from './cli/dashboard.mjs';
|
|
6
|
+
import { parseArgs, UsageError } from './cli/args.mjs';
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(
|
|
9
|
+
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const USAGE = `Usage: wavework <command> [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
init Set up .claude/ in the current repository [--config <file>] [--adopt] [--dry-run]
|
|
16
|
+
update Re-render .claude/ from the installed version [--dry-run]
|
|
17
|
+
dashboard Start the pipeline dashboard of this repository (installed by init)
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-v, --version Print the version
|
|
21
|
+
-h, --help Print this help
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const COMMANDS = {
|
|
25
|
+
init: { run: init, options: { '--config': 'value', '--adopt': 'flag', '--dry-run': 'flag' } },
|
|
26
|
+
update: { run: update, options: { '--dry-run': 'flag' } },
|
|
27
|
+
dashboard: { run: dashboard, options: {} },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs the CLI with the given arguments (without node and script path).
|
|
32
|
+
* Writes to the provided streams and returns the exit code; does not exit the process.
|
|
33
|
+
* Exit codes: 0 ok, 1 error, 2 done, but locally edited files need review.
|
|
34
|
+
*
|
|
35
|
+
* `io.cwd` (default: process.cwd()) is the repository root. `io.targets`, `io.templatesDir` and `io.hooks`
|
|
36
|
+
* replace the packaged target list, templates directory and hook definitions; `io.env` and `io.spawn` replace the
|
|
37
|
+
* environment and child_process.spawn of `dashboard` (tests).
|
|
38
|
+
*
|
|
39
|
+
* @param {string[]} argv
|
|
40
|
+
* @param {{ stdout: { write(s: string): unknown }, stderr: { write(s: string): unknown }, cwd?: string, targets?: Function, templatesDir?: string, hooks?: object }} io
|
|
41
|
+
* @returns {Promise<number>}
|
|
42
|
+
*/
|
|
43
|
+
export async function run(argv, io) {
|
|
44
|
+
const { stdout, stderr } = io;
|
|
45
|
+
const [command, ...rest] = argv;
|
|
46
|
+
|
|
47
|
+
if (command === undefined || command === '--help' || command === '-h') {
|
|
48
|
+
stdout.write(USAGE);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (command === '--version' || command === '-v') {
|
|
52
|
+
stdout.write(`${pkg.version}\n`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spec = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
|
|
57
|
+
if (!spec) {
|
|
58
|
+
stderr.write(`wavework: unknown command: ${command}\n\n${USAGE}`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const args = parseArgs(rest, spec.options);
|
|
63
|
+
return await spec.run(args, {
|
|
64
|
+
cwd: io.cwd ?? process.cwd(),
|
|
65
|
+
stdout,
|
|
66
|
+
version: pkg.version,
|
|
67
|
+
targets: io.targets,
|
|
68
|
+
templatesDir: io.templatesDir,
|
|
69
|
+
hooks: io.hooks,
|
|
70
|
+
env: io.env,
|
|
71
|
+
spawn: io.spawn,
|
|
72
|
+
});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof UsageError) stderr.write(`wavework ${command}: ${e.message}\n\n${USAGE}`);
|
|
75
|
+
else stderr.write(`wavework ${command}: ${e.message}\n`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|