@hozu/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/commands/add-widget.d.ts.map +1 -1
  2. package/dist/commands/add-widget.js +5 -4
  3. package/dist/commands/add-widget.js.map +1 -1
  4. package/dist/commands/app.d.ts.map +1 -1
  5. package/dist/commands/app.js +10 -0
  6. package/dist/commands/app.js.map +1 -1
  7. package/dist/commands/build.d.ts.map +1 -1
  8. package/dist/commands/build.js +5 -1
  9. package/dist/commands/build.js.map +1 -1
  10. package/dist/commands/map.d.ts.map +1 -1
  11. package/dist/commands/map.js +7 -2
  12. package/dist/commands/map.js.map +1 -1
  13. package/dist/commands/migrate.d.ts +17 -16
  14. package/dist/commands/migrate.d.ts.map +1 -1
  15. package/dist/commands/migrate.js +202 -297
  16. package/dist/commands/migrate.js.map +1 -1
  17. package/dist/commands/scaffold.d.ts.map +1 -1
  18. package/dist/commands/scaffold.js +8 -4
  19. package/dist/commands/scaffold.js.map +1 -1
  20. package/dist/contract.d.ts +49 -0
  21. package/dist/contract.d.ts.map +1 -1
  22. package/dist/main.d.ts.map +1 -1
  23. package/dist/main.js +9 -2
  24. package/dist/main.js.map +1 -1
  25. package/dist/migrate/ast.d.ts +31 -0
  26. package/dist/migrate/ast.d.ts.map +1 -0
  27. package/dist/migrate/ast.js +61 -0
  28. package/dist/migrate/ast.js.map +1 -0
  29. package/dist/migrate/record-ir.d.ts +2 -0
  30. package/dist/migrate/record-ir.d.ts.map +1 -0
  31. package/dist/migrate/record-ir.js +16 -0
  32. package/dist/migrate/record-ir.js.map +1 -0
  33. package/dist/migrate/step-0.11.d.ts +15 -0
  34. package/dist/migrate/step-0.11.d.ts.map +1 -0
  35. package/dist/migrate/step-0.11.js +88 -0
  36. package/dist/migrate/step-0.11.js.map +1 -0
  37. package/dist/migrate/steps.d.ts +23 -0
  38. package/dist/migrate/steps.d.ts.map +1 -0
  39. package/dist/migrate/steps.js +33 -0
  40. package/dist/migrate/steps.js.map +1 -0
  41. package/dist/output.d.ts.map +1 -1
  42. package/dist/output.js +2 -0
  43. package/dist/output.js.map +1 -1
  44. package/package.json +8 -8
  45. package/schema/check.schema.json +3 -1
  46. package/schema/inspect.schema.json +41 -3
  47. package/schema/map.schema.json +17 -0
  48. package/schema/migrate.schema.json +718 -0
  49. package/schema/plan.schema.json +4 -2
  50. package/schema/render.schema.json +3 -1
  51. package/schema/validate.schema.json +3 -1
  52. package/dist/commands/migrate-agent.d.ts +0 -12
  53. package/dist/commands/migrate-agent.d.ts.map +0 -1
  54. package/dist/commands/migrate-agent.js +0 -6
  55. package/dist/commands/migrate-agent.js.map +0 -1
  56. package/dist/commands/migrate-app.d.ts +0 -15
  57. package/dist/commands/migrate-app.d.ts.map +0 -1
  58. package/dist/commands/migrate-app.js +0 -334
  59. package/dist/commands/migrate-app.js.map +0 -1
  60. package/dist/commands/migrate-ast.d.ts +0 -63
  61. package/dist/commands/migrate-ast.d.ts.map +0 -1
  62. package/dist/commands/migrate-ast.js +0 -257
  63. package/dist/commands/migrate-ast.js.map +0 -1
  64. package/dist/commands/migrate-forms.d.ts +0 -4
  65. package/dist/commands/migrate-forms.d.ts.map +0 -1
  66. package/dist/commands/migrate-forms.js +0 -120
  67. package/dist/commands/migrate-forms.js.map +0 -1
  68. package/dist/commands/migrate-freshness.d.ts +0 -4
  69. package/dist/commands/migrate-freshness.d.ts.map +0 -1
  70. package/dist/commands/migrate-freshness.js +0 -47
  71. package/dist/commands/migrate-freshness.js.map +0 -1
  72. package/dist/commands/migrate-head.d.ts +0 -8
  73. package/dist/commands/migrate-head.d.ts.map +0 -1
  74. package/dist/commands/migrate-head.js +0 -132
  75. package/dist/commands/migrate-head.js.map +0 -1
  76. package/dist/commands/migrate-links.d.ts +0 -5
  77. package/dist/commands/migrate-links.d.ts.map +0 -1
  78. package/dist/commands/migrate-links.js +0 -55
  79. package/dist/commands/migrate-links.js.map +0 -1
  80. package/dist/commands/migrate-lists.d.ts +0 -9
  81. package/dist/commands/migrate-lists.d.ts.map +0 -1
  82. package/dist/commands/migrate-lists.js +0 -225
  83. package/dist/commands/migrate-lists.js.map +0 -1
  84. package/dist/commands/migrate-normalize.d.ts +0 -27
  85. package/dist/commands/migrate-normalize.d.ts.map +0 -1
  86. package/dist/commands/migrate-normalize.js +0 -252
  87. package/dist/commands/migrate-normalize.js.map +0 -1
  88. package/dist/commands/migrate-ops.d.ts +0 -4
  89. package/dist/commands/migrate-ops.d.ts.map +0 -1
  90. package/dist/commands/migrate-ops.js +0 -192
  91. package/dist/commands/migrate-ops.js.map +0 -1
  92. package/dist/commands/migrate-parts.d.ts +0 -7
  93. package/dist/commands/migrate-parts.d.ts.map +0 -1
  94. package/dist/commands/migrate-parts.js +0 -166
  95. package/dist/commands/migrate-parts.js.map +0 -1
  96. package/dist/commands/migrate-stale.d.ts +0 -24
  97. package/dist/commands/migrate-stale.d.ts.map +0 -1
  98. package/dist/commands/migrate-stale.js +0 -114
  99. package/dist/commands/migrate-stale.js.map +0 -1
@@ -1,26 +1,32 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import { dirname, extname, join, relative, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { runnerOf, writeAgentFiles } from 'create-hozu';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { HozuCliError } from '../errors.js';
7
7
  import { load } from '../load.js';
8
+ import { chain, compareMinor, minorOf, OLDEST } from '../migrate/steps.js';
8
9
  import { runCheck } from './check.js';
9
- import { migrateGuide, skillTargets } from './migrate-agent.js';
10
- import { migrateApp, migratePackage } from './migrate-app.js';
11
- import { forget, lineOf, parse, walk } from './migrate-ast.js';
12
- import { migrateForms } from './migrate-forms.js';
13
- import { migrateFreshness } from './migrate-freshness.js';
14
- import { migrateHead } from './migrate-head.js';
15
- import { migrateLinks } from './migrate-links.js';
16
- import { migrateLists } from './migrate-lists.js';
17
- import { differences, normalize07 } from './migrate-normalize.js';
18
- import { migrateOps } from './migrate-ops.js';
19
- import { migrateParts } from './migrate-parts.js';
10
+ import { runSkill } from './skill.js';
20
11
  const SKIP = /(^|\/)(node_modules|dist|\.[^/]+)(\/|$)/;
21
- const RECORD = '.hozu/migrate-0.7.json';
12
+ const cliVersion = () => {
13
+ const pkg = new URL('../../package.json', import.meta.url);
14
+ return JSON.parse(readFileSync(fileURLToPath(pkg), 'utf8')).version;
15
+ };
16
+ export function installedCore(config) {
17
+ let d = dirname(config);
18
+ for (;;) {
19
+ const pkg = join(d, 'node_modules/@hozu/core/package.json');
20
+ if (existsSync(pkg))
21
+ return JSON.parse(readFileSync(pkg, 'utf8')).version;
22
+ const up = dirname(d);
23
+ if (up === d)
24
+ return null;
25
+ d = up;
26
+ }
27
+ }
22
28
  export function sources(dir) {
23
- const out = new Map();
29
+ const out = [];
24
30
  const visit = (d) => {
25
31
  for (const name of readdirSync(d).sort()) {
26
32
  const path = join(d, name);
@@ -29,329 +35,228 @@ export function sources(dir) {
29
35
  if (statSync(path).isDirectory())
30
36
  visit(path);
31
37
  else if (extname(name) === '.ts' && !name.endsWith('.d.ts'))
32
- out.set(path, readFileSync(path, 'utf8'));
38
+ out.push(path);
33
39
  }
34
40
  };
35
41
  visit(dir);
36
42
  return out;
37
43
  }
38
- export function checkOn07(config) {
39
- const self = fileURLToPath(import.meta.url);
40
- const child = spawnSync(process.execPath, ['--no-warnings', join(dirname(self), `migrate-stale${extname(self)}`), config], { cwd: dirname(config), maxBuffer: 1 << 28, env: { ...process.env, NODE_OPTIONS: '' } });
44
+ /** The IR as the app's installed Hozu builds it, in a child process with the app's own transform hook. */
45
+ function recordWithInstalled(config) {
46
+ const require = createRequire(config);
47
+ let register;
41
48
  try {
42
- return JSON.parse(child.stdout.toString());
49
+ register = require.resolve('@hozu/transform/register');
43
50
  }
44
51
  catch {
45
- return {
46
- core: null,
47
- irVersion: null,
48
- skipped: `the stale check failed: ${child.stderr.toString().split('\n').find(Boolean) ?? 'no output'}`,
49
- stale: [],
50
- routes: {},
51
- errors: [],
52
- ir: null,
53
- };
52
+ throw new HozuCliError('config', 'hozu migrate needs the app’s installed @hozu/transform to read the old IR', ['npm install, then run hozu migrate again']);
54
53
  }
55
- }
56
- const PRINTS = [
57
- [
58
- 'any',
59
- /AsyncLocalStorage/,
60
- 'request state in AsyncLocalStorage (hand-made i18n?): the URL holds the language (site.locales)',
61
- 'i18n',
62
- ],
63
- [
64
- 'any',
65
- /accept-language/i,
66
- 'hand-made language negotiation: the URL holds the language (site.locales), no Accept-Language redirect',
67
- 'i18n',
68
- ],
69
- [
70
- 'any',
71
- /Session\s*=\s*z\.object\(\{[^}]*\b(?:lang|locale|language)\s*:/,
72
- 'a language in the session: the URL holds the language (site.locales), not the session',
73
- 'i18n',
74
- ],
75
- [
76
- 'resolvers',
77
- /<html\b[^>]*\blang\b|text\/html|<!doctype/i,
78
- 'HTML from a resolver is HZ053: make it a ui.page with head.failed',
79
- 'pages',
80
- ],
81
- ];
82
- const REDIRECT = "a redirect Response: declare output: 'redirect' and return redirect(ui.link(…))";
83
- const statusOf = (n) => {
84
- if (n?.type === 'Literal' && typeof n.value === 'number')
85
- return n.value;
86
- if (n?.type !== 'ObjectExpression')
87
- return null;
88
- const p = n.properties.find((x) => x.type === 'Property' && (x.key.name ?? x.key.value) === 'status');
89
- return p?.value.type === 'Literal' && typeof p.value.value === 'number' ? p.value.value : null;
90
- };
91
- function responses(file, source) {
92
- let program;
54
+ const self = fileURLToPath(import.meta.url);
55
+ const script = join(dirname(self), `../migrate/record-ir${extname(self)}`);
56
+ const child = spawnSync(process.execPath, ['--no-warnings', '--import', pathToFileURL(register).href, script, config], { cwd: dirname(config), maxBuffer: 1 << 28, env: { ...process.env, NODE_OPTIONS: '' } });
93
57
  try {
94
- program = parse(source);
58
+ return JSON.parse(child.stdout.toString());
95
59
  }
96
60
  catch {
97
- return [];
61
+ throw new HozuCliError('build', `hozu migrate could not build the app with its installed Hozu: ${child.stderr.toString().split('\n').find(Boolean) ?? 'no output'}`, ['npx hozu check # fix the app on its current version first']);
98
62
  }
63
+ }
64
+ /** JSON pointers where two IRs differ, each with both values, at most 50. */
65
+ export function differences(before, after, at = '') {
99
66
  const out = [];
100
- walk(program, (n) => {
101
- const c = n.callee;
102
- const built = n.type === 'NewExpression' && c.type === 'Identifier' && c.name === 'Response';
103
- const helper = n.type === 'CallExpression' &&
104
- c.type === 'MemberExpression' &&
105
- c.object.type === 'Identifier' &&
106
- c.object.name === 'Response' &&
107
- ['json', 'redirect'].includes(c.property.name);
108
- if (!built && !helper)
67
+ const show = (v) => {
68
+ const s = v === undefined ? 'absent' : JSON.stringify(v);
69
+ return s.length > 80 ? `${s.slice(0, 77)}…` : s;
70
+ };
71
+ const visit = (a, b, path) => {
72
+ if (out.length >= 50)
109
73
  return;
110
- const status = statusOf(n.arguments[1]) ?? (helper && c.property.name === 'redirect' ? 302 : null);
111
- if (status === null || status < 300 || status >= 500)
74
+ if (JSON.stringify(a) === JSON.stringify(b))
112
75
  return;
113
- const message = status < 400
114
- ? REDIRECT
115
- : `a ${status} Response: declare errors: { Name: schema } and failed: { Name: ${status} }, and return fail('Name', …)`;
116
- out.push({
117
- file,
118
- line: lineOf(source, n.start),
119
- rule: 'print',
120
- message,
121
- see: 'endpoints',
122
- behaviour: true,
123
- });
124
- });
76
+ const objects = a && b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b);
77
+ if (!objects) {
78
+ out.push(`${path || '/'}: ${show(a)} → ${show(b)}`);
79
+ return;
80
+ }
81
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
82
+ for (const k of keys)
83
+ visit(a[k], b[k], `${path}/${k.replace(/~/g, '~0').replace(/\//g, '~1')}`);
84
+ };
85
+ visit(before, after, at);
125
86
  return out;
126
87
  }
127
- /** What migrate cannot rewrite and check cannot see (ADR 0043 Migration). */
128
- export function printed(file, source) {
88
+ function raisePackages(pkgPath, version, write) {
89
+ if (!existsSync(pkgPath))
90
+ return [];
91
+ const source = readFileSync(pkgPath, 'utf8');
92
+ const pkg = JSON.parse(source);
129
93
  const out = [];
130
- const resolvers = /\bimplement\s*\(/.test(source);
131
- for (const [where, re, message, see] of PRINTS) {
132
- if (where === 'resolvers' && !resolvers)
133
- continue;
134
- const m = re.exec(source);
135
- if (m)
136
- out.push({ file, line: lineOf(source, m.index), rule: 'print', message, see, behaviour: true });
137
- }
138
- return resolvers ? [...out, ...responses(file, source)] : out;
139
- }
140
- /** Every source rewrite of `hozu migrate 0.8`, in memory: removed files are absent from `files`. */
141
- export function rewriteSources(dir, config, before, routes) {
142
- const files = new Map(before);
143
- const notes = [];
144
- const passes = [
145
- migrateOps,
146
- migrateHead,
147
- migrateFreshness,
148
- (s, f) => migrateLinks(s, f, routes),
149
- migrateForms,
150
- migrateLists,
151
- ];
152
- for (const pass of passes)
153
- for (const [file, source] of files) {
154
- forget();
155
- const r = pass(source, file);
156
- files.set(file, r.code);
157
- notes.push(...r.notes);
94
+ for (const field of ['dependencies', 'devDependencies'])
95
+ for (const [name, range] of Object.entries(pkg[field] ?? {})) {
96
+ if (!name.startsWith('@hozu/') && name !== 'create-hozu')
97
+ continue;
98
+ if (/^(workspace|file|link):/.test(range))
99
+ continue;
100
+ const next = `^${version}`;
101
+ if (range === next)
102
+ continue;
103
+ pkg[field][name] = next;
104
+ out.push({ name, from: range, to: next });
158
105
  }
159
- for (let round = 0; round < 5; round++) {
160
- forget();
161
- const parts = migrateParts(files);
162
- for (const [f, s] of parts.files)
163
- files.set(f, s);
164
- notes.push(...parts.notes.filter((n) => !notes.some((m) => m.file === n.file && m.message === n.message)));
165
- if (!parts.files.size)
166
- break;
167
- }
168
- forget();
169
- const app = migrateApp(dir, config, files);
170
- for (const [f, s] of app.write)
171
- files.set(f, s);
172
- notes.push(...app.notes);
173
- for (const f of app.remove)
174
- files.delete(f);
175
- for (const [file, source] of files)
176
- notes.push(...printed(file, source));
177
- forget();
178
- return { files, notes, app };
106
+ if (out.length && write)
107
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
108
+ return out;
179
109
  }
180
- export async function runMigrate(cwd, configArg, version, options = {}) {
181
- if (version !== '0.8')
182
- throw new HozuCliError('usage', 'hozu migrate upgrades a 0.7 app: hozu migrate 0.8', ['hozu migrate 0.8']);
183
- const config = resolve(cwd, configArg ?? 'hozu.config.ts');
110
+ const installCommand = (dir) => existsSync(join(dir, 'pnpm-lock.yaml'))
111
+ ? 'pnpm install'
112
+ : existsSync(join(dir, 'yarn.lock'))
113
+ ? 'yarn install'
114
+ : 'npm install';
115
+ export async function runMigrate(cwd, options) {
116
+ const config = resolve(cwd, options.config ?? 'hozu.config.ts');
184
117
  if (!existsSync(config))
185
118
  throw new HozuCliError('config', `No config found at ${config}`, [
186
- 'Run hozu migrate 0.8 in the app directory',
119
+ 'run hozu migrate in the app directory',
187
120
  ]);
188
121
  const dir = dirname(config);
189
- const rel = (f) => relative(cwd, f) || '.';
190
- const on07 = checkOn07(config);
191
- const record = join(dir, RECORD);
192
- if (on07.ir) {
193
- mkdirSync(dirname(record), { recursive: true });
194
- writeFileSync(record, JSON.stringify(on07.ir));
195
- }
196
- const before = sources(dir);
197
- const { files, notes, app } = rewriteSources(dir, config, before, new Map(Object.entries(on07.routes)));
198
- const changed = [];
199
- const write = (file, code) => {
200
- if (file.endsWith('hozu.lock.json'))
201
- return;
202
- writeFileSync(file, code);
203
- changed.push(rel(file));
122
+ const rel = (p) => relative(cwd, p) || '.';
123
+ const installed = options.versions?.installed ?? installedCore(config);
124
+ if (!installed)
125
+ throw new HozuCliError('config', 'No installed @hozu/core: install the app’s dependencies first', [
126
+ installCommand(dir),
127
+ ]);
128
+ const target = options.versions?.target ?? cliVersion();
129
+ const from = minorOf(installed);
130
+ const to = minorOf(target);
131
+ if (compareMinor(from, OLDEST) < 0)
132
+ throw new HozuCliError('usage', `hozu migrate starts at ${OLDEST}.0; this app is on ${installed}. Upgrade it to ${OLDEST} by hand with the CHANGELOG first`, ['https://github.com/olevatorr/Hozu/blob/main/CHANGELOG.md']);
133
+ if (compareMinor(from, to) > 0)
134
+ throw new HozuCliError('usage', `The app is on ${installed}, newer than this CLI (${target})`, [
135
+ `npx @hozu/cli@${installed} …`,
136
+ ]);
137
+ const record = join(dir, `.hozu/migrate-${from}.json`);
138
+ const out = {
139
+ ok: true,
140
+ from: installed,
141
+ to: target,
142
+ phase: 'current',
143
+ dryRun: options.dryRun,
144
+ steps: [],
145
+ changed: [],
146
+ notes: [],
147
+ packages: [],
148
+ record: null,
149
+ ir: { compared: false, skipped: null, differences: [] },
150
+ guide: [],
151
+ check: null,
152
+ next: [],
204
153
  };
205
- for (const [file, code] of files)
206
- if (before.get(file) !== code)
207
- write(file, code);
208
- for (const file of app.remove)
209
- rmSync(file, { force: true });
210
- const pkgPath = join(dir, 'package.json');
211
- if (existsSync(pkgPath)) {
212
- const source = readFileSync(pkgPath, 'utf8');
213
- const pkg = migratePackage(source, app.entry);
214
- if (pkg.code !== source)
215
- write(pkgPath, pkg.code);
216
- notes.push(...pkg.notes.map((n) => ({ ...n, file: pkgPath })));
217
- }
218
- const guide = [];
219
- const targets = skillTargets(dir);
220
- if (targets.length) {
221
- const runner = runnerOf(process.env.npm_config_user_agent);
222
- const agent = targets.length === 2 ? 'both' : targets[0].guide === 'CLAUDE.md' ? 'claude' : 'agents';
223
- const existing = new Map(targets.map((t) => [
224
- t.guide,
225
- existsSync(join(dir, t.guide)) ? readFileSync(join(dir, t.guide), 'utf8') : null,
226
- ]));
227
- const skills = new Map(targets.map((t) => [t.skill, contentOf(join(dir, t.skill))]));
228
- await writeAgentFiles(dir, agent, { name: dir.split('/').pop(), runner });
229
- for (const t of targets) {
230
- if (contentOf(join(dir, t.skill)) !== skills.get(t.skill))
231
- changed.push(rel(join(dir, t.skill)));
232
- const file = join(dir, t.guide);
233
- const r = migrateGuide(existing.get(t.guide) ?? null, t, dir, runner);
234
- if (r.kind === 'custom') {
235
- guide.push({ file: rel(file), state: 'custom', block: r.block });
236
- }
237
- else {
238
- if (r.kind !== 'current')
239
- write(file, r.code);
240
- guide.push({ file: rel(file), state: r.kind, block: null });
154
+ if (compareMinor(from, to) < 0) {
155
+ const plan = chain(from, to);
156
+ if (!plan)
157
+ throw new HozuCliError('usage', `No migration path from ${from} to ${to}`, []);
158
+ out.phase = 'rewrite';
159
+ out.steps = plan.map((s) => ({ from: s.from, to: s.to, summary: s.summary }));
160
+ const ir = (options.recordIR ?? recordWithInstalled)(config);
161
+ for (const file of sources(dir)) {
162
+ let code = readFileSync(file, 'utf8');
163
+ const original = code;
164
+ let edits = 0;
165
+ for (const step of plan) {
166
+ const r = step.rewrite(rel(file), code);
167
+ code = r.code;
168
+ edits += r.count;
169
+ out.notes.push(...r.notes);
241
170
  }
171
+ if (code === original)
172
+ continue;
173
+ out.changed.push({ file: rel(file), edits });
174
+ if (!options.dryRun)
175
+ writeFileSync(file, code);
242
176
  }
243
- }
244
- const installed = on07.core ?? installedCore(config);
245
- const next = [];
246
- let check = null;
247
- const ir = { compared: false, skipped: null, differences: [] };
248
- if (on07.irVersion !== 2) {
249
- next.push(`upgrade every @hozu/* dependency to 0.8: ${upgradeCommand(dir)}`);
250
- next.push('npx hozu migrate 0.8 # again after the upgrade: nothing to rewrite, then it runs hozu check');
251
- if (on07.ir)
252
- next.push(`keep ${RECORD} until that second run: it holds the 0.7 IR it compares with (a reinstall keeps it; delete it afterwards)`);
253
- }
254
- else if (options.runCheck !== false) {
255
- const loaded = await load(configArg, cwd);
256
- if (existsSync(record)) {
257
- const before = normalize07(JSON.parse(readFileSync(record, 'utf8')));
258
- ir.compared = true;
259
- ir.differences = differences(before, loaded.build(false).ir);
260
- next.push(`delete ${RECORD}: the IR comparison with 0.7 is done`);
177
+ out.packages = raisePackages(join(dir, 'package.json'), target, !options.dryRun);
178
+ if (!options.dryRun) {
179
+ mkdirSync(dirname(record), { recursive: true });
180
+ writeFileSync(join(dir, `.hozu/migrate-${to}.json`), JSON.stringify({ from, to, ir }));
261
181
  }
262
- else
263
- ir.skipped = `${RECORD} is missing: it is written by the run on the 0.7 app, before the upgrade`;
264
- check = await runCheck(loaded, cwd, false);
265
- if (!check.ok)
266
- next.push('npx hozu check # fix each diagnostic; accept the lock with --update-lock');
182
+ out.record = rel(join(dir, `.hozu/migrate-${to}.json`));
183
+ out.next.push(`${installCommand(dir)} # installs Hozu ${target}`, 'npx hozu migrate # again: compares the IR with the old one and runs hozu check');
184
+ return out;
185
+ }
186
+ const saved = join(dir, `.hozu/migrate-${to}.json`);
187
+ if (!existsSync(saved)) {
188
+ out.next.push('Nothing to migrate: the app is on this CLI’s version.');
189
+ return out;
267
190
  }
268
- const ok = !guide.some((g) => g.state === 'custom') && (check?.ok ?? true);
269
- return {
270
- ok,
271
- installed,
272
- stale: { skipped: on07.skipped, entries: on07.stale },
273
- changed: [...new Set(changed)].sort(),
274
- removed: app.remove.map(rel).sort(),
275
- notes: notes.map((n) => ({ ...n, file: rel(n.file) })),
276
- guide,
277
- ir,
278
- next,
279
- check,
191
+ out.phase = 'verify';
192
+ out.record = rel(saved);
193
+ const { from: was, ir: before } = JSON.parse(readFileSync(saved, 'utf8'));
194
+ const plan = chain(was, to) ?? [];
195
+ out.steps = plan.map((s) => ({ from: s.from, to: s.to, summary: s.summary }));
196
+ const loaded = await load(options.config, cwd);
197
+ const normalized = plan.reduce((ir, step) => step.normalize(ir), before);
198
+ out.ir = {
199
+ compared: true,
200
+ skipped: null,
201
+ differences: differences(normalized, loaded.build(false).ir),
280
202
  };
281
- }
282
- function contentOf(dir) {
283
- const out = [];
284
- const visit = (d) => {
285
- for (const name of readdirSync(d).sort()) {
286
- const path = join(d, name);
287
- if (statSync(path).isDirectory())
288
- visit(path);
289
- else
290
- out.push(`${relative(dir, path)}\n${readFileSync(path, 'utf8')}`);
203
+ if (!options.dryRun) {
204
+ const agents = ['.claude/skills/hozu', '.agents/skills/hozu'].filter((p) => existsSync(join(dir, p)));
205
+ if (agents.length) {
206
+ const written = await runSkill(dir, undefined);
207
+ out.guide = written.written.map((f) => rel(resolve(dir, f)));
208
+ for (const c of written.custom)
209
+ out.notes.push({
210
+ file: rel(resolve(dir, c.guide)),
211
+ line: 1,
212
+ message: `not a Hozu template: replace its Hozu section with this block\n${c.block}`,
213
+ see: null,
214
+ });
291
215
  }
292
- };
293
- if (existsSync(dir))
294
- visit(dir);
295
- return out.join('\u0000');
296
- }
297
- function installedCore(config) {
298
- let d = dirname(config);
299
- for (;;) {
300
- const pkg = join(d, 'node_modules/@hozu/core/package.json');
301
- if (existsSync(pkg))
302
- return JSON.parse(readFileSync(pkg, 'utf8')).version;
303
- const up = dirname(d);
304
- if (up === d)
305
- return null;
306
- d = up;
307
216
  }
308
- }
309
- function upgradeCommand(dir) {
310
- const pkg = existsSync(join(dir, 'package.json'))
311
- ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
312
- : {};
313
- const names = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]
314
- .filter((n) => n.startsWith('@hozu/'))
315
- .sort();
316
- const tool = existsSync(join(dir, 'pnpm-lock.yaml'))
317
- ? 'pnpm add'
318
- : existsSync(join(dir, 'yarn.lock'))
319
- ? 'yarn add'
320
- : 'npm install';
321
- return `${tool} ${names.map((n) => `${n}@^0.8.0`).join(' ')}`;
217
+ if (options.check !== false) {
218
+ out.check = await runCheck(loaded, cwd, false);
219
+ if (!out.check.ok)
220
+ out.next.push('npx hozu check # fix each diagnostic; accept intended lock changes with --update-lock');
221
+ }
222
+ out.ok = out.ir.differences.length === 0 && (out.check?.ok ?? true);
223
+ if (out.ir.differences.length)
224
+ out.next.push('Review each IR difference: it is a behaviour change the migration did not declare');
225
+ if (out.ok && !options.dryRun) {
226
+ rmSync(saved);
227
+ out.next.push('Done. Commit the migration; hozu.lock.json was not touched.');
228
+ }
229
+ return out;
322
230
  }
323
231
  export function describeMigrate(r) {
324
232
  const lines = [];
325
- lines.push(r.stale.skipped
326
- ? `1. stale under 0.7: skipped (${r.stale.skipped})`
327
- : `1. stale under 0.7: ${r.stale.entries.length} lock ${r.stale.entries.length === 1 ? 'entry' : 'entries'}${r.stale.entries.length ? ' (review each before --update-lock):' : ''}`);
328
- for (const e of r.stale.entries)
329
- lines.push(` ${e.feature} ${e.id} [${e.kind}]${e.was ? `\n was: ${e.was}` : ''}${e.now ? `\n now: ${e.now}` : ''}`);
330
- lines.push(`2. rewrote ${r.changed.length} ${r.changed.length === 1 ? 'path' : 'paths'}${r.removed.length ? `, removed ${r.removed.join(', ')}` : ''}`);
331
- const behaviour = r.notes.filter((n) => n.behaviour);
332
- const rest = r.notes.filter((n) => !n.behaviour);
333
- for (const n of rest)
334
- lines.push(` ${n.file}:${n.line} ${n.message}${n.see ? ` (hozu docs ${n.see})` : ''}`);
335
- if (behaviour.length) {
336
- lines.push(' Behaviour changes and code to move by hand:');
337
- for (const n of behaviour)
338
- lines.push(` ! ${n.file}:${n.line} ${n.message}${n.see ? ` (hozu docs ${n.see})` : ''}`);
233
+ const title = r.phase === 'rewrite' ? 'rewrite' : r.phase === 'verify' ? 'verify' : 'nothing to do';
234
+ lines.push(`hozu migrate ${r.from} → ${r.to}: ${title}${r.dryRun ? ' (dry run, nothing written)' : ''}`);
235
+ for (const s of r.steps)
236
+ lines.push(` ${s.from} → ${s.to}: ${s.summary}`);
237
+ if (r.phase === 'rewrite') {
238
+ lines.push(` rewrote ${r.changed.length} ${r.changed.length === 1 ? 'file' : 'files'}`);
239
+ for (const c of r.changed)
240
+ lines.push(` ${c.file} (${c.edits} ${c.edits === 1 ? 'edit' : 'edits'})`);
241
+ for (const p of r.packages)
242
+ lines.push(` ${p.name}: ${p.from} → ${p.to}`);
243
+ if (r.record)
244
+ lines.push(` saved the old IR to ${r.record}`);
339
245
  }
340
- for (const g of r.guide)
341
- lines.push(g.state === 'custom'
342
- ? ` ${g.file} is not a Hozu template: replace its Hozu section with this block (the markers keep it current):\n\n${g.block}`
343
- : ` ${g.file}: Hozu block ${g.state}`);
344
- let step = 3;
345
- if (r.ir.skipped)
346
- lines.push(`${step++}. IR comparison with 0.7: skipped (${r.ir.skipped})`);
246
+ if (r.notes.length)
247
+ lines.push(' by hand:');
248
+ for (const n of r.notes)
249
+ lines.push(` ${n.file}:${n.line} ${n.message}${n.see ? ` (hozu docs ${n.see})` : ''}`);
347
250
  if (r.ir.compared)
348
251
  lines.push(r.ir.differences.length
349
- ? `${step++}. the IR differs from 0.7 (after the mapping every rewrite allows) at ${r.ir.differences.length} ${r.ir.differences.length === 1 ? 'place' : 'places'}; each is a behaviour change to review:\n${r.ir.differences.map((d) => ` ! ${d}`).join('\n')}`
350
- : `${step++}. the IR equals the 0.7 IR apart from the mapping every rewrite allows (ADR 0043 Migration)`);
252
+ ? ` the IR differs from ${r.from === r.to ? 'the saved one' : r.from} after the declared mapping, at ${r.ir.differences.length} ${r.ir.differences.length === 1 ? 'place' : 'places'}:\n${r.ir.differences.map((d) => ` ! ${d}`).join('\n')}`
253
+ : ' the IR equals the old one after the declared mapping');
254
+ for (const g of r.guide)
255
+ lines.push(` updated ${g}`);
351
256
  if (r.check)
352
- lines.push(`${step++}. hozu check: ${r.check.ok ? 'ok' : 'failed'} · ${r.check.validate.summary.errors} errors, ${r.check.validate.summary.warnings} warnings · lock ${r.check.validate.lock}`);
257
+ lines.push(` hozu check: ${r.check.ok ? 'ok' : 'failed'} · ${r.check.validate.summary.errors} errors, ${r.check.validate.summary.warnings} warnings`);
353
258
  for (const n of r.next)
354
- lines.push(`${step++}. ${n}`);
259
+ lines.push(`next: ${n}`);
355
260
  lines.push('Migrate never writes hozu.lock.json and never deletes a contract.');
356
261
  return `${lines.join('\n')}\n`;
357
262
  }