wize-dev-kit 0.26.0 → 0.27.2

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 (71) hide show
  1. package/ARCH.md +2 -1
  2. package/CHANGELOG.md +70 -0
  3. package/README.es.md +28 -6
  4. package/README.md +28 -6
  5. package/README.pt-BR.md +28 -6
  6. package/adapters/continue/render.js +2 -3
  7. package/adapters/copilot/render.js +3 -3
  8. package/adapters/cursor/render.js +2 -3
  9. package/adapters/generic/render.js +2 -3
  10. package/adapters/opencode/render.js +3 -4
  11. package/adapters/windsurf/render.js +2 -3
  12. package/docs/WORKTREES.md +150 -0
  13. package/docs/WORKTREES.pt-BR.md +150 -0
  14. package/docs/harnesses/antigravity.md +2 -0
  15. package/docs/harnesses/antigravity.pt-BR.md +2 -0
  16. package/docs/harnesses/claude-code.md +2 -0
  17. package/docs/harnesses/claude-code.pt-BR.md +2 -0
  18. package/docs/harnesses/codex.md +3 -1
  19. package/docs/harnesses/codex.pt-BR.md +3 -1
  20. package/docs/harnesses/continue.md +2 -0
  21. package/docs/harnesses/continue.pt-BR.md +2 -0
  22. package/docs/harnesses/copilot.md +2 -0
  23. package/docs/harnesses/copilot.pt-BR.md +2 -0
  24. package/docs/harnesses/cursor.md +2 -0
  25. package/docs/harnesses/cursor.pt-BR.md +2 -0
  26. package/docs/harnesses/generic.md +2 -0
  27. package/docs/harnesses/generic.pt-BR.md +2 -0
  28. package/docs/harnesses/hermes.md +2 -0
  29. package/docs/harnesses/hermes.pt-BR.md +2 -0
  30. package/docs/harnesses/kimi-code.md +2 -0
  31. package/docs/harnesses/kimi-code.pt-BR.md +2 -0
  32. package/docs/harnesses/kiro.md +2 -0
  33. package/docs/harnesses/kiro.pt-BR.md +2 -0
  34. package/docs/harnesses/opencode.md +2 -0
  35. package/docs/harnesses/opencode.pt-BR.md +2 -0
  36. package/docs/harnesses/windsurf.md +2 -0
  37. package/docs/harnesses/windsurf.pt-BR.md +2 -0
  38. package/package.json +1 -1
  39. package/src/orchestrator-skills/module.yaml +1 -1
  40. package/src/orchestrator-skills/wize/skill.md +17 -7
  41. package/src/orchestrator-skills/wize-help/skill.md +20 -7
  42. package/src/orchestrator-skills/wize-update/skill.md +33 -27
  43. package/src/security-overlay/skills/wize-sec-recon/scripts/run-recon.js +4 -4
  44. package/src/security-overlay/skills/wize-sec-recon/skill.md +1 -1
  45. package/tools/installer/commands/doctor.js +186 -40
  46. package/tools/installer/commands/document-project.js +13 -3
  47. package/tools/installer/commands/hooks.js +215 -0
  48. package/tools/installer/commands/hydrate.js +298 -0
  49. package/tools/installer/commands/sync.js +50 -23
  50. package/tools/installer/commands/update.js +596 -120
  51. package/tools/installer/commands/version-check.js +69 -10
  52. package/tools/installer/document-project/modes/full-rescan.js +1 -1
  53. package/tools/installer/document-project/modes/initial-scan.js +5 -4
  54. package/tools/installer/document-project/modes/quick.js +11 -4
  55. package/tools/installer/git-context.js +219 -0
  56. package/tools/installer/git-ops.js +211 -0
  57. package/tools/installer/install-plan.js +136 -0
  58. package/tools/installer/marker.js +67 -0
  59. package/tools/installer/project-root.js +68 -0
  60. package/tools/installer/project-status.js +171 -0
  61. package/tools/installer/render-shared.js +170 -4
  62. package/tools/installer/render-targets.js +112 -0
  63. package/tools/installer/semver.js +59 -0
  64. package/tools/installer/setup-helpers.js +89 -31
  65. package/tools/installer/templates/hydrate.sh +163 -0
  66. package/tools/installer/templates/wize-kit-update.yml +38 -0
  67. package/tools/installer/toml.js +133 -0
  68. package/tools/installer/util.js +16 -0
  69. package/tools/installer/version-check.js +167 -37
  70. package/tools/installer/wize-cli.js +216 -78
  71. package/tools/installer/sync.js +0 -29
@@ -1,56 +1,39 @@
1
- // `wize-dev-kit update` — refresh an installed kit to the version currently
2
- // resolved by `node_modules/wize-dev-kit`. Preserves .wize/config/user.toml,
3
- // re-runs the active IDE adapters, and re-applies the .gitignore block when
4
- // it was opted-in originally.
1
+ // `wize-dev-kit update` — converge a project to the kit binary that is running.
2
+ //
3
+ // The tracked changes an update needs are small: the `kit_version` pin in
4
+ // .wize/config/project.toml, the managed .gitignore block, the bootstrap block
5
+ // in a kit-generated AGENTS.md, the managed .wize/hooks/hydrate.sh when one is
6
+ // installed, and the removal of generated `wize` alias files from the index. In a git repo the default mode
7
+ // prepares them as one commit on `chore/wize-kit-<version>` inside a throwaway
8
+ // worktree, so the user's checkout, branch, index and dirty files are never
9
+ // touched and nothing is ever committed on the default branch. `--pr` pushes
10
+ // that branch and opens a pull request. `--in-place` (and any non-git
11
+ // directory) edits the working tree directly, re-renders the adapters and
12
+ // never commits.
13
+ //
14
+ // Never touched: user.toml, tea.toml, .wize/custom/, artifacts, knowledge.
5
15
 
6
16
  'use strict';
7
17
 
8
18
  const fs = require('node:fs');
19
+ const os = require('node:os');
9
20
  const path = require('node:path');
10
- const { applyGitignore } = require('../setup-helpers.js');
21
+ const { ALIAS_PATHS, computeGitignore } = require('../setup-helpers.js');
22
+ const { readToml, parseToml, loadProjectConfig, rewriteTomlValue } = require('../toml.js');
23
+ const { compareVersions } = require('../semver.js');
24
+ const { renderTargets, outputSpecs } = require('../render-targets.js');
25
+ const { writeMarker } = require('../marker.js');
26
+ const { kitVersionOf } = require('../project-status.js');
27
+ const {
28
+ gitContext, defaultBranch, hasRemote, worktreeList, statusPorcelain, dirtyTrackedSet, localBranchExists, refExists
29
+ } = require('../git-context.js');
30
+ const ops = require('../git-ops.js');
31
+ const { isKitAgentsMd, upsertBootstrapBlock } = require('../render-shared.js');
32
+ const { refreshedHook, HOOK_REL } = require('./hooks.js');
11
33
 
12
- // Minimal TOML reader for the subset we write — handles `[section]` headers,
13
- // `key = "value"` scalars, and `key = ["a", "b"]` string arrays.
14
- function readToml(file) {
15
- if (!fs.existsSync(file)) return {};
16
- const content = fs.readFileSync(file, 'utf-8');
17
- const out = {};
18
- let section = null;
19
- for (const raw of content.split('\n')) {
20
- const line = raw.replace(/#.*$/, '').trim();
21
- if (!line) continue;
22
- const head = line.match(/^\[([^\]]+)\]$/);
23
- if (head) { section = head[1]; out[section] = out[section] || {}; continue; }
24
- const kv = line.match(/^([a-zA-Z_][\w-]*)\s*=\s*(.+)$/);
25
- if (!kv) continue;
26
- const [, key, valRaw] = kv;
27
- const v = parseTomlValue(valRaw.trim());
28
- if (section) out[section][key] = v;
29
- else out[key] = v;
30
- }
31
- return out;
32
- }
34
+ const PROJECT_TOML = '.wize/config/project.toml';
33
35
 
34
- function parseTomlValue(raw) {
35
- if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
36
- if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
37
- if (raw.startsWith('[') && raw.endsWith(']')) {
38
- return raw.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
39
- }
40
- if (raw === 'true') return true;
41
- if (raw === 'false') return false;
42
- if (!isNaN(Number(raw))) return Number(raw);
43
- return raw;
44
- }
45
-
46
- function loadProjectConfig(projectRoot) {
47
- return readToml(path.join(projectRoot, '.wize/config/project.toml'));
48
- }
49
-
50
- function loadInstalledKitVersion(kitRoot) {
51
- try { return require(path.join(kitRoot, 'package.json')).version; }
52
- catch { return null; }
53
- }
36
+ const loadInstalledKitVersion = kitVersionOf;
54
37
 
55
38
  // Returns a list of CHANGELOG entries (markdown blocks) for versions strictly
56
39
  // between `from` and `to`. Best-effort — if CHANGELOG layout is unusual,
@@ -69,104 +52,597 @@ function changelogBetween(kitRoot, fromVersion, toVersion) {
69
52
  matches[i].end = i + 1 < matches.length ? matches[i + 1].start : content.length;
70
53
  }
71
54
  return matches
72
- .filter(x => compare(x.v, fromVersion) > 0 && compare(x.v, toVersion) <= 0)
55
+ .filter(x => compareVersions(x.v, fromVersion) > 0 && compareVersions(x.v, toVersion) <= 0)
73
56
  .map(x => content.slice(x.start, x.end).trim());
74
57
  }
75
58
 
76
- function compare(a, b) {
77
- const pa = a.split('.').map(Number);
78
- const pb = b.split('.').map(Number);
79
- for (let i = 0; i < 3; i++) {
80
- if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
59
+ const excerpt = (entry) => entry.split('\n').slice(0, 4).join('\n');
60
+
61
+ // ---- plan ------------------------------------------------------------------
62
+
63
+ // A plan source answers two questions about one tree: what does a file say,
64
+ // and which files under these paths are tracked. Paths are relative to the
65
+ // project root.
66
+ function fsSource(root) {
67
+ return {
68
+ read(rel) {
69
+ try {
70
+ const file = path.join(root, rel);
71
+ if (!fs.lstatSync(file).isFile()) return null;
72
+ return fs.readFileSync(file, 'utf-8');
73
+ } catch { return null; }
74
+ },
75
+ special(rel) {
76
+ try { const st = fs.lstatSync(path.join(root, rel)); return !st.isFile(); } catch { return false; }
77
+ },
78
+ tracked: (paths) => ops.lsFiles(root, paths)
79
+ };
80
+ }
81
+
82
+ function refSource(root, ref) {
83
+ return {
84
+ read: (rel) => ops.showFile(root, ref, rel),
85
+ special: (rel) => ops.isSpecialAt(root, ref, rel),
86
+ tracked: (paths) => ops.lsTree(root, ref, paths)
87
+ };
88
+ }
89
+
90
+ // The tracked changes needed to converge a tree to kit version `to`. Pure:
91
+ // reads through `source` (a directory or a fs/ref source) and writes nothing.
92
+ // Returns { ok, from, to, files: [{ path, content, mode }], untrack, converged },
93
+ // or { ok: false, error: 'no-project' | 'toml-write' }.
94
+ //
95
+ // `untrack` is limited to tracked files under the exact ALIAS_PATHS: the
96
+ // managed block ignores those by construction, and other tracked-but-ignored
97
+ // files (repos that deliberately commit wize-* outputs) stay as they are.
98
+ function planTrackedChanges(treeRootOrSource, to, { refreshGitignore = true } = {}) {
99
+ const source = typeof treeRootOrSource === 'string' ? fsSource(treeRootOrSource) : treeRootOrSource;
100
+ const toml = source.read(PROJECT_TOML);
101
+ const cfg = toml === null ? {} : parseToml(toml);
102
+ if (!cfg.project) return { ok: false, error: 'no-project' };
103
+ const from = cfg.project.kit_version || '0.0.0';
104
+
105
+ const files = [];
106
+ const nextToml = rewriteTomlValue(toml, 'project', 'kit_version', to);
107
+ if (nextToml === null) return { ok: false, error: 'toml-write', from };
108
+ if (nextToml !== toml) files.push({ path: PROJECT_TOML, content: nextToml, mode: 'pin' });
109
+
110
+ let untrack = [];
111
+ if (refreshGitignore) {
112
+ // A symlinked .gitignore is the user's own arrangement: left alone.
113
+ const gi = source.special('.gitignore') ? { changed: false } : computeGitignore(source.read('.gitignore'));
114
+ if (gi.changed) files.push({ path: '.gitignore', content: gi.next, mode: gi.mode });
115
+ untrack = source.tracked(ALIAS_PATHS);
116
+ }
117
+
118
+ // Only an AGENTS.md this kit generated gets the block; a foreign one is never edited.
119
+ const agents = source.read('AGENTS.md');
120
+ if (isKitAgentsMd(agents)) {
121
+ const next = upsertBootstrapBlock(agents);
122
+ if (next !== agents) files.push({ path: 'AGENTS.md', content: next, mode: 'bootstrap block' });
81
123
  }
82
- return 0;
124
+ const hook = source.read(HOOK_REL);
125
+ const hookNext = hook === null ? null : refreshedHook(hook);
126
+ if (hookNext !== null) files.push({ path: HOOK_REL, content: hookNext, mode: 'managed hook', exec: true });
127
+ return { ok: true, from, to, files, untrack, converged: files.length === 0 && untrack.length === 0 };
83
128
  }
84
129
 
85
- // Re-runs every adapter that the project opted into at install time, using
86
- // the current versions of the kit's renderers.
87
- function rerunAdapters({ kitRoot, projectRoot, ideTargets, profiles, log }) {
88
- const results = [];
89
- for (const code of ideTargets) {
90
- const renderPath = path.join(kitRoot, 'adapters', code, 'render.js');
91
- if (!fs.existsSync(renderPath)) {
92
- results.push({ code, skipped: true, reason: 'adapter missing in this kit version' });
93
- log(` - ${code}: skipped (adapter no longer exists)`);
94
- continue;
95
- }
96
- delete require.cache[require.resolve(renderPath)]; // make `update` idempotent in the same process
97
- try {
98
- const mod = require(renderPath);
99
- const out = mod.render(kitRoot, projectRoot, { profiles });
100
- const n = out && Array.isArray(out.written) ? out.written.length : 0;
101
- results.push({ code, written: n });
102
- log(` ✓ ${code}: ${n} file(s) refreshed`);
103
- } catch (err) {
104
- results.push({ code, error: err.message });
105
- log(` ✖ ${code}: ${err.message}`);
106
- }
130
+ // Writes the plan into `treeRoot`. Refuses (returns { error }) before writing
131
+ // anything when a target, or a directory above it, is a symlink: a write
132
+ // would land outside the tree. A throwing caller never sees partial writes.
133
+ function unsafeTarget(treeRoot, rel) {
134
+ const parts = rel.split('/');
135
+ let cur = treeRoot;
136
+ for (const part of parts) {
137
+ cur = path.join(cur, part);
138
+ let st;
139
+ try { st = fs.lstatSync(cur); } catch { return false; }
140
+ if (st.isSymbolicLink()) return true;
107
141
  }
108
- return results;
142
+ return false;
109
143
  }
110
144
 
111
- function writeUpdatedProjectToml(projectRoot, currentToml, newKitVersion) {
112
- // Surgical rewrite — only updates `kit_version = ...` under `[project]`.
113
- // Leaves everything else as the user/team configured it.
114
- const file = path.join(projectRoot, '.wize/config/project.toml');
115
- if (!fs.existsSync(file)) return false;
116
- const content = fs.readFileSync(file, 'utf-8');
117
- const updated = content.replace(/^kit_version\s*=\s*".*"$/m, `kit_version = "${newKitVersion}"`);
118
- if (updated === content) return false;
119
- fs.writeFileSync(file, updated, 'utf-8');
120
- return true;
145
+ function writePlan(treeRoot, plan) {
146
+ const bad = plan.files.filter(f => unsafeTarget(treeRoot, f.path)).map(f => f.path);
147
+ if (bad.length) return { error: `refusing to write through a symlink: ${bad.join(', ')}; replace it with a regular file` };
148
+ for (const f of plan.files) {
149
+ const file = path.join(treeRoot, f.path);
150
+ fs.mkdirSync(path.dirname(file), { recursive: true });
151
+ fs.writeFileSync(file, f.content, 'utf-8');
152
+ if (f.exec) fs.chmodSync(file, 0o755);
153
+ }
154
+ return {};
155
+ }
156
+
157
+ // ---- options and output ------------------------------------------------------
158
+
159
+ function readOpts(opts) {
160
+ const flag = (kebab, camel) => !!(opts[kebab] ?? opts[camel]);
161
+ return {
162
+ pr: !!opts.pr,
163
+ inPlace: flag('in-place', 'inPlace'),
164
+ check: !!opts.check,
165
+ dryRun: flag('dry-run', 'dryRun'),
166
+ json: !!opts.json,
167
+ base: opts.base,
168
+ allowDowngrade: flag('allow-downgrade', 'allowDowngrade'),
169
+ noRender: flag('no-render', 'noRender'),
170
+ refreshGitignore: opts.refreshGitignore !== false
171
+ };
172
+ }
173
+
174
+ function printChangelog(log, kitRoot, entries) {
175
+ if (!entries.length) return;
176
+ log('\nWhat changed (excerpt from CHANGELOG):');
177
+ for (const e of entries) {
178
+ log(excerpt(e).split('\n').map(l => ' ' + l).join('\n'));
179
+ log(' …');
180
+ }
181
+ log(`\nFull details: ${path.join(kitRoot, 'CHANGELOG.md')}`);
182
+ }
183
+
184
+ // ---- base resolution (isolated mode) -----------------------------------------
185
+
186
+ // { base, def, warning } or { error }. With `fetch` false (check/dry-run) the
187
+ // remote is not contacted and origin/<default> is used if it exists.
188
+ function resolveBase(projectRoot, baseFlag, { fetch }) {
189
+ if (baseFlag !== undefined && baseFlag !== null) {
190
+ if (typeof baseFlag !== 'string' || !baseFlag.trim()) return { error: '--base needs a ref' };
191
+ const base = baseFlag.trim();
192
+ if (!refExists(projectRoot, base)) return { error: `base ${base} not found` };
193
+ return { base, def: defaultBranch(projectRoot) || base.replace(/^origin\//, ''), warning: null };
194
+ }
195
+ const def = defaultBranch(projectRoot);
196
+ if (!def) return { error: 'cannot determine default branch; pass --base <ref> or --in-place' };
197
+ const remoteRef = `origin/${def}`;
198
+ if (!fetch) {
199
+ if (refExists(projectRoot, remoteRef)) return { base: remoteRef, def, warning: null };
200
+ if (refExists(projectRoot, def)) return { base: def, def, warning: null };
201
+ return { error: `cannot find ${def}; pass --base <ref> or --in-place` };
202
+ }
203
+ const fetched = hasRemote(projectRoot) && ops.fetch(projectRoot, 'origin', def).ok;
204
+ if (fetched && refExists(projectRoot, remoteRef)) return { base: remoteRef, def, warning: null };
205
+ if (refExists(projectRoot, def)) return { base: def, def, warning: `using local ${def}; fetch failed` };
206
+ if (refExists(projectRoot, remoteRef)) return { base: remoteRef, def, warning: `using ${remoteRef}; fetch failed` };
207
+ return { error: `cannot find ${def}; pass --base <ref> or --in-place` };
208
+ }
209
+
210
+ // ---- temp worktree -------------------------------------------------------------
211
+
212
+ // Runs fn(treeRoot) inside a detached worktree of `ref`, removing it on every
213
+ // exit path including SIGINT/SIGTERM.
214
+ function withTempWorktree(projectRoot, toplevel, ref, fn) {
215
+ const rel = path.relative(toplevel, projectRoot);
216
+ const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wize-update-')));
217
+ let cleaned = false;
218
+ const cleanup = () => {
219
+ if (cleaned) return;
220
+ cleaned = true;
221
+ ops.worktreeRemove(projectRoot, tmp);
222
+ ops.rmDir(tmp);
223
+ ops.worktreeForget(projectRoot, tmp);
224
+ };
225
+ const onInt = () => { cleanup(); process.exit(130); };
226
+ const onTerm = () => { cleanup(); process.exit(143); };
227
+ process.once('SIGINT', onInt);
228
+ process.once('SIGTERM', onTerm);
229
+ try {
230
+ const added = ops.worktreeAdd(projectRoot, tmp, ref);
231
+ if (!added.ok) return { error: `could not create a temp worktree: ${added.err}` };
232
+ return fn(path.join(tmp, rel), tmp);
233
+ } finally {
234
+ process.removeListener('SIGINT', onInt);
235
+ process.removeListener('SIGTERM', onTerm);
236
+ cleanup();
237
+ }
121
238
  }
122
239
 
123
- async function cmdUpdate({ kitRoot, projectRoot, opts = {} } = {}) {
124
- const log = opts.log || console.log;
240
+ // 'converge' | 'downgrade' | 'bump': one verb for the commit and the PR.
241
+ function updateVerb(from, to) {
242
+ if (from === to) return 'converge';
243
+ return compareVersions(to, from) < 0 ? 'downgrade' : 'bump';
244
+ }
245
+
246
+ function updateSubject(from, to) {
247
+ const verb = updateVerb(from, to);
248
+ return verb === 'converge' ? `chore(wize): converge kit ${to}` : `chore(wize): ${verb} kit ${from} -> ${to}`;
249
+ }
250
+
251
+ function commitMessage(from, to, plan) {
252
+ const subject = updateSubject(from, to);
253
+ const lines = [`Converge this project to wize-dev-kit ${to}.`, ''];
254
+ if (plan.files.length) {
255
+ lines.push('Updated:');
256
+ for (const f of plan.files) lines.push(`- ${f.path}`);
257
+ }
258
+ if (plan.untrack.length) {
259
+ if (plan.files.length) lines.push('');
260
+ lines.push('Untracked (generated, now git-ignored; still on disk and rebuilt by hydrate):');
261
+ for (const p of plan.untrack) lines.push(`- ${p}`);
262
+ }
263
+ return { subject, body: lines.join('\n') };
264
+ }
265
+
266
+ // ---- command ---------------------------------------------------------------------
267
+
268
+ async function cmdUpdate({ kitRoot, projectRoot: rawRoot, opts = {} } = {}) {
269
+ const o = readOpts(opts);
270
+ const log = o.json ? () => {} : (opts.log || console.log);
271
+ const errlog = opts.errlog || console.error;
272
+ const emit = (r) => { if (o.json) (opts.write || console.log)(JSON.stringify(r)); return r; };
273
+
274
+ let projectRoot;
275
+ try { projectRoot = fs.realpathSync(rawRoot); } catch { projectRoot = path.resolve(rawRoot); }
276
+
277
+ const to = loadInstalledKitVersion(kitRoot);
278
+ let from = null;
279
+ let mode = null;
280
+ let base = null;
281
+ let branch = null;
282
+ const result = (extra) => ({
283
+ changed: false, from, to, mode, base, branch, branch_exists: false, pr_url: null, adapters: [], converged: false, exitCode: 0, ...extra
284
+ });
285
+ const fail = (message, extra = {}) => {
286
+ errlog(`✖ ${message}`);
287
+ return emit(result({ exitCode: 1, error: message, ...extra }));
288
+ };
289
+
290
+ if (!to) return fail(`could not read the kit version from ${path.join(kitRoot, 'package.json')}`);
125
291
  const cfg = loadProjectConfig(projectRoot);
126
- if (!cfg.project) {
127
- log('No .wize/config/project.toml found here.');
128
- log('Run `wize-dev-kit install` first.');
129
- return { changed: false };
292
+ if (!cfg.project) return fail('no .wize/config/project.toml here; run install first (npx wize-dev-kit install)');
293
+ if (o.pr && o.inPlace) return fail('--pr and --in-place cannot be combined');
294
+
295
+ const ctx = gitContext(projectRoot);
296
+ // An unborn HEAD (git init, nothing committed) has no base to branch from;
297
+ // there is no history to protect, so update edits the checkout directly.
298
+ const unborn = ctx.isRepo && !!ctx.branch && !ctx.head;
299
+ mode = !ctx.isRepo || o.inPlace || (unborn && !o.pr) ? 'in-place' : 'isolated';
300
+ if (o.pr && !ctx.isRepo) return fail('--pr needs a git repository');
301
+ const planOpts = { refreshGitignore: o.refreshGitignore };
302
+ const readOnly = o.check || o.dryRun;
303
+
304
+ if (mode === 'in-place') {
305
+ const plan = planTrackedChanges(fsSource(projectRoot), to, planOpts);
306
+ if (!plan.ok) return fail(planError(plan, null));
307
+ from = plan.from;
308
+ const pinAhead = compareVersions(to, from) < 0 && !o.allowDowngrade;
309
+ const changelog = changelogBetween(kitRoot, from, to);
310
+ if (readOnly) return reportCheck({ o, plan, mode, base: null, branch: null, branchExists: false, pinAhead, changelog, kitRoot, log, emit, result });
311
+ if (pinAhead) return fail(downgradeMessage(from, to));
312
+ return applyInPlace({ kitRoot, projectRoot, ctx, cfg, plan, o, to, from, changelog, log, fail, emit, result });
313
+ }
314
+
315
+ // isolated
316
+ const resolved = resolveBase(projectRoot, o.base, { fetch: !readOnly });
317
+ if (resolved.error) return fail(resolved.error);
318
+ base = resolved.base;
319
+ const def = resolved.def;
320
+ if (resolved.warning) log(`warning: ${resolved.warning}`);
321
+
322
+ const basePlan = planTrackedChanges(refSource(projectRoot, base), to, planOpts);
323
+ if (!basePlan.ok) return fail(planError(basePlan, base));
324
+ from = basePlan.from;
325
+ branch = `chore/wize-kit-${to}`;
326
+ const branchExists = localBranchExists(projectRoot, branch);
327
+ const pinAhead = compareVersions(to, from) < 0 && !o.allowDowngrade;
328
+ const changelog = changelogBetween(kitRoot, from, to);
329
+
330
+ if (readOnly) {
331
+ return reportCheck({ o, plan: basePlan, mode, base, branch, branchExists, pinAhead, changelog, kitRoot, log, emit, result });
332
+ }
333
+ if (pinAhead) return fail(downgradeMessage(from, to));
334
+ const extra = { branch_exists: branchExists };
335
+
336
+ log(`Wize Dev Kit update: ${from} → ${to} (isolated, base ${base})`);
337
+ const localPin = cfg.project.kit_version;
338
+ if (localPin && localPin !== from) {
339
+ if (compareVersions(from, localPin) > 0) log(`note: this checkout pins ${localPin}, ${base} pins ${from}; merge ${base} to pick that up`);
340
+ else log(`note: this checkout pins ${localPin}, ${base} pins ${from}; ${base} is behind this checkout, and the update branch builds on ${base}`);
341
+ }
342
+ if (basePlan.converged) {
343
+ log(`already converged at ${to}`);
344
+ return emit(result({ ...extra, converged: true }));
345
+ }
346
+
347
+ let plan = basePlan;
348
+ let startRef = base;
349
+ let prepared = false;
350
+ let preparedAt = null;
351
+ if (branchExists) {
352
+ const branchPlan = planTrackedChanges(refSource(projectRoot, branch), to, planOpts);
353
+ if (!branchPlan.ok) return fail(`${branch} has no usable project.toml; rename or delete that branch`, extra);
354
+ const wt = (worktreeList(projectRoot) || []).find(w => w.branch === branch);
355
+ if (branchPlan.converged) {
356
+ prepared = true;
357
+ preparedAt = wt ? wt.path : null;
358
+ } else if (wt) {
359
+ return fail(`${branch} is checked out at ${wt.path}; finish it there`, extra);
360
+ } else {
361
+ plan = branchPlan;
362
+ startRef = branch;
363
+ }
364
+ }
365
+
366
+ // A fresh CI checkout, or a second developer, has no local branch while the
367
+ // remote one already carries this update (an open PR, or a closed one whose
368
+ // branch was kept). Building another commit would only be rejected at push.
369
+ const remoteConverged = () => {
370
+ if (!hasRemote(projectRoot) || !ops.fetch(projectRoot, 'origin', branch).ok) return false;
371
+ const remotePlan = planTrackedChanges(refSource(projectRoot, 'FETCH_HEAD'), to, planOpts);
372
+ return remotePlan.ok && remotePlan.converged;
373
+ };
374
+ let remoteOnly = false;
375
+ if (o.pr && !prepared && !branchExists && remoteConverged()) {
376
+ prepared = true;
377
+ remoteOnly = true;
378
+ }
379
+
380
+ let commit = null;
381
+ if (remoteOnly) {
382
+ log(`already prepared on origin/${branch}`);
383
+ } else if (prepared) {
384
+ log(`already prepared on ${branch}${preparedAt ? ` (${preparedAt})` : ''}`);
385
+ } else {
386
+ const made = withTempWorktree(projectRoot, ctx.toplevel, startRef, (treeRoot, tmp) => {
387
+ const wrote = writePlan(treeRoot, plan);
388
+ if (wrote.error) return { error: wrote.error };
389
+ const staged = plan.files.map(f => f.path);
390
+ if (staged.length) {
391
+ const a = ops.add(treeRoot, staged);
392
+ if (!a.ok) return { error: `git add failed: ${a.err}` };
393
+ }
394
+ if (plan.untrack.length) {
395
+ const r = ops.rmCached(treeRoot, plan.untrack);
396
+ if (!r.ok) return { error: `git rm --cached failed: ${r.err}` };
397
+ }
398
+ const parent = ops.headSha(tmp);
399
+ const { subject, body } = commitMessage(from, to, plan);
400
+ let c;
401
+ try { c = ops.commit(treeRoot, subject, body); }
402
+ catch (err) { c = { ok: false, err: err.message }; }
403
+ if (!c.ok) return { error: `${c.err}\nthe commit hook rejected the update; nothing was created. Fix the hook, or run \`update --in-place\` on a feature branch yourself.` };
404
+ const sha = ops.headSha(tmp);
405
+ // A project hook may have staged more than the plan (git add -u / -A).
406
+ // The branch only ever carries the planned files.
407
+ const prefix = path.relative(tmp, treeRoot).split(path.sep).filter(Boolean).join('/');
408
+ const allowed = new Set([...plan.files.map(f => f.path), ...plan.untrack].map(p => (prefix ? `${prefix}/${p}` : p)));
409
+ const changed = ops.diffNames(tmp, parent, sha);
410
+ if (!changed) return { error: 'could not verify the update commit; nothing was created' };
411
+ const stray = changed.filter(p => !allowed.has(p));
412
+ if (stray.length) {
413
+ const shown = stray.slice(0, 5).join(', ') + (stray.length > 5 ? `, and ${stray.length - 5} more` : '');
414
+ return { error: `a commit hook changed files outside the update (${shown}); nothing was created. Fix the hook, or run \`update --in-place\` on a feature branch yourself.` };
415
+ }
416
+ const ref = branchExists ? ops.updateRef(projectRoot, branch, sha, parent) : ops.branchCreate(projectRoot, branch, sha);
417
+ if (!ref.ok) return { error: `could not move ${branch} to the new commit: ${ref.err}` };
418
+ return { sha };
419
+ });
420
+ if (made.error) return fail(made.error, extra);
421
+ commit = made.sha;
422
+ log(`✓ prepared ${branch} (${commit.slice(0, 7)}): ${plan.files.length} file(s) updated, ${plan.untrack.length} untracked`);
423
+ for (const f of plan.files) log(` ${f.path}`);
424
+ for (const p of plan.untrack) log(` untracked: ${p}`);
425
+ log(` review: git diff ${base}...${branch}`);
426
+ if (!o.pr) log(' open a PR: npx wize-dev-kit update --pr');
427
+ }
428
+
429
+ let prUrl = null;
430
+ if (o.pr) {
431
+ const pr = openPr({ projectRoot, branch, def, from, to, entries: changelog, log, skipPush: remoteOnly, remoteConverged });
432
+ if (pr.error) return fail(pr.error, { ...extra, branch_exists: true, commit });
433
+ prUrl = pr.url;
434
+ }
435
+
436
+ if (!prepared || o.pr) {
437
+ printChangelog(log, kitRoot, changelog);
438
+ log('\nRestart your harness after the change reaches this checkout.');
439
+ }
440
+ return emit(result({ ...extra, branch_exists: true, changed: !prepared, commit, pr_url: prUrl }));
441
+ }
442
+
443
+ function planError(plan, base) {
444
+ if (plan.error === 'toml-write') return 'could not write kit_version in project.toml';
445
+ return base
446
+ ? `.wize/config/project.toml not found on ${base}; merge the kit install first or pass --base <ref>`
447
+ : 'no .wize/config/project.toml here; run install first (npx wize-dev-kit install)';
448
+ }
449
+
450
+ function downgradeMessage(from, to) {
451
+ return `project pinned to ${from}, this kit is ${to}; run: npx wize-dev-kit@latest update, or pass --allow-downgrade if that pin is not published`;
452
+ }
453
+
454
+ // ---- --check / --dry-run ----------------------------------------------------------
455
+
456
+ function reportCheck({ o, plan, mode, base, branch, branchExists, pinAhead, changelog, kitRoot, log, emit, result }) {
457
+ const report = {
458
+ from: plan.from,
459
+ to: plan.to,
460
+ mode,
461
+ base,
462
+ branch,
463
+ branch_exists: branchExists,
464
+ files: plan.files.map(f => f.path),
465
+ untrack: plan.untrack,
466
+ changelog: changelog.map(excerpt),
467
+ converged: plan.converged
468
+ };
469
+ const exitCode = o.dryRun ? 0 : pinAhead ? 12 : plan.converged ? 0 : 11;
470
+ if (o.json) {
471
+ emit(report);
472
+ return result({ ...report, exitCode, converged: plan.converged });
130
473
  }
131
- const fromVersion = cfg.project.kit_version || '0.0.0';
132
- const toVersion = loadInstalledKitVersion(kitRoot) || 'unknown';
133
- if (toVersion === 'unknown') {
134
- log('Could not read kit version from node_modules. Aborting.');
135
- return { changed: false };
474
+ log(`Wize Dev Kit update --${o.dryRun ? 'dry-run' : 'check'}: ${plan.from} → ${plan.to} (${mode}${base ? `, base ${base}` : ''})`);
475
+ if (pinAhead) {
476
+ log(downgradeMessage(plan.from, plan.to));
477
+ } else if (plan.converged) {
478
+ log(`already converged at ${plan.to}`);
479
+ } else {
480
+ log('pending changes:');
481
+ for (const f of plan.files) log(` ${f.path}`);
482
+ for (const p of plan.untrack) log(` untrack: ${p}`);
483
+ if (branch) log(` branch: ${branch}${branchExists ? ' (exists)' : ''}`);
136
484
  }
485
+ if (!plan.converged && !pinAhead) printChangelog(log, kitRoot, changelog);
486
+ return result({ ...report, exitCode, converged: plan.converged });
487
+ }
488
+
489
+ // ---- in-place -------------------------------------------------------------------
490
+
491
+ function applyInPlace({ kitRoot, projectRoot, ctx, cfg, plan, o, to, from, changelog, log, fail, emit, result }) {
492
+ const gitFailed = (what, bypass = false) => fail(`git could not report the status of ${what} (git failed, timed out or reports a dubious ownership); cannot verify that nothing uncommitted would be overwritten. Fix git (for example safe.directory) and retry${bypass ? ', or rerun with --no-render' : ''}`);
493
+
494
+ // Unborn HEAD: nothing is tracked yet, so there is no committed state to
495
+ // protect and every file shows as untracked; edit like a non-git project.
496
+ const guarded = ctx.isRepo && !(ctx.branch && !ctx.head);
137
497
 
138
- log(`Wize Dev Kit update: ${fromVersion} → ${toVersion}`);
139
- if (fromVersion === toVersion) {
140
- log('Already on the same version. Re-rendering adapters anyway (idempotent).');
498
+ if (guarded && plan.files.length) {
499
+ const dirty = statusPorcelain(projectRoot, plan.files.map(f => f.path));
500
+ if (dirty === null) return gitFailed('the files update must edit');
501
+ if (dirty.length) {
502
+ return fail(`uncommitted changes in files update must edit: ${dirty.map(l => l.slice(3)).join(', ')}; commit or stash them, or rerun without --in-place`);
503
+ }
141
504
  }
142
505
 
143
- const ideTargets = (cfg.install && cfg.install.ide_targets) || ['claude-code', 'generic'];
144
- const profiles = (cfg.install && cfg.install.profiles) || ['core'];
506
+ if (guarded && !o.noRender) {
507
+ const targets = ((cfg.install && cfg.install.ide_targets) || ['claude-code', 'generic']).map(t => (typeof t === 'string' ? t : t && t.code)).filter(Boolean);
508
+ const specs = [...new Set(targets.flatMap(outputSpecs))];
509
+ const dirtyOut = specs.length ? statusPorcelain(projectRoot, specs) : [];
510
+ if (dirtyOut === null) return gitFailed('the generated files', true);
511
+ const tracked = dirtyOut.filter(l => !l.startsWith('??') && !l.startsWith('!!'));
512
+ if (tracked.length) {
513
+ return fail(`uncommitted changes in tracked generated files the re-render would overwrite: ${tracked.map(l => l.slice(3)).join(', ')}; commit or stash them, or rerun with --no-render`);
514
+ }
515
+ // Git pathspecs stop at a symlinked output directory, so also ask the
516
+ // renderer, in probe mode, which absolute landing paths it would write and
517
+ // veto the ones that are tracked with uncommitted changes.
518
+ const dirtyAbs = dirtyTrackedSet(projectRoot);
519
+ if (dirtyAbs === null) return gitFailed('the tracked files', true);
520
+ if (dirtyAbs.size) {
521
+ const probe = renderTargets({
522
+ kitRoot, projectRoot, targets: (cfg.install && cfg.install.ide_targets) || ['claude-code', 'generic'],
523
+ profiles: (cfg.install && cfg.install.profiles) || ['core'],
524
+ guard: (abs) => dirtyAbs.has(abs), probe: true
525
+ });
526
+ const vetoed = [...new Set(probe.adapters.flatMap(a => a.skippedTracked))].map(abs => path.relative(projectRoot, abs));
527
+ if (vetoed.length) {
528
+ const shown = vetoed.slice(0, 5).join(', ') + (vetoed.length > 5 ? `, and ${vetoed.length - 5} more` : '');
529
+ return fail(`uncommitted changes in tracked generated files the re-render would overwrite (through a symlinked directory or not): ${shown}; commit or stash them, or rerun with --no-render`);
530
+ }
531
+ }
532
+ }
145
533
 
146
- log('\nRe-rendering active IDE adapters:');
147
- const adapterResults = rerunAdapters({ kitRoot, projectRoot, ideTargets, profiles, log });
534
+ log(`Wize Dev Kit update: ${from} → ${to} (in-place)`);
535
+ if (plan.converged) log('Already converged. Re-rendering adapters anyway (idempotent).');
148
536
 
149
- if (opts.refreshGitignore !== false) {
150
- const r = applyGitignore(projectRoot);
151
- if (r.changed) log(`\n✓ .gitignore ${r.mode}`);
152
- else log('\n= .gitignore already up to date');
537
+ const wrote = writePlan(projectRoot, plan);
538
+ if (wrote.error) return fail(wrote.error);
539
+ const pinned = readToml(path.join(projectRoot, PROJECT_TOML));
540
+ if (!pinned.project || pinned.project.kit_version !== to) return fail('could not write kit_version in project.toml');
541
+ for (const f of plan.files) {
542
+ if (f.mode === 'pin') log(`✓ project.toml kit_version → ${to}`);
543
+ else if (f.path === '.gitignore') log(`✓ .gitignore ${f.mode}`);
544
+ else log(`✓ ${f.path} ${f.mode} refreshed`);
153
545
  }
154
546
 
155
- writeUpdatedProjectToml(projectRoot, cfg, toVersion);
156
- log(`\n✓ project.toml kit_version → ${toVersion}`);
547
+ if (plan.untrack.length) {
548
+ const r = ops.rmCached(projectRoot, plan.untrack);
549
+ if (r.ok) log(`✓ untracked ${plan.untrack.length} generated alias file(s) (kept on disk)`);
550
+ else return fail(`git rm --cached failed: ${r.err}`);
551
+ }
157
552
 
158
- const entries = changelogBetween(kitRoot, fromVersion, toVersion);
159
- if (entries.length) {
160
- log(`\nWhat changed (excerpt from CHANGELOG):`);
161
- for (const e of entries) {
162
- log(e.split('\n').slice(0, 4).map(l => ' ' + l).join('\n'));
163
- log(' …');
553
+ let adapters = [];
554
+ let renderOk = true;
555
+ if (!o.noRender) {
556
+ const targets = (cfg.install && cfg.install.ide_targets) || ['claude-code', 'generic'];
557
+ const profiles = (cfg.install && cfg.install.profiles) || ['core'];
558
+ log('\nRe-rendering active IDE adapters:');
559
+ const rendered = renderTargets({ kitRoot, projectRoot, targets, profiles, log });
560
+ adapters = rendered.adapters;
561
+ renderOk = rendered.ok;
562
+ if (renderOk) {
563
+ writeMarker(projectRoot, {
564
+ kit_version: to,
565
+ pin: to,
566
+ targets,
567
+ profiles,
568
+ counts: {
569
+ written: adapters.reduce((n, a) => n + a.written, 0),
570
+ unchanged: adapters.reduce((n, a) => n + a.unchanged, 0)
571
+ },
572
+ delegated: false,
573
+ rendered_with: to
574
+ });
164
575
  }
165
- log(`\nFull details: ${path.join(kitRoot, 'CHANGELOG.md')}`);
166
576
  }
167
577
 
168
- log('\nDone. Restart your IDE so the refreshed slash commands are picked up.');
169
- return { changed: true, from: fromVersion, to: toVersion, adapters: adapterResults };
578
+ if (ctx.isRepo) {
579
+ const def = defaultBranch(projectRoot);
580
+ if (ctx.detached || ctx.branch === def) {
581
+ log(`\ndo not commit this on ${ctx.detached ? 'a detached HEAD' : def}: create a branch first (or rerun without --in-place)`);
582
+ } else if (ctx.linked) {
583
+ log('\nwarning: pin bump on a feature branch conflicts at merge; prefer the default (isolated) mode');
584
+ }
585
+ }
586
+
587
+ printChangelog(log, kitRoot, changelog);
588
+ log('\nRestart your harness after the change reaches this checkout.');
589
+ return emit(result({ changed: true, adapters, converged: plan.converged, exitCode: renderOk ? 0 : 1 }));
590
+ }
591
+
592
+ // ---- --pr -------------------------------------------------------------------------
593
+
594
+ // Push (never forced) and open or find the PR. { url } or { error }. With
595
+ // `skipPush` the remote branch already carries the update. A push the remote
596
+ // rejects is fine too when the remote branch carries the same update, which
597
+ // happens when two checkouts built the same commit content separately.
598
+ function openPr({ projectRoot, branch, def, from, to, entries, log, skipPush = false, remoteConverged = () => false }) {
599
+ if (!hasRemote(projectRoot)) return { error: `no origin remote; branch ${branch} kept locally` };
600
+ if (!skipPush) {
601
+ const pushed = ops.push(projectRoot, 'origin', branch);
602
+ if (!pushed.ok) {
603
+ if (!/rejected|non-fast-forward|fetch first/i.test(pushed.err)) return { error: `git push failed: ${pushed.err}` };
604
+ if (!remoteConverged()) return { error: `remote ${branch} diverged; resolve manually` };
605
+ log(`origin/${branch} already carries this update`);
606
+ } else {
607
+ log(`✓ pushed ${branch} to origin`);
608
+ }
609
+ }
610
+
611
+ const title = updateSubject(from, to);
612
+ if (ops.ghAvailable()) {
613
+ const existing = ops.ghPrView(projectRoot, branch);
614
+ if (existing && String(existing.state).toUpperCase() === 'OPEN') {
615
+ log(`PR already open: ${existing.url}`);
616
+ return { url: existing.url };
617
+ }
618
+ const body = [
619
+ { converge: `Converges the wize-dev-kit pin at ${to}.`, downgrade: `Downgrades the wize-dev-kit pin from ${from} to ${to}.`, bump: `Bumps the wize-dev-kit pin from ${from} to ${to}.` }[updateVerb(from, to)],
620
+ ...(entries.length ? ['', entries.map(excerpt).join('\n\n')] : []),
621
+ '',
622
+ 'After merge every worktree self-hydrates on its next session (`hydrate`).'
623
+ ].join('\n');
624
+ const created = ops.ghPrCreate(projectRoot, { base: def, head: branch, title, body });
625
+ if (created.ok) {
626
+ log(`✓ PR opened: ${created.url}`);
627
+ return { url: created.url };
628
+ }
629
+ log(`warning: gh pr create failed: ${created.err.split('\n')[0]}`);
630
+ }
631
+ const web = ops.originWebUrl(projectRoot);
632
+ if (web) {
633
+ const url = `${web}/compare/${def}...${branch}?expand=1`;
634
+ log(`Open the PR: ${url}`);
635
+ return { url };
636
+ }
637
+ log(`open a PR for ${branch}`);
638
+ return { url: null };
170
639
  }
171
640
 
172
- module.exports = { cmdUpdate, loadProjectConfig, loadInstalledKitVersion, changelogBetween };
641
+ module.exports = {
642
+ cmdUpdate,
643
+ planTrackedChanges,
644
+ readToml,
645
+ loadProjectConfig,
646
+ loadInstalledKitVersion,
647
+ changelogBetween
648
+ };