@gadmin2n/cli 0.0.106 → 0.0.107

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 (44) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/actions/update.action.d.ts +1 -0
  3. package/actions/update.action.js +564 -630
  4. package/commands/command.loader.js +0 -2
  5. package/commands/update.command.js +27 -37
  6. package/lib/template-merge/base-store.d.ts +16 -0
  7. package/lib/template-merge/base-store.js +127 -0
  8. package/lib/template-merge/binary-detect.d.ts +7 -0
  9. package/lib/template-merge/binary-detect.js +36 -0
  10. package/lib/template-merge/classify.d.ts +10 -0
  11. package/lib/template-merge/classify.js +61 -0
  12. package/lib/template-merge/config-validate.d.ts +5 -0
  13. package/lib/template-merge/config-validate.js +33 -0
  14. package/lib/template-merge/excludes.d.ts +7 -0
  15. package/lib/template-merge/excludes.js +109 -0
  16. package/lib/template-merge/index.d.ts +9 -8
  17. package/lib/template-merge/index.js +10 -8
  18. package/lib/template-merge/materialize.d.ts +18 -0
  19. package/lib/template-merge/materialize.js +129 -0
  20. package/lib/template-merge/merge3way.d.ts +15 -0
  21. package/lib/template-merge/merge3way.js +54 -0
  22. package/lib/template-merge/residual-scan.d.ts +10 -0
  23. package/lib/template-merge/residual-scan.js +31 -0
  24. package/lib/template-merge/types.d.ts +27 -0
  25. package/lib/template-merge/types.js +5 -0
  26. package/package.json +2 -2
  27. package/commands/build.command.d.ts +0 -5
  28. package/commands/build.command.js +0 -50
  29. package/lib/template-merge/config-loader.d.ts +0 -15
  30. package/lib/template-merge/config-loader.js +0 -38
  31. package/lib/template-merge/env-merger.d.ts +0 -5
  32. package/lib/template-merge/env-merger.js +0 -57
  33. package/lib/template-merge/glob.d.ts +0 -5
  34. package/lib/template-merge/glob.js +0 -78
  35. package/lib/template-merge/json-merger.d.ts +0 -5
  36. package/lib/template-merge/json-merger.js +0 -269
  37. package/lib/template-merge/merger.d.ts +0 -30
  38. package/lib/template-merge/merger.js +0 -2
  39. package/lib/template-merge/prisma-merger.d.ts +0 -5
  40. package/lib/template-merge/prisma-merger.js +0 -112
  41. package/lib/template-merge/registry.d.ts +0 -25
  42. package/lib/template-merge/registry.js +0 -42
  43. package/lib/template-merge/ts-module-merger.d.ts +0 -16
  44. package/lib/template-merge/ts-module-merger.js +0 -193
@@ -17,157 +17,37 @@ const path = require("path");
17
17
  const inquirer = require("inquirer");
18
18
  const child_process_1 = require("child_process");
19
19
  const abstract_action_1 = require("./abstract.action");
20
- const template_merge_1 = require("../lib/template-merge");
21
20
  const version_check_1 = require("../lib/version-check");
21
+ const merge3way_1 = require("../lib/template-merge/merge3way");
22
+ const binary_detect_1 = require("../lib/template-merge/binary-detect");
23
+ const residual_scan_1 = require("../lib/template-merge/residual-scan");
24
+ const classify_1 = require("../lib/template-merge/classify");
25
+ const excludes_1 = require("../lib/template-merge/excludes");
26
+ const base_store_1 = require("../lib/template-merge/base-store");
27
+ const materialize_1 = require("../lib/template-merge/materialize");
28
+ const config_validate_1 = require("../lib/template-merge/config-validate");
29
+ const types_1 = require("../lib/template-merge/types");
22
30
  /* ------------------------------------------------------------------ *
23
31
  * Config / constants
24
32
  * ------------------------------------------------------------------ */
25
- const DEFAULT_EXCLUDES = [
26
- 'node_modules',
27
- '.git',
28
- 'dist',
29
- 'generated',
30
- '.DS_Store',
31
- '.env.local',
32
- 'readme.md',
33
- // package manager lock files & PnP artifacts —
34
- // regenerated by the auto-install stage based on the merged package.json.
35
- // Letting template versions overwrite instance lock files is semantically
36
- // wrong (lock should reflect actually-installed deps) and clutters diff output.
37
- 'yarn.lock',
38
- 'package-lock.json',
39
- 'pnpm-lock.yaml',
40
- '.yarn',
41
- '.pnp.cjs',
42
- '.pnp.loader.mjs',
43
- ];
44
33
  const CLAUDE_CONTEXT_REPO_HTTPS = 'https://git.tencent.com/OIT-OMC/erp-ai-coding-context.git';
45
34
  const CLAUDE_CONTEXT_REPO_SSH = 'git@git.tencent.com:OIT-OMC/erp-ai-coding-context.git';
46
35
  const CLAUDE_CONTEXT_BRANCH = 'master';
47
36
  const CLAUDE_SOURCE_SUBDIR = '.claude';
48
37
  const CLAUDE_TARGET_DIRS = ['.claude-internal', '.agent'];
49
- /* ------------------------------------------------------------------ *
50
- * Glob / pattern utilities
51
- * ------------------------------------------------------------------ */
52
38
  /**
53
- * Convert a glob pattern to a RegExp.
54
- * Supports: * (any chars except /), ** (any path segments), ? (single char)
39
+ * Canonical formatting for "failed to materialize the base" errors.
40
+ * All three handlers (handleDiff/handleStatus/handleUpdate) share this so the
41
+ * user sees the same wording regardless of entry point.
55
42
  */
56
- function globToRegex(pattern) {
57
- let regexStr = '^';
58
- let i = 0;
59
- while (i < pattern.length) {
60
- const c = pattern[i];
61
- if (c === '*') {
62
- if (pattern[i + 1] === '*') {
63
- if (pattern[i + 2] === '/') {
64
- regexStr += '(?:.+/)?';
65
- i += 3;
66
- }
67
- else {
68
- regexStr += '.*';
69
- i += 2;
70
- }
71
- }
72
- else {
73
- regexStr += '[^/]*';
74
- i++;
75
- }
76
- }
77
- else if (c === '?') {
78
- regexStr += '[^/]';
79
- i++;
80
- }
81
- else if (c === '.' ||
82
- c === '(' ||
83
- c === ')' ||
84
- c === '[' ||
85
- c === ']' ||
86
- c === '{' ||
87
- c === '}' ||
88
- c === '+' ||
89
- c === '^' ||
90
- c === '$' ||
91
- c === '|' ||
92
- c === '\\') {
93
- regexStr += '\\' + c;
94
- i++;
95
- }
96
- else {
97
- regexStr += c;
98
- i++;
99
- }
100
- }
101
- regexStr += '$';
102
- return new RegExp(regexStr);
103
- }
104
- function matchesPattern(filePath, pattern) {
105
- if (filePath === pattern)
106
- return true;
107
- if (filePath.startsWith(pattern + '/'))
108
- return true;
109
- const parts = filePath.split('/');
110
- if (!pattern.includes('/') &&
111
- !pattern.includes('*') &&
112
- !pattern.includes('?')) {
113
- if (parts.includes(pattern))
114
- return true;
115
- }
116
- if (pattern.includes('*') || pattern.includes('?')) {
117
- const regex = globToRegex(pattern);
118
- return regex.test(filePath);
119
- }
120
- return false;
121
- }
122
- function shouldExclude(filePath, excludes) {
123
- for (const pattern of excludes) {
124
- if (matchesPattern(filePath, pattern))
125
- return true;
126
- }
127
- return false;
128
- }
129
- function collectFiles(dir, excludes, base = '') {
130
- const results = [];
131
- const entries = fs.readdirSync(path.join(dir, base), { withFileTypes: true });
132
- for (const entry of entries) {
133
- const rel = base ? `${base}/${entry.name}` : entry.name;
134
- if (shouldExclude(rel, excludes))
135
- continue;
136
- if (entry.isDirectory()) {
137
- results.push(...collectFiles(dir, excludes, rel));
138
- }
139
- else if (entry.isFile()) {
140
- results.push(rel);
141
- }
142
- }
143
- return results.sort();
144
- }
145
- function loadTemplateConfig() {
146
- const configPaths = [
147
- path.join(process.cwd(), 'server/gadmin-cli.json'),
148
- path.join(process.cwd(), 'gadmin-cli.json'),
149
- ];
150
- let templateName;
151
- let userExcludes = [];
152
- for (const configPath of configPaths) {
153
- if (fs.existsSync(configPath)) {
154
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
155
- templateName = config.template;
156
- if (Array.isArray(config.templateExcludes)) {
157
- userExcludes = config.templateExcludes;
158
- }
159
- break;
160
- }
161
- }
162
- if (!templateName) {
163
- throw new Error('Cannot find "template" field in gadmin-cli.json. ' +
164
- 'Make sure you are in a gadmin2 project root directory.');
165
- }
166
- return {
167
- templateName,
168
- excludes: [...DEFAULT_EXCLUDES, ...userExcludes],
169
- };
43
+ function formatMaterializeError(schematicsVersion, err) {
44
+ var _a;
45
+ const m = (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err);
46
+ return `\n❌ Failed to materialize base @ schematics ${schematicsVersion}: ${m}\n`;
170
47
  }
48
+ /* ------------------------------------------------------------------ *
49
+ * Template config / path resolution
50
+ * ------------------------------------------------------------------ */
171
51
  function resolveTemplatePath(templateName) {
172
52
  let schematicsRoot;
173
53
  const searchPaths = [
@@ -208,101 +88,6 @@ function resolveTemplatePath(templateName) {
208
88
  }
209
89
  throw new Error(`Template directory not found for "${templateName}" in ${schematicsRoot}`);
210
90
  }
211
- /* ------------------------------------------------------------------ *
212
- * Diff / merge utilities
213
- * ------------------------------------------------------------------ */
214
- function createUnifiedDiff(filePath, instanceContent, templateContent) {
215
- const instanceLines = instanceContent.split('\n');
216
- const templateLines = templateContent.split('\n');
217
- const lines = [];
218
- lines.push(chalk.bold(`--- a/${filePath} (instance)`));
219
- lines.push(chalk.bold(`+++ b/${filePath} (template)`));
220
- const maxLen = Math.max(instanceLines.length, templateLines.length);
221
- const hunks = [];
222
- let currentHunk = null;
223
- for (let i = 0; i < maxLen; i++) {
224
- const a = i < instanceLines.length ? instanceLines[i] : undefined;
225
- const b = i < templateLines.length ? templateLines[i] : undefined;
226
- if (a !== b) {
227
- if (!currentHunk) {
228
- currentHunk = { start: i, instanceLines: [], templateLines: [] };
229
- }
230
- if (a !== undefined)
231
- currentHunk.instanceLines.push(a);
232
- if (b !== undefined)
233
- currentHunk.templateLines.push(b);
234
- }
235
- else {
236
- if (currentHunk) {
237
- hunks.push(currentHunk);
238
- currentHunk = null;
239
- }
240
- }
241
- }
242
- if (currentHunk)
243
- hunks.push(currentHunk);
244
- for (const hunk of hunks.slice(0, 10)) {
245
- const contextStart = Math.max(0, hunk.start - 2);
246
- lines.push(chalk.cyan(`@@ -${hunk.start + 1},${hunk.instanceLines.length} +${hunk.start + 1},${hunk.templateLines.length} @@`));
247
- for (let i = contextStart; i < hunk.start; i++) {
248
- if (i < instanceLines.length) {
249
- lines.push(` ${instanceLines[i]}`);
250
- }
251
- }
252
- for (const line of hunk.instanceLines) {
253
- lines.push(chalk.red(`-${line}`));
254
- }
255
- for (const line of hunk.templateLines) {
256
- lines.push(chalk.green(`+${line}`));
257
- }
258
- const afterStart = hunk.start + Math.max(hunk.instanceLines.length, hunk.templateLines.length);
259
- for (let i = afterStart; i < Math.min(afterStart + 2, instanceLines.length); i++) {
260
- lines.push(` ${instanceLines[i]}`);
261
- }
262
- }
263
- if (hunks.length > 10) {
264
- lines.push(chalk.yellow(` ... and ${hunks.length - 10} more hunks`));
265
- }
266
- return lines.join('\n');
267
- }
268
- function createConflictContent(instanceContent, templateContent) {
269
- return [
270
- '<<<<<<< INSTANCE',
271
- instanceContent,
272
- '=======',
273
- templateContent,
274
- '>>>>>>> TEMPLATE',
275
- ].join('\n');
276
- }
277
- function tryAutoMerge(instanceContent, templateContent) {
278
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gadmin-merge-'));
279
- const basePath = path.join(tmpDir, 'base');
280
- const oursPath = path.join(tmpDir, 'ours');
281
- const theirsPath = path.join(tmpDir, 'theirs');
282
- try {
283
- fs.writeFileSync(basePath, '', 'utf-8');
284
- fs.writeFileSync(oursPath, instanceContent, 'utf-8');
285
- fs.writeFileSync(theirsPath, templateContent, 'utf-8');
286
- try {
287
- (0, child_process_1.execSync)(`git merge-file -L "INSTANCE" -L "BASE" -L "TEMPLATE" "${oursPath}" "${basePath}" "${theirsPath}"`, { stdio: 'pipe' });
288
- const merged = fs.readFileSync(oursPath, 'utf-8');
289
- return { merged, hasConflicts: false };
290
- }
291
- catch (err) {
292
- if (err.status > 0) {
293
- const merged = fs.readFileSync(oursPath, 'utf-8');
294
- return { merged, hasConflicts: true };
295
- }
296
- return {
297
- merged: createConflictContent(instanceContent, templateContent),
298
- hasConflicts: true,
299
- };
300
- }
301
- }
302
- finally {
303
- fs.rmSync(tmpDir, { recursive: true, force: true });
304
- }
305
- }
306
91
  /* ------------------------------------------------------------------ *
307
92
  * .claude context sync (from remote git repo)
308
93
  * ------------------------------------------------------------------ */
@@ -591,40 +376,288 @@ function maybeInstallChangedDeps(snapshots, dryRun) {
591
376
  }
592
377
  });
593
378
  }
379
+ function assertGitAvailable() {
380
+ try {
381
+ (0, child_process_1.execSync)('git --version', { stdio: 'pipe' });
382
+ }
383
+ catch (_a) {
384
+ throw new Error('gadmin2 update requires git in PATH (used internally for 3-way merge). Install git and retry.');
385
+ }
386
+ }
387
+ function assertNotBothOursAndTheirs(opts) {
388
+ if (opts.ours && opts.theirs) {
389
+ throw new Error('--ours and --theirs are mutually exclusive');
390
+ }
391
+ }
392
+ function loadGadminCliJson(instanceDir) {
393
+ const candidates = [
394
+ path.join(instanceDir, 'server/gadmin-cli.json'),
395
+ path.join(instanceDir, 'gadmin-cli.json'),
396
+ ];
397
+ for (const p of candidates) {
398
+ if (fs.existsSync(p)) {
399
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
400
+ if (!raw.template) {
401
+ throw new Error('gadmin-cli.json missing "template" field');
402
+ }
403
+ return {
404
+ template: raw.template,
405
+ templateExcludes: raw.templateExcludes,
406
+ templateUpdate: raw.templateUpdate,
407
+ raw,
408
+ };
409
+ }
410
+ }
411
+ throw new Error('Cannot find "template" field in gadmin-cli.json. ' +
412
+ 'Make sure you are in a gadmin2 project root directory.');
413
+ }
414
+ /**
415
+ * 整合:必须在 instance 根 / git 可用 / 配置无废弃字段 / 锁可获取 / 没有残留冲突 / flag 不矛盾。
416
+ */
417
+ function runPreflight(opts) {
418
+ var _a, _b, _c;
419
+ const instanceDir = process.cwd();
420
+ const instanceMarker = path.join(instanceDir, 'server', 'gadmin-cli.json');
421
+ if (!fs.existsSync(instanceMarker)) {
422
+ throw new Error('Not at a gadmin2 instance project root.\n' +
423
+ ` Expected to find: server/gadmin-cli.json\n` +
424
+ ` Current directory: ${instanceDir}\n\n` +
425
+ 'Run this command from the project root created by `gadmin2 n <name>`.');
426
+ }
427
+ assertGitAvailable();
428
+ assertNotBothOursAndTheirs(opts);
429
+ const cfg = loadGadminCliJson(instanceDir);
430
+ (0, config_validate_1.assertNoDeprecatedFields)(cfg.raw);
431
+ const userExcludes = [
432
+ ...((_a = cfg.templateExcludes) !== null && _a !== void 0 ? _a : []),
433
+ ...((_c = (_b = cfg.templateUpdate) === null || _b === void 0 ? void 0 : _b.excludes) !== null && _c !== void 0 ? _c : []),
434
+ ];
435
+ const releaseLock = (0, base_store_1.acquireLock)(instanceDir);
436
+ // Residual conflict scan (instance-side files only).
437
+ const allExcludes = [...excludes_1.HARDCODED_EXCLUDES, ...excludes_1.DEFAULT_EXCLUDES, ...userExcludes];
438
+ const instanceFiles = (0, excludes_1.collectFiles)(instanceDir, allExcludes);
439
+ const residual = (0, residual_scan_1.scanResidualMarkers)(instanceDir, instanceFiles);
440
+ if (residual.length > 0) {
441
+ releaseLock();
442
+ const lines = residual.map((r) => ` ${r.file}:${r.line}`).join('\n');
443
+ throw new Error(`Refusing to update: ${residual.length} marker(s) from a previous update remain.\n\n` +
444
+ `${lines}\n\n` +
445
+ ` Resolve all <<<<<<< / >>>>>>> markers, then re-run.\n` +
446
+ ` Hint: git diff --check\n` +
447
+ ` gadmin2 update status`);
448
+ }
449
+ return { instanceDir, templateName: cfg.template, userExcludes, releaseLock };
450
+ }
594
451
  /* ------------------------------------------------------------------ *
595
- * Smart merger registry
452
+ * Apply phase
596
453
  * ------------------------------------------------------------------ */
597
- const DEFAULT_MERGER_RULES = [
598
- { pattern: 'package.json', mergerName: 'json' },
599
- { pattern: '**/package.json', mergerName: 'json' },
600
- { pattern: 'tsconfig*.json', mergerName: 'json' },
601
- { pattern: '**/tsconfig*.json', mergerName: 'json' },
602
- { pattern: '**/*.module.ts', mergerName: 'ts-module' },
603
- { pattern: 'config/prisma/*.prisma', mergerName: 'prisma' },
604
- { pattern: 'server/prisma/schema.prisma', mergerName: 'prisma' },
605
- { pattern: '.env', mergerName: 'env' },
606
- { pattern: '.env.*', mergerName: 'env' },
607
- { pattern: '**/.env', mergerName: 'env' },
608
- { pattern: '**/.env.*', mergerName: 'env' },
609
- ];
610
- function buildMergerRegistry(userConfig) {
611
- const reg = new template_merge_1.MergerRegistry();
612
- reg.register(new template_merge_1.JsonMerger());
613
- reg.register(new template_merge_1.EnvMerger());
614
- reg.register(new template_merge_1.PrismaMerger());
615
- reg.register(new template_merge_1.TsModuleMerger());
616
- if ((userConfig === null || userConfig === void 0 ? void 0 : userConfig.mergers) && Object.keys(userConfig.mergers).length > 0) {
617
- // 用户配置完全替换默认规则
618
- for (const [pattern, mergerName] of Object.entries(userConfig.mergers)) {
619
- reg.addRule({ pattern, mergerName });
454
+ function readUtf8Safe(p) {
455
+ return fs.readFileSync(p, 'utf-8');
456
+ }
457
+ function ensureDir(file) {
458
+ fs.mkdirSync(path.dirname(file), { recursive: true });
459
+ }
460
+ function handleBinary(rel, baseRoot, oursRoot, theirsRoot, opts, conflicts) {
461
+ const basePath = path.join(baseRoot, rel);
462
+ const oursPath = path.join(oursRoot, rel);
463
+ const theirsPath = path.join(theirsRoot, rel);
464
+ const baseBuf = fs.existsSync(basePath) ? fs.readFileSync(basePath) : null;
465
+ const oursBuf = fs.existsSync(oursPath) ? fs.readFileSync(oursPath) : null;
466
+ const theirsBuf = fs.existsSync(theirsPath) ? fs.readFileSync(theirsPath) : null;
467
+ if (oursBuf && theirsBuf && oursBuf.equals(theirsBuf))
468
+ return;
469
+ if (baseBuf && oursBuf && baseBuf.equals(oursBuf) && theirsBuf) {
470
+ ensureDir(oursPath);
471
+ fs.copyFileSync(theirsPath, oursPath);
472
+ return;
473
+ }
474
+ if (opts.theirs && theirsBuf) {
475
+ ensureDir(oursPath);
476
+ fs.copyFileSync(theirsPath, oursPath);
477
+ return;
478
+ }
479
+ if (opts.ours)
480
+ return; // keep OURS as-is
481
+ conflicts.push({
482
+ path: rel,
483
+ kind: 'binary',
484
+ detail: `binary differs three-ways. To accept template: cp "${theirsPath}" "${oursPath}"`,
485
+ });
486
+ console.warn(chalk.yellow(` ⚠️ binary conflict (cannot 3-way merge): ${rel}`));
487
+ console.warn(chalk.gray(` template: ${theirsPath}`));
488
+ console.warn(chalk.gray(` base: ${basePath}`));
489
+ }
490
+ function applyPlan(plan, baseRoot, oursRoot, theirsRoot, opts) {
491
+ const conflicts = [];
492
+ for (const rel of plan.add) {
493
+ const dst = path.join(oursRoot, rel);
494
+ ensureDir(dst);
495
+ fs.copyFileSync(path.join(theirsRoot, rel), dst);
496
+ }
497
+ for (const rel of plan.delete) {
498
+ const dst = path.join(oursRoot, rel);
499
+ if (fs.existsSync(dst))
500
+ fs.rmSync(dst);
501
+ }
502
+ for (const rel of plan.update) {
503
+ const basePath = path.join(baseRoot, rel);
504
+ const oursPath = path.join(oursRoot, rel);
505
+ const theirsPath = path.join(theirsRoot, rel);
506
+ if ((0, binary_detect_1.isAnyBinary)(basePath, oursPath, theirsPath)) {
507
+ handleBinary(rel, baseRoot, oursRoot, theirsRoot, opts, conflicts);
508
+ continue;
509
+ }
510
+ const result = (0, merge3way_1.merge3Way)(readUtf8Safe(basePath), readUtf8Safe(oursPath), readUtf8Safe(theirsPath));
511
+ if (result.hasConflicts && opts.ours) {
512
+ // keep OURS as-is — no write
513
+ continue;
514
+ }
515
+ if (result.hasConflicts && opts.theirs) {
516
+ ensureDir(oursPath);
517
+ fs.copyFileSync(theirsPath, oursPath);
518
+ continue;
519
+ }
520
+ ensureDir(oursPath);
521
+ fs.writeFileSync(oursPath, result.merged, 'utf-8');
522
+ if (result.hasConflicts)
523
+ conflicts.push({ path: rel, kind: 'content' });
524
+ }
525
+ // add/add
526
+ for (const rel of plan.addAddConflict) {
527
+ const oursPath = path.join(oursRoot, rel);
528
+ const theirsPath = path.join(theirsRoot, rel);
529
+ if (opts.theirs) {
530
+ ensureDir(oursPath);
531
+ fs.copyFileSync(theirsPath, oursPath);
532
+ }
533
+ else if (!opts.ours) {
534
+ conflicts.push({
535
+ path: rel,
536
+ kind: 'add/add',
537
+ detail: 'both added different content; instance kept',
538
+ });
539
+ }
540
+ }
541
+ // modify/delete (template deleted, instance modified)
542
+ for (const rel of plan.modifyDeleteConflict) {
543
+ const oursPath = path.join(oursRoot, rel);
544
+ if (opts.theirs) {
545
+ if (fs.existsSync(oursPath))
546
+ fs.rmSync(oursPath);
547
+ }
548
+ else if (!opts.ours) {
549
+ conflicts.push({
550
+ path: rel,
551
+ kind: 'modify/delete',
552
+ detail: 'template deleted this file; instance modified — file kept',
553
+ });
554
+ }
555
+ }
556
+ // delete/modify (instance deleted, template modified)
557
+ for (const rel of plan.deleteModifyConflict) {
558
+ const oursPath = path.join(oursRoot, rel);
559
+ const theirsPath = path.join(theirsRoot, rel);
560
+ if (opts.theirs) {
561
+ ensureDir(oursPath);
562
+ fs.copyFileSync(theirsPath, oursPath);
563
+ }
564
+ else if (!opts.ours) {
565
+ conflicts.push({
566
+ path: rel,
567
+ kind: 'delete/modify',
568
+ detail: 'instance deleted this file; template modified — file NOT restored',
569
+ });
620
570
  }
621
571
  }
622
- else {
623
- for (const rule of DEFAULT_MERGER_RULES) {
624
- reg.addRule(rule);
572
+ return conflicts;
573
+ }
574
+ /* ------------------------------------------------------------------ *
575
+ * Print summary
576
+ * ------------------------------------------------------------------ */
577
+ function printPlanCounts(plan) {
578
+ console.info(` ${plan.update.length} files cleanly merged or 3-way merged\n` +
579
+ ` ${plan.add.length} files added (template-only)\n` +
580
+ ` ${plan.delete.length} files deleted (template removed)`);
581
+ if (plan.keep.length > 0) {
582
+ console.info(chalk.gray(` ${plan.keep.length} files kept (instance-only or identical)`));
583
+ }
584
+ }
585
+ function printSummary(plan, conflicts, templateName, schematicsVersion) {
586
+ console.info(chalk.bold('\n═══════════════════════════════════════════════════'));
587
+ console.info(chalk.bold(' Template Update Summary'));
588
+ console.info(chalk.bold('═══════════════════════════════════════════════════\n'));
589
+ printPlanCounts(plan);
590
+ console.info();
591
+ if (conflicts.length === 0) {
592
+ console.info(chalk.green('✅ Template update complete — no conflicts.'));
593
+ console.info(` Base advanced to: ${templateName} @ schematics ${schematicsVersion}\n`);
594
+ return;
595
+ }
596
+ console.info(chalk.yellow(`⚠️ ${conflicts.length} conflict(s) need your attention:\n`));
597
+ const padKind = (k) => k.padEnd(16);
598
+ for (const c of conflicts) {
599
+ const kindLabel = `[${c.kind}]`;
600
+ console.info(` ${chalk.yellow(padKind(kindLabel))} ${c.path}` +
601
+ (c.detail ? chalk.gray(` (${c.detail})`) : ''));
602
+ }
603
+ console.info();
604
+ console.info(chalk.gray(' Recommended workflow:'));
605
+ console.info(chalk.gray(' $ git status # see all modified files'));
606
+ console.info(chalk.gray(' $ git diff --check # locate residual <<<<<<< / >>>>>>> markers'));
607
+ console.info(chalk.gray(' $ <editor> <conflicting-file>'));
608
+ console.info(chalk.gray(' ...'));
609
+ console.info(chalk.gray(' $ gadmin2 update status # verify conflicts resolved'));
610
+ console.info(chalk.gray(' $ git add . && git commit'));
611
+ console.info();
612
+ console.info(` Base advanced to: ${templateName} @ schematics ${schematicsVersion}\n`);
613
+ }
614
+ /* ------------------------------------------------------------------ *
615
+ * Per-file 3-way diff display (used by `gadmin2 update diff <file>`)
616
+ * ------------------------------------------------------------------ */
617
+ function printPerFileTriDiff(rel, baseRoot, oursRoot, theirsRoot, baseExists) {
618
+ const basePath = path.join(baseRoot, rel);
619
+ const oursPath = path.join(oursRoot, rel);
620
+ const theirsPath = path.join(theirsRoot, rel);
621
+ const has = (p) => fs.existsSync(p);
622
+ console.info(`File: ${chalk.bold(rel)}\n`);
623
+ if (baseExists && has(basePath) && has(theirsPath)) {
624
+ console.info(chalk.gray(' base → theirs (template increment since last update)'));
625
+ console.info(chalk.gray(' ──────────────────'));
626
+ console.info(unifiedDiffSimple(fs.readFileSync(basePath, 'utf-8'), fs.readFileSync(theirsPath, 'utf-8')));
627
+ console.info();
628
+ }
629
+ if (baseExists && has(basePath) && has(oursPath)) {
630
+ console.info(chalk.gray(' ours → base (instance changes since last update)'));
631
+ console.info(chalk.gray(' ──────────────────'));
632
+ console.info(unifiedDiffSimple(fs.readFileSync(basePath, 'utf-8'), fs.readFileSync(oursPath, 'utf-8')));
633
+ console.info();
634
+ }
635
+ else if (!baseExists) {
636
+ console.info(chalk.gray(' ours vs theirs (no base — falls back to 2-way)'));
637
+ console.info(chalk.gray(' ──────────────────'));
638
+ if (has(oursPath) && has(theirsPath)) {
639
+ console.info(unifiedDiffSimple(fs.readFileSync(oursPath, 'utf-8'), fs.readFileSync(theirsPath, 'utf-8')));
625
640
  }
641
+ console.info();
626
642
  }
627
- return reg;
643
+ }
644
+ /** Minimal unified diff output suitable for terminal. */
645
+ function unifiedDiffSimple(a, b) {
646
+ const A = a.split('\n');
647
+ const B = b.split('\n');
648
+ const out = [];
649
+ const max = Math.max(A.length, B.length);
650
+ for (let i = 0; i < max; i++) {
651
+ const x = i < A.length ? A[i] : undefined;
652
+ const y = i < B.length ? B[i] : undefined;
653
+ if (x === y)
654
+ continue;
655
+ if (x !== undefined)
656
+ out.push(chalk.red(`-${x}`));
657
+ if (y !== undefined)
658
+ out.push(chalk.green(`+${y}`));
659
+ }
660
+ return out.length === 0 ? chalk.gray(' (no textual diff)') : out.join('\n');
628
661
  }
629
662
  /* ------------------------------------------------------------------ *
630
663
  * Action
@@ -634,8 +667,8 @@ class UpdateAction extends abstract_action_1.AbstractAction {
634
667
  var _a, _b;
635
668
  return __awaiter(this, void 0, void 0, function* () {
636
669
  const subcommand = (_a = inputs.find((i) => i.name === 'subcommand')) === null || _a === void 0 ? void 0 : _a.value;
637
- if (subcommand && subcommand !== 'update' && subcommand !== 'diff') {
638
- console.error(chalk.red(`Unknown subcommand "${subcommand}". Expected: (none) | diff`));
670
+ if (subcommand && subcommand !== 'update' && subcommand !== 'diff' && subcommand !== 'status') {
671
+ console.error(chalk.red(`Unknown subcommand "${subcommand}". Expected: (none) | diff | status`));
639
672
  process.exit(1);
640
673
  }
641
674
  // Guard: refuse to run anywhere except a gadmin2 instance project root.
@@ -643,7 +676,7 @@ class UpdateAction extends abstract_action_1.AbstractAction {
643
676
  // We check this up-front so commands like `gadmin2 update --no-template`
644
677
  // can't silently sync .claude into an unrelated directory.
645
678
  const instanceMarker = path.join(process.cwd(), 'server', 'gadmin-cli.json');
646
- if (!fs.existsSync(instanceMarker)) {
679
+ if (subcommand !== 'status' && !fs.existsSync(instanceMarker)) {
647
680
  console.error(chalk.red('\nNot at a gadmin2 instance project root.\n' +
648
681
  ` Expected to find: server/gadmin-cli.json\n` +
649
682
  ` Current directory: ${process.cwd()}\n\n` +
@@ -665,6 +698,10 @@ class UpdateAction extends abstract_action_1.AbstractAction {
665
698
  // never let the version check break update
666
699
  }
667
700
  }
701
+ if (subcommand === 'status') {
702
+ yield this.handleStatus();
703
+ return;
704
+ }
668
705
  if (subcommand === 'diff') {
669
706
  yield this.handleDiff(options);
670
707
  return;
@@ -673,88 +710,169 @@ class UpdateAction extends abstract_action_1.AbstractAction {
673
710
  });
674
711
  }
675
712
  handleDiff(options) {
676
- var _a;
713
+ var _a, _b, _c, _d, _e;
677
714
  return __awaiter(this, void 0, void 0, function* () {
678
- const noContent = (_a = options.find((o) => o.name === 'no-content')) === null || _a === void 0 ? void 0 : _a.value;
679
- let templateDir;
680
- let excludes;
681
- try {
682
- const config = loadTemplateConfig();
683
- templateDir = resolveTemplatePath(config.templateName);
684
- excludes = config.excludes;
685
- }
686
- catch (err) {
687
- console.error(chalk.red(err.message));
688
- process.exit(1);
689
- }
715
+ const filterFile = (_a = options.find((o) => o.name === 'subcommand-arg')) === null || _a === void 0 ? void 0 : _a.value;
690
716
  const instanceDir = process.cwd();
691
- console.info(`\n${chalk.gray('Template:')} ${templateDir}`);
692
- console.info(`${chalk.gray('Instance:')} ${instanceDir}\n`);
693
- const templateFiles = collectFiles(templateDir, excludes);
694
- const instanceFiles = collectFiles(instanceDir, excludes);
695
- const templateSet = new Set(templateFiles);
696
- const instanceSet = new Set(instanceFiles);
697
- const modified = [];
698
- const onlyInTemplate = [];
699
- const onlyInInstance = [];
700
- for (const file of templateFiles) {
701
- if (instanceSet.has(file)) {
702
- const tContent = fs.readFileSync(path.join(templateDir, file), 'utf-8');
703
- const iContent = fs.readFileSync(path.join(instanceDir, file), 'utf-8');
704
- if (tContent !== iContent) {
705
- modified.push(file);
706
- }
717
+ const cfg = loadGadminCliJson(instanceDir);
718
+ const userExcludes = [
719
+ ...((_b = cfg.templateExcludes) !== null && _b !== void 0 ? _b : []),
720
+ ...((_d = (_c = cfg.templateUpdate) === null || _c === void 0 ? void 0 : _c.excludes) !== null && _d !== void 0 ? _d : []),
721
+ ];
722
+ const theirsRoot = resolveTemplatePath(cfg.template);
723
+ const metaExists = fs.existsSync((0, base_store_1.metaPath)(instanceDir));
724
+ let baseHandle = null;
725
+ let baseRoot;
726
+ let meta = null;
727
+ if (metaExists) {
728
+ try {
729
+ meta = (0, base_store_1.readMeta)(instanceDir);
730
+ baseHandle = (0, materialize_1.materializeBase)(meta.schematicsVersion, cfg.template);
731
+ baseRoot = baseHandle.dir;
707
732
  }
708
- else {
709
- onlyInTemplate.push(file);
733
+ catch (err) {
734
+ console.error(chalk.red(formatMaterializeError((_e = meta === null || meta === void 0 ? void 0 : meta.schematicsVersion) !== null && _e !== void 0 ? _e : '?', err)));
735
+ process.exit(1);
710
736
  }
711
737
  }
712
- for (const file of instanceFiles) {
713
- if (!templateSet.has(file)) {
714
- onlyInInstance.push(file);
715
- }
738
+ else {
739
+ // No bootstrap yet — fall back to "compare instance vs theirs" by pointing
740
+ // base at a non-existent path so classifyAll treats every file as new.
741
+ baseRoot = path.join(instanceDir, '.gadmin/__nonexistent__');
716
742
  }
717
- console.info(chalk.bold('═══════════════════════════════════════════════════'));
718
- console.info(chalk.bold(' Template Diff Summary'));
719
- console.info(chalk.bold('═══════════════════════════════════════════════════\n'));
720
- if (modified.length > 0) {
721
- console.info(chalk.yellow(`📝 Modified (${modified.length} files)`));
722
- for (const f of modified) {
723
- console.info(` ${f}`);
724
- }
743
+ try {
744
+ console.info(`\n${chalk.gray('Template:')} ${theirsRoot}`);
745
+ console.info(`${chalk.gray('Instance:')} ${instanceDir}`);
746
+ console.info(`${chalk.gray('Base: ')} ${metaExists
747
+ ? `(materialized from schematics ${meta.schematicsVersion})`
748
+ : '(not yet established — run `gadmin2 update` once to bootstrap)'}`);
725
749
  console.info();
726
- }
727
- if (onlyInTemplate.length > 0) {
728
- console.info(chalk.green(`➕ Only in template (${onlyInTemplate.length} files)`));
729
- for (const f of onlyInTemplate) {
730
- console.info(` ${f}`);
750
+ const plan = (0, classify_1.classifyAll)(baseRoot, instanceDir, theirsRoot, userExcludes);
751
+ if (filterFile) {
752
+ printPerFileTriDiff(filterFile, baseRoot, instanceDir, theirsRoot, metaExists);
753
+ return;
731
754
  }
732
- console.info();
733
- }
734
- if (onlyInInstance.length > 0) {
735
- console.info(chalk.cyan(`📄 Only in instance (${onlyInInstance.length} files)`));
736
- for (const f of onlyInInstance) {
737
- console.info(` ${f}`);
755
+ console.info(chalk.bold('═══════════════════════════════════════════════════'));
756
+ console.info(chalk.bold(' Template Diff Summary (3-way)'));
757
+ console.info(chalk.bold('═══════════════════════════════════════════════════\n'));
758
+ if (plan.add.length > 0) {
759
+ console.info(chalk.green(`➕ To add (${plan.add.length} files)`));
760
+ plan.add.forEach((f) => console.info(` ${f}`));
761
+ console.info();
762
+ }
763
+ if (plan.delete.length > 0) {
764
+ console.info(chalk.red(`🗑 To delete (${plan.delete.length} files)`));
765
+ plan.delete.forEach((f) => console.info(` ${f}`));
766
+ console.info();
767
+ }
768
+ if (plan.update.length > 0) {
769
+ console.info(chalk.yellow(`📝 To update via 3-way merge (${plan.update.length} files)`));
770
+ plan.update.forEach((f) => console.info(` ${f}`));
771
+ console.info();
772
+ }
773
+ const conflicts = [
774
+ ...plan.addAddConflict.map((p) => ['add/add', p]),
775
+ ...plan.modifyDeleteConflict.map((p) => ['modify/delete', p]),
776
+ ...plan.deleteModifyConflict.map((p) => ['delete/modify', p]),
777
+ ];
778
+ if (conflicts.length > 0) {
779
+ console.info(chalk.magenta(`⚠️ Structural conflicts (${conflicts.length} files)`));
780
+ conflicts.forEach(([k, p]) => console.info(` [${k}] ${p}`));
781
+ console.info();
782
+ }
783
+ if (plan.add.length + plan.delete.length + plan.update.length + conflicts.length === 0) {
784
+ console.info(chalk.green('✅ No differences found. Instance matches template.\n'));
738
785
  }
739
- console.info();
740
786
  }
741
- if (modified.length === 0 &&
742
- onlyInTemplate.length === 0 &&
743
- onlyInInstance.length === 0) {
744
- console.info(chalk.green('✅ No differences found. Instance matches template.\n'));
745
- return;
787
+ finally {
788
+ if (baseHandle)
789
+ baseHandle.cleanup();
746
790
  }
747
- if (!noContent && modified.length > 0) {
748
- console.info(chalk.bold('───────────────────────────────────────────────────'));
749
- console.info(chalk.bold(' File Diffs (instance vs template)'));
750
- console.info(chalk.bold('───────────────────────────────────────────────────\n'));
751
- for (const file of modified) {
752
- const tContent = fs.readFileSync(path.join(templateDir, file), 'utf-8');
753
- const iContent = fs.readFileSync(path.join(instanceDir, file), 'utf-8');
754
- const diff = createUnifiedDiff(file, iContent, tContent);
755
- console.info(diff);
791
+ });
792
+ }
793
+ handleStatus() {
794
+ var _a, _b, _c, _d;
795
+ return __awaiter(this, void 0, void 0, function* () {
796
+ const instanceDir = process.cwd();
797
+ const instanceMarker = path.join(instanceDir, 'server', 'gadmin-cli.json');
798
+ if (!fs.existsSync(instanceMarker)) {
799
+ console.error(chalk.red('Not at a gadmin2 instance project root.\n'));
800
+ process.exit(2);
801
+ }
802
+ const cfg = loadGadminCliJson(instanceDir);
803
+ const userExcludes = [
804
+ ...((_a = cfg.templateExcludes) !== null && _a !== void 0 ? _a : []),
805
+ ...((_c = (_b = cfg.templateUpdate) === null || _b === void 0 ? void 0 : _b.excludes) !== null && _c !== void 0 ? _c : []),
806
+ ];
807
+ if (!fs.existsSync((0, base_store_1.metaPath)(instanceDir))) {
808
+ console.error(chalk.red('Base meta missing — run `gadmin2 update` to bootstrap.\n'));
809
+ process.exit(2);
810
+ }
811
+ let meta;
812
+ try {
813
+ meta = (0, base_store_1.readMeta)(instanceDir);
814
+ }
815
+ catch (err) {
816
+ console.error(chalk.red(err.message + '\n'));
817
+ process.exit(2);
818
+ }
819
+ console.info(`Template: ${cfg.template} @ schematics ${meta.schematicsVersion}`);
820
+ const allExcludes = [...excludes_1.HARDCODED_EXCLUDES, ...excludes_1.DEFAULT_EXCLUDES, ...userExcludes];
821
+ const instanceFiles = (0, excludes_1.collectFiles)(instanceDir, allExcludes);
822
+ const residual = (0, residual_scan_1.scanResidualMarkers)(instanceDir, instanceFiles);
823
+ let theirsRoot;
824
+ try {
825
+ theirsRoot = resolveTemplatePath(cfg.template);
826
+ }
827
+ catch (err) {
828
+ console.error(chalk.red(err.message + '\n'));
829
+ process.exit(2);
830
+ }
831
+ let baseHandle = null;
832
+ try {
833
+ try {
834
+ baseHandle = (0, materialize_1.materializeBase)(meta.schematicsVersion, cfg.template);
835
+ }
836
+ catch (err) {
837
+ console.error(chalk.red(formatMaterializeError(meta.schematicsVersion, err)));
838
+ process.exit(2);
839
+ }
840
+ const plan = (0, classify_1.classifyAll)(baseHandle.dir, instanceDir, theirsRoot, userExcludes);
841
+ const structural = [
842
+ ...plan.addAddConflict.map((p) => ({ kind: 'add/add', path: p })),
843
+ ...plan.modifyDeleteConflict.map((p) => ({ kind: 'modify/delete', path: p })),
844
+ ...plan.deleteModifyConflict.map((p) => ({ kind: 'delete/modify', path: p })),
845
+ ];
846
+ const totalConflicts = residual.length + structural.length;
847
+ if (totalConflicts === 0) {
848
+ console.info('Instance state: ' + chalk.green('✅ clean (no unresolved conflicts)\n'));
849
+ return;
850
+ }
851
+ console.info(`Instance state: ${chalk.yellow(`${totalConflicts} unresolved conflict(s)`)}\n`);
852
+ if (residual.length > 0) {
853
+ console.info(' Content conflicts (with markers in source):');
854
+ const byFile = new Map();
855
+ for (const r of residual)
856
+ byFile.set(r.file, ((_d = byFile.get(r.file)) !== null && _d !== void 0 ? _d : 0) + 1);
857
+ for (const [f, n] of byFile) {
858
+ console.info(` ${f.padEnd(48)} (${n} marker${n === 1 ? '' : 's'})`);
859
+ }
860
+ console.info();
861
+ }
862
+ if (structural.length > 0) {
863
+ console.info(' Structural conflicts:');
864
+ for (const s of structural) {
865
+ console.info(` [${s.kind.padEnd(13)}] ${s.path}`);
866
+ }
756
867
  console.info();
757
868
  }
869
+ console.info(' Resolve them, then run `gadmin2 update status` again to verify.');
870
+ process.exitCode = 1;
871
+ return;
872
+ }
873
+ finally {
874
+ if (baseHandle)
875
+ baseHandle.cleanup();
758
876
  }
759
877
  });
760
878
  }
@@ -763,324 +881,140 @@ class UpdateAction extends abstract_action_1.AbstractAction {
763
881
  return __awaiter(this, void 0, void 0, function* () {
764
882
  const dryRun = (_a = options.find((o) => o.name === 'dry-run')) === null || _a === void 0 ? void 0 : _a.value;
765
883
  const autoYes = (_b = options.find((o) => o.name === 'yes')) === null || _b === void 0 ? void 0 : _b.value;
766
- const smartMergeEnabled = (_d = (_c = options.find((o) => o.name === 'smart-merge')) === null || _c === void 0 ? void 0 : _c.value) !== null && _d !== void 0 ? _d : true;
884
+ const ours = (_c = options.find((o) => o.name === 'ours')) === null || _c === void 0 ? void 0 : _c.value;
885
+ const theirs = (_d = options.find((o) => o.name === 'theirs')) === null || _d === void 0 ? void 0 : _d.value;
767
886
  const templateEnabled = ((_e = options.find((o) => o.name === 'template')) === null || _e === void 0 ? void 0 : _e.value) !== false;
768
- const claudeEnabled = ((_f = options.find((o) => o.name === 'coding-context')) === null || _f === void 0 ? void 0 : _f.value) !==
769
- false;
887
+ const claudeEnabled = ((_f = options.find((o) => o.name === 'coding-context')) === null || _f === void 0 ? void 0 : _f.value) !== false;
770
888
  if (!templateEnabled && !claudeEnabled) {
771
889
  console.error(chalk.red('\nNothing to do: both --no-template and --no-coding-context were specified.\n'));
772
890
  process.exit(1);
773
891
  }
774
- const instanceDir = process.cwd();
775
- // Hoisted so stage 3 (auto-install) can see snapshots taken in stage 1.
776
- // Remains null when --no-template, which is also the signal to skip stage 3.
892
+ const mergeOpts = { ours, theirs };
777
893
  let pkgSnapshots = null;
778
- if (templateEnabled) {
779
- let templateDir;
780
- let excludes;
781
- try {
782
- const config = loadTemplateConfig();
783
- templateDir = resolveTemplatePath(config.templateName);
784
- excludes = config.excludes;
785
- }
786
- catch (err) {
787
- console.error(chalk.red(err.message));
788
- process.exit(1);
789
- }
790
- console.info(`\n${chalk.gray('Template:')} ${templateDir}`);
791
- console.info(`${chalk.gray('Instance:')} ${instanceDir}\n`);
792
- // Build smart-merger registry (or null if disabled)
793
- const userTemplateUpdate = smartMergeEnabled
794
- ? (0, template_merge_1.loadTemplateUpdateConfig)(instanceDir)
795
- : null;
796
- const registry = smartMergeEnabled
797
- ? buildMergerRegistry(userTemplateUpdate)
798
- : null;
799
- const userPolicies = userTemplateUpdate === null || userTemplateUpdate === void 0 ? void 0 : userTemplateUpdate.policies;
800
- // Snapshot package.json files BEFORE any writes. We watch:
801
- // - <instanceDir>/package.json (root, if present — workspace / husky / etc.)
802
- // - <instanceDir>/web/package.json
803
- // - <instanceDir>/server/package.json
804
- pkgSnapshots = [
805
- snapshotPackageJson(instanceDir, '(root)'),
806
- snapshotPackageJson(path.join(instanceDir, 'web'), 'web'),
807
- snapshotPackageJson(path.join(instanceDir, 'server'), 'server'),
808
- ];
809
- const templateFiles = collectFiles(templateDir, excludes);
810
- const instanceFiles = collectFiles(instanceDir, excludes);
811
- const instanceSet = new Set(instanceFiles);
812
- const toUpdate = [];
813
- const toAdd = [];
814
- for (const file of templateFiles) {
815
- if (instanceSet.has(file)) {
816
- const tContent = fs.readFileSync(path.join(templateDir, file), 'utf-8');
817
- const iContent = fs.readFileSync(path.join(instanceDir, file), 'utf-8');
818
- if (tContent !== iContent) {
819
- toUpdate.push(file);
820
- }
894
+ let preflight = null;
895
+ let baseHandle = null;
896
+ try {
897
+ if (templateEnabled) {
898
+ try {
899
+ preflight = runPreflight(mergeOpts);
821
900
  }
822
- else {
823
- toAdd.push(file);
901
+ catch (err) {
902
+ console.error(chalk.red(`\n❌ ${err.message}\n`));
903
+ process.exit(1);
824
904
  }
825
- }
826
- if (toUpdate.length === 0 && toAdd.length === 0) {
827
- console.info(chalk.green('✅ Instance is up to date with template.\n'));
828
- }
829
- else {
830
- console.info(chalk.bold('═══════════════════════════════════════════════════'));
831
- console.info(chalk.bold(' Template Update Summary'));
832
- console.info(chalk.bold('═══════════════════════════════════════════════════\n'));
833
- if (toUpdate.length > 0) {
834
- console.info(chalk.yellow(`📝 To update (${toUpdate.length} files)`));
835
- for (const f of toUpdate) {
836
- console.info(` ${f}`);
905
+ const { instanceDir, templateName, userExcludes } = preflight;
906
+ const theirsRoot = resolveTemplatePath(templateName);
907
+ console.info(`\n${chalk.gray('Template:')} ${theirsRoot}`);
908
+ console.info(`${chalk.gray('Instance:')} ${instanceDir}\n`);
909
+ // Snapshot package.json before any writes (for stage 3 install)
910
+ pkgSnapshots = [
911
+ snapshotPackageJson(instanceDir, '(root)'),
912
+ snapshotPackageJson(path.join(instanceDir, 'web'), 'web'),
913
+ snapshotPackageJson(path.join(instanceDir, 'server'), 'server'),
914
+ ];
915
+ const schematicsPkg = require(require.resolve('@gadmin2n/schematics/package.json', { paths: [instanceDir, path.join(instanceDir, 'server')] }));
916
+ const schematicsVersion = schematicsPkg.version || '0.0.0';
917
+ // First-time bootstrap: meta absent → write meta + README, exit.
918
+ if (!fs.existsSync((0, base_store_1.metaPath)(instanceDir))) {
919
+ if (dryRun) {
920
+ console.info(chalk.gray('(dry-run: would write .gadmin/.meta.json pinned to current schematics version, then exit)\n'));
837
921
  }
838
- console.info();
839
- }
840
- if (toAdd.length > 0) {
841
- console.info(chalk.green(`➕ To add (${toAdd.length} files)`));
842
- for (const f of toAdd) {
843
- console.info(` ${f}`);
922
+ else {
923
+ (0, base_store_1.writeMeta)(instanceDir, {
924
+ schematicsVersion,
925
+ schemaVersion: types_1.CURRENT_META_SCHEMA_VERSION,
926
+ });
927
+ (0, base_store_1.writeReadme)(instanceDir);
928
+ console.info(chalk.green('✅ Baseline established at .gadmin/.meta.json'));
929
+ console.info(chalk.gray(` Pinned to schematics ${schematicsVersion}.`));
930
+ console.info(chalk.gray(' The next `gadmin2 update` will perform a real 3-way merge.'));
844
931
  }
845
- console.info();
932
+ // Skip the rest of stage 1 — nothing to merge yet.
846
933
  }
847
- console.info(chalk.gray('───────────────────────────────────────────────────'));
848
- console.info(chalk.gray(` Total: ${toUpdate.length} update | ${toAdd.length} add`));
849
- console.info(chalk.gray('───────────────────────────────────────────────────\n'));
850
- if (!dryRun) {
851
- if (!autoYes) {
852
- const { confirm } = yield inquirer.prompt([
853
- {
854
- type: 'confirm',
855
- name: 'confirm',
856
- message: 'Proceed with template update?',
857
- default: false,
858
- },
859
- ]);
860
- if (!confirm) {
861
- console.info('Cancelled.\n');
862
- return;
863
- }
934
+ else {
935
+ // Validate meta consistency.
936
+ let meta;
937
+ try {
938
+ meta = (0, base_store_1.validateMetaConsistency)(instanceDir);
939
+ }
940
+ catch (err) {
941
+ console.error(chalk.red(`\n❌ ${err.message}\n`));
942
+ process.exit(1);
943
+ return; // unreachable; satisfies TS that meta is definitely assigned
864
944
  }
865
- let skippedCount = 0;
866
- let overwrittenCount = 0;
867
- let mergedCount = 0;
868
- let conflictCount = 0;
869
- let smartCount = 0;
870
- let applyAll = null;
871
- for (let i = 0; i < toUpdate.length; i++) {
872
- const file = toUpdate[i];
873
- const tContent = fs.readFileSync(path.join(templateDir, file), 'utf-8');
874
- const iContent = fs.readFileSync(path.join(instanceDir, file), 'utf-8');
875
- // 1. Try smart merger if registered for this file
876
- const smartMerger = registry
877
- ? registry.resolve(file)
878
- : null;
879
- let smartResult = null;
880
- if (smartMerger) {
881
- try {
882
- smartResult = yield smartMerger.merge({
883
- filePath: file,
884
- instancePath: path.join(instanceDir, file),
885
- templatePath: path.join(templateDir, file),
886
- instanceContent: iContent,
887
- templateContent: tContent,
888
- policies: userPolicies,
889
- });
890
- }
891
- catch (err) {
892
- smartResult = {
893
- ok: false,
894
- reason: (err === null || err === void 0 ? void 0 : err.message) || String(err),
895
- };
896
- }
897
- }
898
- let strategy = applyAll;
899
- if (!strategy) {
900
- if (autoYes) {
901
- // autoYes: prefer smart merge when available, else conflict markers
902
- strategy =
903
- smartResult && smartResult.ok ? 'smart' : 'conflict';
904
- }
905
- else {
906
- const diff = createUnifiedDiff(file, iContent, tContent);
907
- console.info(`\n${chalk.bold(`[${i + 1}/${toUpdate.length}]`)} ${chalk.yellow(file)}`);
908
- console.info(diff);
909
- console.info();
910
- const choices = [];
911
- if (smartResult && smartResult.ok) {
912
- const summary = smartResult.notes.slice(0, 3).join('; ');
913
- const more = smartResult.notes.length > 3 ? '...' : '';
914
- choices.push({
915
- name: `Smart merge — ${smartMerger.name} (recommended): ${summary}${more}`,
916
- value: 'smart',
917
- short: 'smart',
918
- });
919
- }
920
- else if (smartMerger) {
921
- const reason = smartResult && !smartResult.ok
922
- ? smartResult.reason
923
- : 'no result';
924
- choices.push({
925
- name: `Smart merge — ${smartMerger.name} (UNAVAILABLE: ${reason})`,
926
- value: 'smart-unavailable',
927
- short: 'smart-unavailable',
928
- disabled: reason,
929
- });
930
- }
931
- choices.push({
932
- name: 'Skip — keep instance as-is',
933
- value: 'skip',
934
- short: 'skip',
935
- }, {
936
- name: 'Overwrite — use template version',
937
- value: 'overwrite',
938
- short: 'overwrite',
939
- }, {
940
- name: 'Auto merge — attempt git merge',
941
- value: 'merge',
942
- short: 'merge',
943
- }, {
944
- name: 'Conflict markers — write both versions for manual resolve',
945
- value: 'conflict',
946
- short: 'conflict',
947
- }, new inquirer.Separator());
948
- if (smartResult && smartResult.ok) {
949
- choices.push({
950
- name: 'Smart-merge ALL remaining (where supported)',
951
- value: 'smart-all',
952
- short: 'smart all',
953
- });
954
- }
955
- choices.push({
956
- name: 'Skip ALL remaining',
957
- value: 'skip-all',
958
- short: 'skip all',
959
- }, {
960
- name: 'Overwrite ALL remaining',
961
- value: 'overwrite-all',
962
- short: 'overwrite all',
963
- }, {
964
- name: 'Conflict markers ALL remaining',
965
- value: 'conflict-all',
966
- short: 'conflict all',
967
- });
968
- const { action } = yield inquirer.prompt([
969
- {
970
- type: 'list',
971
- name: 'action',
972
- message: `How to handle ${file}?`,
973
- default: smartResult && smartResult.ok ? 'smart' : 'skip',
974
- choices,
975
- },
976
- ]);
977
- if (action.endsWith('-all')) {
978
- applyAll = action.replace('-all', '');
979
- strategy = applyAll;
980
- }
981
- else {
982
- strategy = action;
983
- }
984
- }
985
- }
986
- const destPath = path.join(instanceDir, file);
987
- switch (strategy) {
988
- case 'smart':
989
- if (smartResult && smartResult.ok) {
990
- fs.writeFileSync(destPath, smartResult.merged, 'utf-8');
991
- smartCount++;
992
- console.info(chalk.green(` ✓ ${file} — smart merge (${smartMerger.name})`));
993
- for (const note of smartResult.notes) {
994
- console.info(chalk.gray(` • ${note}`));
995
- }
996
- }
997
- else {
998
- // No smart merger usable: fallback to conflict markers
999
- fs.writeFileSync(destPath, createConflictContent(iContent, tContent), 'utf-8');
1000
- conflictCount++;
1001
- console.info(chalk.yellow(` ⚠️ ${file} — no smart merger available, wrote conflict markers`));
1002
- }
1003
- break;
1004
- case 'skip':
1005
- skippedCount++;
1006
- break;
1007
- case 'overwrite':
1008
- fs.writeFileSync(destPath, tContent, 'utf-8');
1009
- overwrittenCount++;
1010
- break;
1011
- case 'merge': {
1012
- const result = tryAutoMerge(iContent, tContent);
1013
- fs.writeFileSync(destPath, result.merged, 'utf-8');
1014
- if (result.hasConflicts) {
1015
- conflictCount++;
1016
- console.info(chalk.yellow(` ⚠️ ${file} — merge has conflicts`));
1017
- }
1018
- else {
1019
- mergedCount++;
1020
- console.info(chalk.green(` ✓ ${file} — merged cleanly`));
1021
- }
1022
- break;
1023
- }
1024
- case 'conflict':
1025
- default:
1026
- fs.writeFileSync(destPath, createConflictContent(iContent, tContent), 'utf-8');
1027
- conflictCount++;
1028
- break;
1029
- }
945
+ // Materialize base from npm version recorded in meta.
946
+ try {
947
+ baseHandle = (0, materialize_1.materializeBase)(meta.schematicsVersion, templateName);
1030
948
  }
1031
- for (const file of toAdd) {
1032
- const destPath = path.join(instanceDir, file);
1033
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
1034
- fs.copyFileSync(path.join(templateDir, file), destPath);
949
+ catch (err) {
950
+ console.error(chalk.red(formatMaterializeError(meta.schematicsVersion, err)));
951
+ process.exit(1);
1035
952
  }
1036
- console.info(chalk.bold('\n✅ Template update complete!\n'));
1037
- const stats = [];
1038
- if (overwrittenCount > 0)
1039
- stats.push(`${overwrittenCount} overwritten`);
1040
- if (smartCount > 0)
1041
- stats.push(`${smartCount} smart-merged`);
1042
- if (mergedCount > 0)
1043
- stats.push(`${mergedCount} merged`);
1044
- if (conflictCount > 0)
1045
- stats.push(`${conflictCount} with conflicts`);
1046
- if (skippedCount > 0)
1047
- stats.push(`${skippedCount} skipped`);
1048
- if (toAdd.length > 0)
1049
- stats.push(`${toAdd.length} added`);
1050
- console.info(` ${stats.join(' | ')}\n`);
1051
- if (conflictCount > 0) {
1052
- console.info(chalk.yellow(`⚠️ ${conflictCount} files have conflict markers. ` +
1053
- `Search for "<<<<<<< INSTANCE" to resolve them.\n`));
953
+ const baseRoot = baseHandle.dir;
954
+ // Classify three sides.
955
+ const plan = (0, classify_1.classifyAll)(baseRoot, instanceDir, theirsRoot, userExcludes);
956
+ const totalChanges = plan.add.length + plan.delete.length + plan.update.length +
957
+ plan.addAddConflict.length + plan.modifyDeleteConflict.length + plan.deleteModifyConflict.length;
958
+ if (totalChanges === 0) {
959
+ console.info(chalk.green('✅ Instance is up to date with template.\n'));
960
+ }
961
+ else if (dryRun) {
962
+ console.info(chalk.bold('═══════════════════════════════════════════════════'));
963
+ console.info(chalk.bold(' Template Update Plan (dry-run)'));
964
+ console.info(chalk.bold('═══════════════════════════════════════════════════\n'));
965
+ for (const f of plan.add)
966
+ console.info(chalk.green(` add ${f}`));
967
+ for (const f of plan.delete)
968
+ console.info(chalk.red(` delete ${f}`));
969
+ for (const f of plan.update)
970
+ console.info(chalk.yellow(` update ${f}`));
971
+ for (const f of plan.addAddConflict)
972
+ console.info(chalk.magenta(` add/add ${f}`));
973
+ for (const f of plan.modifyDeleteConflict)
974
+ console.info(chalk.magenta(` modify/delete ${f}`));
975
+ for (const f of plan.deleteModifyConflict)
976
+ console.info(chalk.magenta(` delete/modify ${f}`));
977
+ console.info();
978
+ }
979
+ else {
980
+ const conflicts = applyPlan(plan, baseRoot, instanceDir, theirsRoot, mergeOpts);
981
+ // Always advance meta to new version, even when conflicts present (invariant #2).
982
+ (0, base_store_1.writeMeta)(instanceDir, {
983
+ schematicsVersion,
984
+ schemaVersion: types_1.CURRENT_META_SCHEMA_VERSION,
985
+ });
986
+ printSummary(plan, conflicts, templateName, schematicsVersion);
1054
987
  }
1055
988
  }
989
+ }
990
+ else {
991
+ console.info(chalk.gray('\nSkipping template update (--no-template).\n'));
992
+ }
993
+ // Stage 2: .claude context sync — UNCHANGED.
994
+ if (claudeEnabled) {
995
+ const cliRepo = ((_g = options.find((o) => o.name === 'coding-context-repo')) === null || _g === void 0 ? void 0 : _g.value) || '';
996
+ const cliProtocol = ((_h = options.find((o) => o.name === 'coding-context-protocol')) === null || _h === void 0 ? void 0 : _h.value) || '';
997
+ const claudeSource = yield resolveClaudeContextSource(cliRepo, cliProtocol, autoYes);
998
+ if (claudeSource) {
999
+ yield syncClaudeContext(process.cwd(), dryRun, claudeSource);
1000
+ }
1056
1001
  else {
1057
- console.info(chalk.gray('(dry-run mode, no template changes made)\n'));
1002
+ console.info(chalk.gray('\nSkipping .claude context sync (per user choice / --no-coding-context).\n'));
1058
1003
  }
1059
1004
  }
1060
- }
1061
- else {
1062
- console.info(chalk.gray('\nSkipping template update (--no-template).\n'));
1063
- }
1064
- // 2. Sync .claude context (regardless of whether template had changes)
1065
- if (claudeEnabled) {
1066
- const cliRepo = ((_g = options.find((o) => o.name === 'coding-context-repo')) === null || _g === void 0 ? void 0 : _g.value) ||
1067
- '';
1068
- const cliProtocol = ((_h = options.find((o) => o.name === 'coding-context-protocol')) === null || _h === void 0 ? void 0 : _h.value) || '';
1069
- const claudeSource = yield resolveClaudeContextSource(cliRepo, cliProtocol, autoYes);
1070
- if (claudeSource) {
1071
- yield syncClaudeContext(instanceDir, dryRun, claudeSource);
1072
- }
1073
1005
  else {
1074
- console.info(chalk.gray('\nSkipping .claude context sync (per user choice / --no-coding-context).\n'));
1006
+ console.info(chalk.gray('\nSkipping .claude context sync (--no-coding-context).\n'));
1007
+ }
1008
+ // Stage 3: Install changed deps — UNCHANGED.
1009
+ if (pkgSnapshots) {
1010
+ yield maybeInstallChangedDeps(pkgSnapshots, dryRun);
1075
1011
  }
1076
1012
  }
1077
- else {
1078
- console.info(chalk.gray('\nSkipping .claude context sync (--no-coding-context).\n'));
1079
- }
1080
- // 3. If web/server package.json changed during template update, install deps.
1081
- // Skipped entirely under --no-template (no snapshots taken → nothing to compare).
1082
- if (pkgSnapshots) {
1083
- yield maybeInstallChangedDeps(pkgSnapshots, dryRun);
1013
+ finally {
1014
+ if (baseHandle)
1015
+ baseHandle.cleanup();
1016
+ if (preflight)
1017
+ preflight.releaseLock();
1084
1018
  }
1085
1019
  });
1086
1020
  }