@haposoft/cafekit 0.9.7 → 0.10.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.
package/bin/install.js CHANGED
@@ -1,1297 +1,136 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * CafeKit Installer
5
- * Multi-platform installer for AI coding assistants
4
+ * CafeKit Installer (orchestrator)
6
5
  *
7
- * Supported platforms (each install is fully self-contained):
8
- * - claude: Claude Code → .claude/ (skills, rules, scripts, references, runtime.json)
9
- * - opencode: OpenCode → .opencode/ (same layout, content rewritten on copy)
6
+ * Thin coordinator that runs the install as a sequence of phase handlers. Each
7
+ * phase receives and returns the run context (`ctx`). Heavy logic lives in
8
+ * bin/phases/* and bin/lib/*. Terminal UX is rendered via ctx.ui (clack in an
9
+ * interactive TTY; plain logs when piped/CI/--yes).
10
10
  *
11
- * Selecting both creates both folders independently. There is no shared
12
- * runtime directory between platforms.
13
- *
14
- * To add a new platform:
15
- * 1. Add to PLATFORMS registry below
16
- * 2. Add platform-specific commands in src/[platform]/commands/
17
- * 3. Update detectPlatforms() if using different folder names
11
+ * Safety features:
12
+ * - Process lock (lib/lock) — refuses to run concurrently.
13
+ * - Pre-run snapshot (lib/backup) — rolls back on a mid-install crash.
14
+ * - Ownership manifest (lib/manifest) — selective update; preserves user edits.
15
+ * - --dry-run — preview only, no filesystem changes.
18
16
  */
19
17
 
20
- const fs = require('fs');
21
- const path = require('path');
22
- const os = require('os');
23
- const readline = require('readline');
24
- const { execSync } = require('child_process');
25
- const packageJson = require('../package.json');
26
- const { copyRecursive, isTextAsset, readJsonFile } = require('./lib/copy-utils');
27
- const {
28
- normalizeOpenCodeBody,
29
- getOpenCodeCopyOptions,
30
- convertOpenCodeAgentContent,
31
- convertOpenCodeCommandContent,
32
- copyOpenCodeAgentsMdFile,
33
- copyOpenCodeSharedRuntimeFiles,
34
- copyOpenCodePlugins,
35
- copyOpenCodeCommandTemplates,
36
- mergeOpenCodeConfig,
37
- setupOpenCodeModel
38
- } = require('./lib/opencode-install');
39
- const INSTALL_COMMAND = `npx ${packageJson.name}@${packageJson.version}`;
40
-
41
- function validateManifestV2(manifest) {
42
- if (!manifest || manifest.version !== 2) return false;
43
- if (!manifest.runtime?.files || !Array.isArray(manifest.runtime.files)) return false;
44
- if (!manifest.settings?.template) return false;
45
- return true;
46
- }
47
-
48
- function loadClaudeMigrationManifest() {
49
- const manifestPath = path.join(__dirname, '../src/claude/migration-manifest.json');
50
-
51
- if (!fs.existsSync(manifestPath)) {
52
- return null;
53
- }
54
-
55
- try {
56
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
57
-
58
- if (!validateManifestV2(manifest)) {
59
- console.error('✗ Invalid manifest v2 schema - missing required fields');
60
- console.error(' Expected: version=2, runtime.files[], settings.template');
61
- process.exit(1);
62
- }
63
-
64
- return manifest;
65
- } catch (error) {
66
- console.warn(`⚠ Failed to parse Claude migration manifest: ${error.message}`);
67
- return null;
68
- }
69
- }
70
-
71
- const CLAUDE_MIGRATION_MANIFEST = loadClaudeMigrationManifest();
72
-
73
- const DEPENDENCY_TEMPLATES = {
74
- commands: {
75
- claude: {},
76
- opencode: {}
77
- },
78
- agents: {
79
- claude: {},
80
- opencode: {}
81
- }
82
- };
83
-
84
- // ═══════════════════════════════════════════════════════════
85
- // PLATFORM REGISTRY - Add new platforms here
86
- // ═══════════════════════════════════════════════════════════
87
- const PLATFORMS = {
88
- claude: {
89
- id: 'claude',
90
- name: 'Claude Code',
91
- description: 'Anthropic\'s Claude Code CLI',
92
- folder: '.claude',
93
- commandsDir: '.claude/commands',
94
- skillsDir: '.claude/skills',
95
- agentsDir: '.claude/agents',
96
- skillsRef: '.claude/skills',
97
- commandPrefix: '/',
98
- sourceDir: 'claude', // Maps to src/claude/
99
- sourceSubdir: 'commands' // Source subfolder within src/claude/
100
- },
101
- opencode: {
102
- id: 'opencode',
103
- name: 'OpenCode',
104
- description: 'OpenCode terminal AI coding agent',
105
- folder: '.opencode',
106
- detectFiles: ['.opencode', 'opencode.json', 'opencode.jsonc'],
107
- commandsDir: '.opencode/commands',
108
- skillsDir: '.opencode/skills',
109
- agentsDir: '.opencode/agents',
110
- skillsRef: '.opencode/skills',
111
- commandPrefix: '/',
112
- sourceDir: 'claude',
113
- sourceSubdir: 'archive-command'
114
- }
115
- // Add new platforms here:
116
- // cursor: {
117
- // id: 'cursor',
118
- // name: 'Cursor',
119
- // description: 'Cursor IDE',
120
- // folder: '.cursor',
121
- // commandsDir: '.cursor/commands',
122
- // sourceDir: 'cursor',
123
- // sourceSubdir: 'commands'
124
- // }
125
- };
126
-
127
- // ═══════════════════════════════════════════════════════════
128
- // DETECTION
129
- // ═══════════════════════════════════════════════════════════
130
-
131
- function detectPlatforms() {
132
- const detected = [];
133
-
134
- for (const [key, config] of Object.entries(PLATFORMS)) {
135
- const markers = Array.isArray(config.detectFiles) ? config.detectFiles : [config.folder];
136
- if (markers.some(marker => fs.existsSync(marker))) {
137
- detected.push(key);
138
- }
139
- }
140
-
141
- return detected;
142
- }
143
-
144
- function formatPlatformList() {
145
- return Object.entries(PLATFORMS)
146
- .map(([key, config], index) => {
147
- return `${index + 1}) ${config.name} (${config.folder}/)\n ${config.description}`;
148
- })
149
- .join('\n');
150
- }
151
-
152
- function getPlatformKeys() {
153
- return Object.keys(PLATFORMS);
154
- }
155
-
156
- // Warn opencode-only installs when a legacy .claude/ folder remains from
157
- // pre-0.9.0 installs (when OpenCode shared the .claude/ runtime). We do not
158
- // delete anything; user must remove it explicitly to opt in.
159
- function warnLegacyClaudeFolder(platforms) {
160
- if (!platforms.includes('opencode')) return;
161
- if (platforms.includes('claude')) return;
162
- if (!fs.existsSync('.claude')) return;
163
-
164
- console.log('');
165
- console.log('⚠ Legacy .claude/ folder detected.');
166
- console.log(' As of CafeKit 0.9.0, OpenCode installs are self-contained under .opencode/.');
167
- console.log(' The .claude/ folder from older installs is no longer used by OpenCode.');
168
- console.log(' After this install finishes you can remove it: rm -rf .claude');
169
- console.log('');
170
- }
171
-
172
- function isClaudeCompatibleRuntime(platformKey) {
173
- return platformKey === 'claude' || platformKey === 'opencode';
174
- }
175
-
176
- function getRuntimeSupportTargetDir(platformKey, subdir) {
177
- return path.join(PLATFORMS[platformKey].folder, subdir);
178
- }
179
-
180
- function parseInstallerArgs(argv) {
181
- const args = {
182
- upgrade: false
183
- };
184
-
185
- for (let i = 2; i < argv.length; i++) {
186
- const arg = argv[i];
187
-
188
- if (arg === '--upgrade' || arg === '-u' || arg === '--force' || arg === '-f') {
189
- args.upgrade = true;
190
- }
191
- }
192
-
193
- return args;
194
- }
195
-
196
- function getCopyOptions(platformKey, baseOptions = {}) {
197
- if (platformKey === 'opencode') {
198
- return getOpenCodeCopyOptions(baseOptions);
199
- }
200
- return baseOptions;
201
- }
202
-
203
- // ═══════════════════════════════════════════════════════════
204
- // USER INTERACTION
205
- // ═══════════════════════════════════════════════════════════
206
-
207
- async function promptPlatformSelection() {
208
- const rl = readline.createInterface({
209
- input: process.stdin,
210
- output: process.stdout
211
- });
212
-
213
- const maxChoice = Object.keys(PLATFORMS).length + 1;
214
-
215
- console.log('╔════════════════════════════════════════════════════════╗');
216
- console.log('║ CafeKit - Platform Selection ║');
217
- console.log('╚════════════════════════════════════════════════════════╝');
218
- console.log();
219
- console.log('No existing AI editor configuration detected.\n');
220
- console.log('Select which platform(s) to install for:\n');
221
- console.log(formatPlatformList());
222
- console.log(`${maxChoice}) All platforms`);
223
- console.log('0) Cancel');
224
- console.log();
225
-
226
- return new Promise((resolve) => {
227
- rl.question(`Select (0-${maxChoice}): `, (answer) => {
228
- rl.close();
229
- const choice = parseInt(answer.trim(), 10);
230
-
231
- if (choice === 0 || isNaN(choice)) {
232
- resolve([]);
233
- } else if (choice === maxChoice) {
234
- resolve(getPlatformKeys());
235
- } else if (choice >= 1 && choice <= Object.keys(PLATFORMS).length) {
236
- resolve([getPlatformKeys()[choice - 1]]);
237
- } else {
238
- console.log('Invalid selection. Please run the installer again.');
239
- resolve([]);
240
- }
241
- });
242
- });
243
- }
244
-
245
- async function promptMultiPlatformConfirm(detected) {
246
- const rl = readline.createInterface({
247
- input: process.stdin,
248
- output: process.stdout
249
- });
250
-
251
- const platformNames = detected.map(key => PLATFORMS[key].name).join(', ');
252
-
253
- console.log(`\nDetected existing configurations: ${platformNames}`);
254
- console.log();
255
-
256
- return new Promise((resolve) => {
257
- rl.question('Install for all detected platforms? (Y/n): ', (answer) => {
258
- rl.close();
259
- const response = answer.trim().toLowerCase();
260
- resolve(response === '' || response === 'y' || response === 'yes');
261
- });
262
- });
263
- }
18
+ const { buildContext, PLATFORMS, packageJson } = require('./lib/context');
19
+ const lock = require('./lib/lock');
20
+ const backup = require('./lib/backup');
21
+ const manifestLib = require('./lib/manifest');
264
22
 
265
- // ═══════════════════════════════════════════════════════════
266
- // FILE OPERATIONS
267
- // ═══════════════════════════════════════════════════════════
268
-
269
- function ensureDependencyFile(targetPath, content, results, label, options = {}) {
270
- const destinationExists = fs.existsSync(targetPath);
271
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
272
-
273
- if (destinationExists && !shouldOverwriteManagedFiles) {
274
- console.log(` → Dependency exists: ${label}`);
275
- results.dependencyChecks++;
276
- return;
277
- }
278
-
279
- if (!content) {
280
- console.log(` ⚠ Missing dependency template: ${label}`);
281
- results.dependencyChecks++;
282
- results.missingDependencies++;
283
- return;
284
- }
285
-
286
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
287
- fs.writeFileSync(targetPath, content, 'utf8');
288
-
289
- if (destinationExists && shouldOverwriteManagedFiles) {
290
- console.log(` ↻ Dependency updated: ${label}`);
291
- results.updated++;
292
- } else {
293
- console.log(` ✓ Dependency installed: ${label}`);
294
- }
295
-
296
- results.dependencyChecks++;
297
- results.installedDependencies++;
298
- }
299
-
300
- function ensureWorkflowDependencies(platformKey, platform, results, options = {}) {
301
- const commandTemplates = DEPENDENCY_TEMPLATES.commands[platformKey] || {};
302
- Object.entries(commandTemplates).forEach(([fileName, content]) => {
303
- const targetPath = path.join(platform.commandsDir, fileName);
304
- ensureDependencyFile(targetPath, content, results, path.join(platform.commandsDir, fileName), options);
305
- });
306
-
307
- const agentTemplates = DEPENDENCY_TEMPLATES.agents[platformKey] || {};
308
- Object.entries(agentTemplates).forEach(([fileName, content]) => {
309
- const targetPath = path.join(platform.agentsDir, fileName);
310
- ensureDependencyFile(targetPath, content, results, path.join(platform.agentsDir, fileName), options);
311
- });
312
- }
313
-
314
- function writePlatformVersionMetadata(platformKey, results) {
23
+ const { resolvePlatforms } = require('./phases/select-platform');
24
+ const { copyPlatformFiles } = require('./phases/copy-payload');
25
+ const {
26
+ copyRoutingFile,
27
+ copyClaudeRuntimeFiles,
28
+ removeObsoleteClaudeRuntimeFiles,
29
+ copyClaudeMdFile,
30
+ copyRulesDirectory
31
+ } = require('./phases/claude-runtime');
32
+ const { mergeClaudeSettings } = require('./phases/claude-settings');
33
+ const { installOpenCodeRuntime } = require('./phases/opencode-runtime');
34
+ const { writePlatformVersionMetadata } = require('./phases/write-metadata');
35
+ const { ensureGitignore } = require('./phases/root-config');
36
+ const { runPostInstall } = require('./phases/post-install');
37
+ const { printSummary } = require('./phases/summary');
38
+
39
+ /** Install a single platform: payload + runtime + metadata, under one spinner. */
40
+ function installPlatform(ctx, platformKey) {
315
41
  const platform = PLATFORMS[platformKey];
316
- const targetPath = path.join(platform.folder, 'cafekit.json');
317
- const targetExists = fs.existsSync(targetPath);
318
- const existingMetadata = readJsonFile(targetPath);
319
- const now = new Date().toISOString();
320
- const previousVersion = typeof existingMetadata.version === 'string'
321
- ? existingMetadata.version
322
- : null;
323
42
 
324
- const metadata = {
325
- schemaVersion: 1,
326
- packageName: packageJson.name,
327
- version: packageJson.version,
328
- platform: platform.id,
329
- platformName: platform.name,
330
- installedAt: existingMetadata.installedAt || now,
331
- lastInstalledAt: now,
332
- installCommand: INSTALL_COMMAND
333
- };
43
+ // Read ownership baseline + start a fresh tracker for this platform.
44
+ ctx.ownership[platform.folder] = manifestLib.read(platform.folder);
45
+ ctx.trackers[platformKey] = manifestLib.createTracker(platform.folder, packageJson.version);
334
46
 
335
- if (previousVersion && previousVersion !== packageJson.version) {
336
- metadata.previousVersion = previousVersion;
337
- }
47
+ const before = { copied: ctx.results.copied, updated: ctx.results.updated, skills: ctx.results.installedSkills };
48
+ ctx.ui.startSpinner(`Installing ${platform.name} (${platform.folder}/)`);
338
49
 
339
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
340
- fs.writeFileSync(targetPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
50
+ copyPlatformFiles(ctx, platformKey);
341
51
 
342
- if (targetExists) {
343
- console.log(` ↻ Version metadata updated: ${targetPath}`);
344
- results.updated++;
345
- } else {
346
- console.log(` ✓ Version metadata installed: ${targetPath}`);
347
- results.copied++;
348
- }
349
- }
350
-
351
- function getPlatformSpecFiles(platformKey) {
352
52
  if (platformKey === 'claude') {
353
- const manifestCommands = CLAUDE_MIGRATION_MANIFEST?.commands?.core;
354
- if (Array.isArray(manifestCommands)) {
355
- return manifestCommands;
356
- }
357
-
358
- return [
359
- 'spec-init.md',
360
- 'spec-requirements.md',
361
- 'spec-design.md',
362
- 'spec-validate.md',
363
- 'spec-tasks.md',
364
- 'spec-status.md',
365
- 'code.md',
366
- 'test.md',
367
- 'docs.md'
368
- ];
53
+ copyRoutingFile(ctx, platformKey);
54
+ copyClaudeRuntimeFiles(ctx, platformKey);
55
+ removeObsoleteClaudeRuntimeFiles(ctx, platformKey);
56
+ mergeClaudeSettings(ctx, platformKey);
57
+ copyClaudeMdFile(ctx, platformKey);
58
+ copyRulesDirectory(ctx, platformKey);
369
59
  }
370
60
 
371
61
  if (platformKey === 'opencode') {
372
- return [];
62
+ installOpenCodeRuntime(ctx, platformKey);
373
63
  }
374
64
 
375
- return [];
376
- }
377
-
378
- function copyPlatformFiles(platformKey, results, options = {}) {
379
- const platform = PLATFORMS[platformKey];
380
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
381
- const recordWriteResult = (didOverwrite) => {
382
- if (didOverwrite) {
383
- results.updated++;
384
- } else {
385
- results.copied++;
386
- }
387
- };
388
-
389
- // Source directories - support different subfolder names per platform
390
- const sourceSubdir = platform.sourceSubdir || 'commands';
391
- const commandsSourceDir = path.join(__dirname, `../src/${platform.sourceDir}/${sourceSubdir}`);
392
- const skillsSourceDir = path.join(__dirname, '../src/claude/skills');
393
- const agentsSourceDir = path.join(__dirname, `../src/${platform.sourceDir}/agents`);
394
-
395
- // Create directories
396
- // commandsDir is created on-demand per platform: Claude only mkdirs when
397
- // getPlatformSpecFiles returns files; OpenCode mkdirs inside
398
- // copyOpenCodeCommandTemplates. Skip the unconditional mkdir to avoid
399
- // leaving empty commands/ folders behind.
400
- if (!fs.existsSync(platform.skillsDir)) {
401
- fs.mkdirSync(platform.skillsDir, { recursive: true });
402
- }
403
- if (!fs.existsSync(platform.agentsDir)) {
404
- fs.mkdirSync(platform.agentsDir, { recursive: true });
405
- }
406
-
407
- // Copy skills (shared across all platforms)
408
- if (fs.existsSync(skillsSourceDir)) {
409
- const specSkillSource = path.join(skillsSourceDir, 'specs');
410
- const specSkillDest = path.join(platform.skillsDir, 'specs');
411
-
412
- if (fs.existsSync(specSkillSource)) {
413
- const skillExisted = fs.existsSync(specSkillDest);
414
- copyRecursive(specSkillSource, specSkillDest, getCopyOptions(platformKey, options));
415
- results.installedSkills++;
416
-
417
- if (shouldOverwriteManagedFiles && skillExisted) {
418
- console.log(` ↻ Skill updated: specs`);
419
- results.updated++;
420
- } else {
421
- console.log(` ✓ Skill installed: specs`);
422
- }
423
- }
424
-
425
- // Keep templates in sync for claude command runtime copies under .claude/skills/specs
426
- if (platformKey === 'claude') {
427
- const legacyInitTemplate = path.join(platform.skillsDir, 'specs', 'templates', 'init.json');
428
- if (fs.existsSync(legacyInitTemplate)) {
429
- fs.rmSync(legacyInitTemplate, { force: true });
430
- console.log(` ↻ Removed legacy template: ${legacyInitTemplate}`);
431
- results.updated++;
432
- }
433
-
434
- const specTemplates = [
435
- 'spec-state.json',
436
- 'requirements-init.md',
437
- 'requirements.md',
438
- 'design.md',
439
- 'research.md',
440
- 'task.md'
441
- ];
442
-
443
- specTemplates.forEach((fileName) => {
444
- const source = path.join(specSkillSource, 'templates', fileName);
445
- const dest = path.join(platform.skillsDir, 'specs', 'templates', fileName);
446
-
447
- if (!fs.existsSync(source)) {
448
- console.log(` ⚠ Missing dependency template: ${path.join(platform.skillsDir, 'specs', 'templates', fileName)}`);
449
- results.missingDependencies++;
450
- results.dependencyChecks++;
451
- return;
452
- }
453
-
454
- const destinationExists = fs.existsSync(dest);
455
-
456
- if (destinationExists && !shouldOverwriteManagedFiles) {
457
- console.log(` → Dependency exists: ${path.join(platform.skillsDir, 'specs', 'templates', fileName)}`);
458
- results.dependencyChecks++;
459
- return;
460
- }
65
+ writePlatformVersionMetadata(ctx, platformKey);
461
66
 
462
- fs.mkdirSync(path.dirname(dest), { recursive: true });
463
- fs.copyFileSync(source, dest);
464
- results.dependencyChecks++;
465
- results.installedDependencies++;
466
-
467
- if (destinationExists && shouldOverwriteManagedFiles) {
468
- console.log(` ↻ Dependency updated: ${path.join(platform.skillsDir, 'specs', 'templates', fileName)}`);
469
- results.updated++;
470
- } else {
471
- console.log(` ✓ Dependency installed: ${path.join(platform.skillsDir, 'specs', 'templates', fileName)}`);
472
- }
473
- });
474
- }
475
-
476
- // Copy additional required skills based on platform
477
- let requiredSkills = [];
478
- if (isClaudeCompatibleRuntime(platformKey)) {
479
- requiredSkills = CLAUDE_MIGRATION_MANIFEST?.skills?.required || [];
480
- }
481
-
482
- requiredSkills
483
- .filter((skillName) => skillName !== 'specs')
484
- .forEach((skillName) => {
485
- const skillSource = path.join(skillsSourceDir, skillName);
486
- const skillDest = path.join(platform.skillsDir, skillName);
487
-
488
- if (fs.existsSync(skillSource)) {
489
- const skillExisted = fs.existsSync(skillDest);
490
- copyRecursive(skillSource, skillDest, getCopyOptions(platformKey, options));
491
- results.installedSkills++;
492
-
493
- if (shouldOverwriteManagedFiles && skillExisted) {
494
- console.log(` ↻ Skill updated: ${skillName}`);
495
- results.updated++;
496
- } else {
497
- console.log(` ✓ Skill installed: ${skillName}`);
498
- }
499
- } else {
500
- results.missingDependencies++;
501
- console.log(` ⚠ Missing dependency template: ${path.join(platform.skillsDir, skillName)}`);
502
- }
503
- });
504
- }
505
-
506
- // Copy agents
507
- if (isClaudeCompatibleRuntime(platformKey)) {
508
- if (fs.existsSync(agentsSourceDir)) {
509
- const requiredAgents = CLAUDE_MIGRATION_MANIFEST?.agents?.required || [
510
- 'tester.md',
511
- 'code-reviewer.md',
512
- 'fullstack-developer.md',
513
- 'debugger.md'
514
- ];
515
-
516
- requiredAgents.forEach((fileName) => {
517
- const source = path.join(agentsSourceDir, fileName);
518
- const dest = path.join(platform.agentsDir, fileName);
519
-
520
- if (!fs.existsSync(source)) {
521
- console.log(` ⚠ Missing dependency template: ${path.join(platform.agentsDir, fileName)}`);
522
- results.missingDependencies++;
523
- results.dependencyChecks++;
524
- return;
525
- }
526
-
527
- const destinationExists = fs.existsSync(dest);
528
-
529
- if (destinationExists && !shouldOverwriteManagedFiles) {
530
- console.log(` → Dependency exists: ${path.join(platform.agentsDir, fileName)}`);
531
- results.dependencyChecks++;
532
- return;
533
- }
534
-
535
- fs.mkdirSync(path.dirname(dest), { recursive: true });
536
- if (platformKey === 'opencode') {
537
- const content = fs.readFileSync(source, 'utf8');
538
- fs.writeFileSync(dest, convertOpenCodeAgentContent(content, fileName), 'utf8');
539
- } else {
540
- fs.copyFileSync(source, dest);
541
- }
542
- results.dependencyChecks++;
543
- results.installedDependencies++;
544
-
545
- if (destinationExists && shouldOverwriteManagedFiles) {
546
- console.log(` ↻ Dependency updated: ${path.join(platform.agentsDir, fileName)}`);
547
- results.updated++;
548
- } else {
549
- console.log(` ✓ Dependency installed: ${path.join(platform.agentsDir, fileName)}`);
550
- }
551
- });
552
-
553
- // Copy agent reference manuals (debugger manuals, etc.) recursively
554
- const refsSource = path.join(__dirname, '../src', platform.sourceDir, 'references');
555
- if (fs.existsSync(refsSource)) {
556
- const refsDest = getRuntimeSupportTargetDir(platformKey, 'references');
557
- const refsExisted = fs.existsSync(refsDest);
558
- copyRecursive(refsSource, refsDest, getCopyOptions(platformKey, options));
559
-
560
- if (shouldOverwriteManagedFiles && refsExisted) {
561
- console.log(` ↻ Agent reference manuals updated`);
562
- results.updated++;
563
- } else {
564
- console.log(` ✓ Agent reference manuals installed`);
565
- results.copied++;
566
- }
567
- }
568
-
569
- } else {
570
- results.missingDependencies++;
571
- console.log(` ⚠ Missing dependency template: ${platform.agentsDir}`);
572
- }
67
+ const wrote = (ctx.results.copied - before.copied) + (ctx.results.updated - before.updated);
68
+ const verb = ctx.dryRun ? 'would install' : 'installed';
69
+ ctx.ui.stopSpinner(`${platform.name} ${verb} — ${wrote} file(s), ${ctx.results.installedSkills - before.skills} skill(s)`);
573
70
 
574
- // Copy native scripts (browser-tool, docs-fetch, validate-docs)
575
- const scriptsSourceDir = path.join(__dirname, '../src/claude/scripts');
576
- if (fs.existsSync(scriptsSourceDir)) {
577
- const scriptsDest = getRuntimeSupportTargetDir(platformKey, 'scripts');
578
- const scriptsExisted = fs.existsSync(scriptsDest);
579
- copyRecursive(scriptsSourceDir, scriptsDest, getCopyOptions(platformKey, options));
580
-
581
- if (shouldOverwriteManagedFiles && scriptsExisted) {
582
- console.log(` ↻ Native scripts updated`);
583
- results.updated++;
584
- } else {
585
- console.log(` ✓ Native scripts installed`);
586
- results.copied++;
587
- }
588
- }
589
-
590
- }
591
-
592
- ensureWorkflowDependencies(platformKey, platform, results, options);
593
-
594
- // Copy commands/workflows
595
- const specFiles = getPlatformSpecFiles(platformKey);
596
-
597
- specFiles.forEach(file => {
598
- const source = path.join(commandsSourceDir, file);
599
- const dest = path.join(platform.commandsDir, file);
600
- const destinationExists = fs.existsSync(dest);
601
-
602
- if (!fs.existsSync(source)) {
603
- console.error(` ✗ Error: Source file not found: ${file}`);
604
- results.errors++;
605
- return;
606
- }
607
-
608
- if (destinationExists && !shouldOverwriteManagedFiles) {
609
- console.log(` → Skipped: ${file} (already exists)`);
610
- results.skipped++;
611
- return;
612
- }
613
-
614
- fs.mkdirSync(path.dirname(dest), { recursive: true });
615
- let content = fs.readFileSync(source, 'utf8');
616
- content = content.replace(/\{\{SKILLS_DIR\}\}/g, platform.skillsRef);
617
- if (platformKey === 'opencode') {
618
- content = convertOpenCodeCommandContent(content, file);
619
- }
620
- fs.writeFileSync(dest, content);
621
-
622
- if (destinationExists && shouldOverwriteManagedFiles) {
623
- console.log(` ↻ Updated: ${file}`);
624
- recordWriteResult(true);
625
- } else {
626
- console.log(` ✓ Copied: ${file}`);
627
- recordWriteResult(false);
628
- }
629
- });
630
- }
631
-
632
- // Copy ROUTING.md to .claude/
633
- function copyRoutingFile(platformKey, results, options = {}) {
634
- const platform = PLATFORMS[platformKey];
635
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
636
- const source = path.join(__dirname, `../src/${platform.sourceDir}/ROUTING.md`);
637
- const dest = path.join(platform.folder, 'ROUTING.md');
638
-
639
- if (fs.existsSync(source)) {
640
- const destinationExists = fs.existsSync(dest);
641
-
642
- if (destinationExists && !shouldOverwriteManagedFiles) {
643
- console.log(` → Skipped: ROUTING.md (already exists)`);
644
- results.skipped++;
645
- return;
646
- }
647
-
648
- let content = fs.readFileSync(source, 'utf8');
649
- content = content.replace(/\{\{SKILLS_DIR\}\}/g, platform.skillsRef);
650
- if (platformKey === 'opencode') {
651
- content = normalizeOpenCodeBody(content);
652
- }
653
- fs.writeFileSync(dest, content);
654
-
655
- if (destinationExists && shouldOverwriteManagedFiles) {
656
- console.log(` ↻ Updated: ROUTING.md`);
657
- results.updated++;
658
- } else {
659
- console.log(` ✓ Copied: ROUTING.md`);
660
- results.copied++;
661
- }
662
- }
663
- }
664
-
665
- // Copy Claude runtime files (statusline bundle)
666
- function copyClaudeRuntimeFiles(platformKey, results, options = {}) {
667
- if (platformKey !== 'claude') return;
668
-
669
- const manifest = CLAUDE_MIGRATION_MANIFEST;
670
- if (!manifest?.runtime?.files) return;
671
-
672
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
673
- const srcBase = path.join(__dirname, '../src/claude');
674
- const targetBase = path.join(PLATFORMS.claude.folder);
675
-
676
- manifest.runtime.files.forEach(relPath => {
677
- const srcPath = path.join(srcBase, relPath);
678
- const targetRelPath = relPath === 'gitignore' ? '.gitignore' : relPath;
679
- const targetPath = path.join(targetBase, targetRelPath);
680
-
681
- if (!fs.existsSync(srcPath)) {
682
- console.log(` ⚠ Runtime file not found: ${relPath}`);
683
- results.missingDependencies++;
684
- return;
685
- }
686
-
687
- const targetExists = fs.existsSync(targetPath);
688
- const shouldCopy = shouldOverwriteManagedFiles || !targetExists;
689
-
690
- if (shouldCopy) {
691
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
692
- fs.copyFileSync(srcPath, targetPath);
693
-
694
- if (targetExists) {
695
- console.log(` ↻ Runtime updated: ${targetRelPath}`);
696
- results.updated++;
697
- } else {
698
- console.log(` ✓ Runtime installed: ${targetRelPath}`);
699
- results.copied++;
700
- }
701
- } else {
702
- results.skipped++;
703
- }
704
- });
71
+ ctx.results.targets.push(platform.commandsDir);
705
72
  }
706
73
 
707
- function removeObsoleteClaudeRuntimeFiles(platformKey, results) {
708
- if (platformKey !== 'claude') return;
709
-
710
- const obsoleteFiles = CLAUDE_MIGRATION_MANIFEST?.obsolete?.runtimeFiles || [];
711
- const targetBase = path.join(PLATFORMS.claude.folder);
712
-
713
- obsoleteFiles.forEach(relPath => {
714
- const targetPath = path.join(targetBase, relPath);
715
-
716
- if (!fs.existsSync(targetPath)) {
717
- return;
718
- }
719
-
720
- fs.rmSync(targetPath, { force: true });
721
- console.log(` ↻ Removed obsolete runtime: ${relPath}`);
722
- results.updated++;
723
- });
724
- }
725
-
726
- function pruneObsoleteSettingsHooks(settings, results) {
727
- const obsoleteSubstrings = CLAUDE_MIGRATION_MANIFEST?.obsolete?.settingsHookCommandSubstrings || [];
728
-
729
- if (!settings.hooks || obsoleteSubstrings.length === 0) {
730
- return settings;
731
- }
732
-
733
- let removedCount = 0;
734
- const prunedHooks = {};
735
-
736
- Object.entries(settings.hooks).forEach(([eventName, matcherEntries]) => {
737
- if (!Array.isArray(matcherEntries)) {
738
- prunedHooks[eventName] = matcherEntries;
739
- return;
740
- }
741
-
742
- const prunedEntries = [];
743
-
744
- matcherEntries.forEach(entry => {
745
- if (!Array.isArray(entry?.hooks)) {
746
- prunedEntries.push(entry);
747
- return;
748
- }
749
-
750
- const remainingHooks = entry.hooks.filter(hook => {
751
- const command = hook?.command || '';
752
- const isObsolete = obsoleteSubstrings.some(substring => command.includes(substring));
753
-
754
- if (isObsolete) {
755
- removedCount++;
756
- }
757
-
758
- return !isObsolete;
759
- });
760
-
761
- if (remainingHooks.length > 0) {
762
- prunedEntries.push({ ...entry, hooks: remainingHooks });
763
- }
764
- });
765
-
766
- if (prunedEntries.length > 0) {
767
- prunedHooks[eventName] = prunedEntries;
768
- }
769
- });
770
-
771
- if (removedCount > 0) {
772
- console.log(` ↻ Settings: removed ${removedCount} obsolete hook(s)`);
773
- results.updated++;
774
- return { ...settings, hooks: prunedHooks };
775
- }
776
-
777
- return settings;
778
- }
779
-
780
- // Merge Claude settings.json
781
- function mergeClaudeSettings(platformKey, results, options = {}) {
782
- if (platformKey !== 'claude') return;
783
-
784
- const manifest = CLAUDE_MIGRATION_MANIFEST;
785
- if (!manifest?.settings?.template) return;
786
-
787
- const templatePath = path.join(__dirname, '../src/claude', manifest.settings.template);
788
- const targetPath = path.join(PLATFORMS.claude.folder, 'settings.json');
789
-
790
- if (!fs.existsSync(templatePath)) {
791
- console.log(` ⚠ Settings template not found: ${manifest.settings.template}`);
792
- return;
793
- }
794
-
795
- const managedSettings = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
796
- let existingSettings = {};
797
-
798
- if (fs.existsSync(targetPath)) {
799
- existingSettings = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
800
- }
801
-
802
- const mergedSettings = pruneObsoleteSettingsHooks({ ...existingSettings }, results);
803
-
804
- // Merge statusLine
805
- if (managedSettings.statusLine) {
806
- const existingCommand = existingSettings.statusLine?.command || '';
807
- const isCafeKitOwned = existingCommand.includes('status.cjs') || existingCommand.includes('statusline.cjs');
808
-
809
- if (options.upgrade || !existingSettings.statusLine || isCafeKitOwned) {
810
- mergedSettings.statusLine = managedSettings.statusLine;
811
- console.log(` ✓ Settings: statusLine merged`);
812
- }
813
- }
814
-
815
- // Merge hooks
816
- if (managedSettings.hooks) {
817
- mergedSettings.hooks = mergedSettings.hooks || {};
818
-
819
- Object.keys(managedSettings.hooks).forEach(eventName => {
820
- const managedHooks = managedSettings.hooks[eventName];
821
- const existingHooks = mergedSettings.hooks[eventName] || [];
822
- const mergedHooks = [...existingHooks];
823
-
824
- managedHooks.forEach(managedHook => {
825
- const managedCommand = managedHook.hooks?.[0]?.command || '';
826
- const isDuplicate = mergedHooks.some(existingHook => {
827
- return existingHook.hooks?.some(h => h.command === managedCommand);
828
- });
829
-
830
- if (!isDuplicate) {
831
- mergedHooks.push(managedHook);
832
- console.log(` ✓ Settings: hook ${eventName} merged`);
833
- }
834
- });
835
-
836
- mergedSettings.hooks[eventName] = mergedHooks;
837
- });
838
- }
839
-
840
- fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
841
- }
842
-
843
- // Copy CLAUDE.md template (always overwrite at Project Root)
844
- function copyClaudeMdFile(platformKey, results, options = {}) {
845
- if (platformKey !== 'claude') return;
846
-
847
- const source = path.join(__dirname, '../src/claude/CLAUDE.md');
848
- const dest = 'CLAUDE.md'; // Xuất thẳng ra thư mục ROOT thay vì nhét vào trong .claude/
849
-
850
- if (fs.existsSync(source)) {
851
- const destinationExists = fs.existsSync(dest);
852
-
853
- fs.mkdirSync(path.dirname(dest), { recursive: true });
854
- fs.copyFileSync(source, dest);
855
-
856
- if (destinationExists) {
857
- console.log(` ↻ CLAUDE.md overwritten`);
858
- results.updated++;
859
- } else {
860
- console.log(` ✓ CLAUDE.md installed`);
861
- results.copied++;
862
- }
863
- } else {
864
- console.log(` ⚠ CLAUDE.md template not found`);
865
- results.missingDependencies++;
866
- }
867
- }
868
-
869
- // Copy rules directory (always overwrite)
870
- function copyRulesDirectory(platformKey, results, options = {}) {
871
- if (!isClaudeCompatibleRuntime(platformKey)) return;
872
-
873
- const source = path.join(__dirname, '../src/claude/rules');
874
- const dest = getRuntimeSupportTargetDir(platformKey, 'rules');
875
-
876
- if (fs.existsSync(source)) {
877
- copyRecursive(source, dest, getCopyOptions(platformKey, { upgrade: true }));
878
- console.log(` ↻ rules/ directory overwritten`);
879
- results.updated++;
880
- } else {
881
- console.log(` ⚠ rules/ directory not found`);
882
- results.missingDependencies++;
883
- }
884
- }
885
-
886
- // Ensure .gitignore configured at root
887
- function ensureGitignore(results, options = {}) {
888
- const gitignorePath = path.join(process.cwd(), '.gitignore');
889
- const header = '# CafeKit / Ecosystem';
890
- const patterns = [
891
- 'specs/_shared/',
892
- 'plans/',
893
- '!plans/templates/'
894
- ];
895
-
896
- if (!fs.existsSync(gitignorePath)) {
897
- const content = ['# Git Ignore', '', header, ...patterns, ''].join('\n');
898
- fs.writeFileSync(gitignorePath, content, 'utf8');
899
- console.log(' ✓ .gitignore created at root');
900
- results.copied++;
901
- return;
902
- }
903
-
904
- const content = fs.readFileSync(gitignorePath, 'utf8');
905
- const lines = content.split('\n').map(l => l.trim());
906
- const missing = patterns.filter(p => !lines.includes(p));
907
-
908
- if (missing.length > 0) {
909
- let newContent = content;
910
- if (!newContent.endsWith('\n')) newContent += '\n';
911
- if (!content.includes(header)) newContent += `\n${header}\n`;
912
-
913
- newContent += missing.join('\n') + '\n';
914
- fs.writeFileSync(gitignorePath, newContent, 'utf8');
915
- console.log(` ↻ .gitignore updated: added ${missing.join(', ')}`);
916
- results.updated++;
917
- } else {
918
- console.log(' → .gitignore already up to date');
919
- results.skipped++;
920
- }
921
- }
922
-
923
- // ═══════════════════════════════════════════════════════════
924
- // GEMINI CLI SETUP
925
- // ═══════════════════════════════════════════════════════════
926
-
927
- function checkGeminiCLI() {
928
- try {
929
- execSync('which gemini', { stdio: 'ignore' });
930
- return true;
931
- } catch {
932
- return false;
933
- }
934
- }
935
-
936
- function installGeminiCLI() {
937
- console.log('\n⚙ Installing gemini-cli...');
938
- try {
939
- execSync('npm install -g @google/gemini-cli', { stdio: 'inherit' });
940
- console.log(' ✓ gemini-cli installed successfully');
941
- return true;
942
- } catch (error) {
943
- console.log(' ✗ Failed to install gemini-cli automatically');
944
- console.log(' Please run manually: npm install -g @google/gemini-cli');
945
- return false;
946
- }
947
- }
948
-
949
- async function promptInstallGemini() {
950
- const rl = readline.createInterface({
951
- input: process.stdin,
952
- output: process.stdout
953
- });
954
-
955
- console.log('\n📦 Optional: Gemini CLI Installation');
956
- console.log(' hapo:inspect with ext mode requires gemini-cli');
957
- console.log(' • Install now: Auto-install and configure API key');
958
- console.log(' • Skip: You can still use hapo:inspect in internal mode');
959
- console.log();
960
-
961
- return new Promise((resolve) => {
962
- rl.question('Install gemini-cli? (Y/n): ', (answer) => {
963
- rl.close();
964
- const response = answer.trim().toLowerCase();
965
- resolve(response === '' || response === 'y' || response === 'yes');
966
- });
967
- });
968
- }
969
-
970
- async function promptGeminiAPIKey() {
971
- const rl = readline.createInterface({
972
- input: process.stdin,
973
- output: process.stdout
974
- });
975
-
976
- console.log('\n📝 Gemini API Key Configuration');
977
- console.log(' Get your API key: https://aistudio.google.com/apikey');
978
- console.log();
979
-
980
- return new Promise((resolve) => {
981
- rl.question('Enter your Gemini API key (or press Enter to skip): ', (answer) => {
982
- rl.close();
983
- resolve(answer.trim());
984
- });
985
- });
986
- }
987
-
988
- function configureGeminiKey(apiKey, platforms = ['claude']) {
989
- const envBody = `GEMINI_API_KEY=${apiKey}\nVISUAL_MODEL=gemma-4-31b-it\nSEARCH_MODEL=gemini-2.5-pro\n`;
990
- const targets = platforms
991
- .filter(key => PLATFORMS[key])
992
- .map(key => PLATFORMS[key].folder);
993
-
994
- if (targets.length === 0) {
995
- targets.push('.claude');
996
- }
997
-
998
- let success = true;
999
- for (const folder of targets) {
1000
- try {
1001
- const targetDir = path.join(process.cwd(), folder);
1002
- const localEnvFile = path.join(targetDir, '.env');
1003
-
1004
- if (!fs.existsSync(targetDir)) {
1005
- fs.mkdirSync(targetDir, { recursive: true });
1006
- }
1007
-
1008
- fs.writeFileSync(localEnvFile, envBody, { mode: 0o600 });
1009
- console.log(` ✓ Gemini API key configured securely in project (${folder}/.env)`);
1010
- } catch (error) {
1011
- success = false;
1012
- console.log(' ✗ Failed to configure Gemini API key');
1013
- console.log(` Error: ${error.message}`);
1014
- console.log(' You can manually set: export GEMINI_API_KEY="your-key"');
1015
- }
1016
- }
1017
-
1018
- return success;
1019
- }
1020
-
1021
- async function promptAddressingConfig() {
1022
- const rl = readline.createInterface({
1023
- input: process.stdin,
1024
- output: process.stdout
1025
- });
1026
-
1027
- return new Promise((resolve) => {
1028
- rl.question('\nHow should the AI address you? (e.g. boss, sir, master - Enter to skip): ', (answer) => {
1029
- rl.close();
1030
- const userAddress = answer.trim();
1031
-
1032
- // Blank = skip, no addressing configured
1033
- if (!userAddress || userAddress.length === 0) {
1034
- resolve({ enabled: false, userAddress: '' });
1035
- return;
1036
- }
1037
-
1038
- // Validate: letters and spaces only
1039
- if (/[^a-zA-ZÀ-ỹ\s]/.test(userAddress)) {
1040
- console.log(' ⚠ Invalid input (letters only), skipping addressing setup');
1041
- resolve({ enabled: false, userAddress: '' });
1042
- return;
1043
- }
1044
-
1045
- resolve({ enabled: true, userAddress: userAddress });
1046
- });
1047
- });
1048
- }
1049
-
1050
- function configureAddressing(addressingConfig, platforms = ['claude']) {
1051
- // CLAUDE.md is installed at the project root (see copyClaudeMdFile),
1052
- // and only the Claude platform ships it. Skip if Claude isn't installed.
1053
- if (!platforms.includes('claude')) {
1054
- return;
1055
- }
1056
-
1057
- // Skip if addressing not enabled (user left blank or invalid input)
1058
- if (!addressingConfig.enabled || !addressingConfig.userAddress) {
1059
- console.log(' → Bỏ qua thiết lập xưng hô');
1060
- return;
74
+ async function main() {
75
+ // ── Concurrency guard (before context; plain output) ────
76
+ const got = lock.acquire();
77
+ if (!got.acquired) {
78
+ console.error(`✗ Another CafeKit install is in progress (pid ${got.pid}, since ${got.since}).`);
79
+ console.error(' If that process is gone, delete .cafekit.lock and retry.');
80
+ process.exit(1);
1061
81
  }
1062
82
 
1063
- const userAddress = addressingConfig.userAddress;
83
+ let ctx;
84
+ let exitCode = 0;
1064
85
 
1065
86
  try {
1066
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
87
+ ctx = buildContext(process.argv, `${Date.now()}`);
88
+ ctx.ui.intro(`${ctx.ui.pc.bgCyan(ctx.ui.pc.black(' CafeKit '))} Installer v${packageJson.version} · Multi-platform SDD`);
89
+ if (got.reclaimed) ctx.ui.info('Reclaimed a stale install lock from a dead process.');
1067
90
 
1068
- if (!fs.existsSync(claudeMdFile)) {
1069
- console.log(' ⚠ CLAUDE.md not found at project root');
1070
- return;
91
+ await resolvePlatforms(ctx);
92
+ if (ctx.cancelled) {
93
+ lock.release();
94
+ process.exit(0);
1071
95
  }
1072
96
 
1073
- let content = fs.readFileSync(claudeMdFile, 'utf8');
1074
-
1075
- // Build the addressing section (short instruction + user-chosen term)
1076
- const addressingSection = `## Addressing (Context Overflow Indicator)
1077
-
1078
- The AI always addresses the user as "${userAddress}" throughout the conversation. If the AI stops doing so, it is a sign the context has been compacted/truncated — tell the user to consider \`/clear\`.`;
1079
-
1080
- // Match the addressing section by its shared marker ("Context Overflow
1081
- // Indicator"), covering both the older Vietnamese heading and the new one,
1082
- // up to the next ## header or end of file. Keeps reinstall idempotent.
1083
- const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
1084
-
1085
- if (regex.test(content)) {
1086
- // Replace existing section
1087
- content = content.replace(regex, addressingSection);
1088
- } else {
1089
- // Append at the end
1090
- if (!content.endsWith('\n')) {
1091
- content += '\n';
1092
- }
1093
- content += '\n' + addressingSection + '\n';
97
+ // ── Pre-run snapshot for rollback ─────────────────────
98
+ // Capture platform folders AND the root files the pipeline mutates
99
+ // (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
100
+ if (!ctx.dryRun) {
101
+ const folders = ctx.platforms.map((key) => PLATFORMS[key].folder);
102
+ ctx.backupDir = backup.snapshot([...folders, 'CLAUDE.md', '.gitignore'], ctx.runId);
1094
103
  }
1095
104
 
1096
- fs.writeFileSync(claudeMdFile, content, 'utf8');
1097
- console.log(` ✓ Addressing configured in CLAUDE.md: AI calls the user "${userAddress}"`);
1098
- } catch (error) {
1099
- console.error(' ✗ Failed to configure addressing in CLAUDE.md');
1100
- console.error(` Error: ${error.message}`);
1101
- if (process.env.DEBUG) {
1102
- console.error(error.stack);
105
+ for (const platformKey of ctx.platforms) {
106
+ installPlatform(ctx, platformKey);
1103
107
  }
1104
- }
1105
- }
1106
108
 
1107
- async function setupGeminiCLI(platforms) {
1108
- const hasGemini = checkGeminiCLI();
109
+ ensureGitignore(ctx);
110
+ await runPostInstall(ctx);
111
+ printSummary(ctx);
1109
112
 
1110
- if (hasGemini) {
1111
- console.log(' ✓ gemini-cli already installed');
1112
- } else {
1113
- const shouldInstall = await promptInstallGemini();
1114
- if (shouldInstall) {
1115
- installGeminiCLI();
1116
- } else {
1117
- console.log('\n ℹ Skipped gemini-cli installation');
1118
- console.log(' • hapo:inspect will work in internal mode (default)');
1119
- console.log(' • To install later: npm install -g @google/gemini-cli');
1120
- }
1121
- }
113
+ if (!ctx.dryRun) backup.prune(3);
1122
114
 
1123
- // Always prompt for API config since we need it for hapo:test Multimodal
1124
- const apiKey = await promptGeminiAPIKey();
1125
- if (apiKey) {
1126
- configureGeminiKey(apiKey, platforms);
1127
- } else {
1128
- console.log('\n ℹ Skipped API key configuration');
1129
- console.log(' Set later: export GEMINI_API_KEY="your-key"');
1130
- }
1131
- }
1132
-
1133
- // ═══════════════════════════════════════════════════════════
1134
- // MAIN
1135
- // ═══════════════════════════════════════════════════════════
1136
-
1137
- async function main() {
1138
- const installerOptions = parseInstallerArgs(process.argv);
1139
-
1140
- console.log();
1141
- console.log('╔════════════════════════════════════════════════════════╗');
1142
- console.log(`║ CafeKit Installer v${String(packageJson.version).padEnd(5, ' ')} ║`);
1143
- console.log('║ Multi-platform SDD Workflow ║');
1144
- console.log('╚════════════════════════════════════════════════════════╝');
1145
- console.log();
1146
-
1147
- let platforms = detectPlatforms();
1148
-
1149
- if (platforms.length === 0) {
1150
- // No platforms detected - prompt user
1151
- platforms = await promptPlatformSelection();
1152
-
1153
- if (platforms.length === 0) {
1154
- console.log('\nInstallation cancelled.');
1155
- process.exit(0);
1156
- }
1157
- } else if (platforms.length > 1) {
1158
- // Multiple platforms detected - confirm with user
1159
- const proceed = await promptMultiPlatformConfirm(platforms);
1160
- if (!proceed) {
1161
- platforms = await promptPlatformSelection();
1162
- if (platforms.length === 0) {
1163
- console.log('\nInstallation cancelled.');
1164
- process.exit(0);
115
+ exitCode = ctx.results.errors > 0 ? 1 : 0;
116
+ } catch (error) {
117
+ const log = ctx && ctx.ui ? ctx.ui : { error: (m) => console.error(m) };
118
+ log.error(`Installation failed: ${error.message}`);
119
+ if (ctx && ctx.backupDir && !ctx.dryRun) {
120
+ try {
121
+ backup.restore(ctx.backupDir);
122
+ log.error('Rolled back to the pre-install state from snapshot.');
123
+ } catch (restoreError) {
124
+ log.error(`Rollback failed: ${restoreError.message}`);
125
+ log.error(`Manual restore available at: ${ctx.backupDir}`);
1165
126
  }
1166
127
  }
128
+ exitCode = 1;
129
+ } finally {
130
+ lock.release();
1167
131
  }
1168
132
 
1169
- // Show detected/selected platforms
1170
- const platformNames = platforms.map(key => PLATFORMS[key].name).join(', ');
1171
- console.log(`\nInstalling for: ${platformNames}`);
1172
-
1173
- warnLegacyClaudeFolder(platforms);
1174
-
1175
- if (installerOptions.upgrade) {
1176
- console.log('Mode: upgrade (overwrite managed files)\n');
1177
- } else {
1178
- console.log('Mode: install (skip existing files)\n');
1179
- }
1180
-
1181
- const results = {
1182
- copied: 0,
1183
- updated: 0,
1184
- skipped: 0,
1185
- installedSkills: 0,
1186
- dependencyChecks: 0,
1187
- installedDependencies: 0,
1188
- missingDependencies: 0,
1189
- errors: 0,
1190
- targets: []
1191
- };
1192
-
1193
- try {
1194
- for (const platformKey of platforms) {
1195
- const platform = PLATFORMS[platformKey];
1196
- console.log(`${platform.name} (${platform.folder}/)`);
1197
- console.log('-'.repeat(40));
1198
-
1199
- copyPlatformFiles(platformKey, results, installerOptions);
1200
-
1201
- // Copy ROUTING.md for Claude Code platform
1202
- if (platformKey === 'claude') {
1203
- copyRoutingFile(platformKey, results, installerOptions);
1204
- copyClaudeRuntimeFiles(platformKey, results, installerOptions);
1205
- removeObsoleteClaudeRuntimeFiles(platformKey, results);
1206
- mergeClaudeSettings(platformKey, results, installerOptions);
1207
- copyClaudeMdFile(platformKey, results, installerOptions);
1208
- copyRulesDirectory(platformKey, results, installerOptions);
1209
- }
1210
-
1211
- if (platformKey === 'opencode') {
1212
- copyOpenCodeSharedRuntimeFiles(platformKey, results, installerOptions);
1213
- copyOpenCodePlugins(platformKey, results, installerOptions);
1214
- copyOpenCodeCommandTemplates(platformKey, results, installerOptions);
1215
- copyOpenCodeAgentsMdFile(platformKey, results, installerOptions);
1216
- copyRulesDirectory(platformKey, results, installerOptions);
1217
- mergeOpenCodeConfig(platformKey, results);
1218
- }
1219
-
1220
- writePlatformVersionMetadata(platformKey, results);
1221
-
1222
- results.targets.push(platform.commandsDir);
1223
- console.log();
1224
- }
1225
-
1226
- // Ensure root .gitignore is configured correctly
1227
- console.log('Root Configuration');
1228
- console.log('-'.repeat(40));
1229
- ensureGitignore(results, installerOptions);
1230
- console.log();
1231
-
1232
- // Setup runtime-specific optional model/API configuration
1233
- await setupOpenCodeModel(platforms, results);
1234
- await setupGeminiCLI(platforms);
1235
-
1236
- // Setup addressing configuration
1237
- const addressingConfig = await promptAddressingConfig();
1238
- configureAddressing(addressingConfig, platforms);
1239
-
1240
- // Summary
1241
- console.log('╔════════════════════════════════════════════════════════╗');
1242
- console.log('║ Installation Complete! ║');
1243
- console.log('╚════════════════════════════════════════════════════════╝');
1244
- console.log();
1245
- console.log(` Copied Files: ${results.copied}`);
1246
- console.log(` Updated Files: ${results.updated}`);
1247
- console.log(` CafeKit Version: ${packageJson.version}`);
1248
- console.log(` Skipped Files: ${results.skipped}`);
1249
- console.log(` Installed Skills: ${results.installedSkills > 0 ? 'Yes ✓' : 'No'}`);
1250
- console.log(` Dependency Checks: ${results.dependencyChecks}`);
1251
- console.log(` Installed Deps: ${results.installedDependencies}`);
1252
- console.log(` Missing Deps: ${results.missingDependencies}`);
1253
- console.log(` Target Directories: ${results.targets.join(', ')}`);
1254
- if (results.errors > 0) {
1255
- console.log(` Errors: ${results.errors} ⚠`);
1256
- }
1257
- console.log();
1258
- console.log('Next steps:');
1259
- const nextEditorLabel = platforms.length === 1 ? PLATFORMS[platforms[0]].name : 'your AI editor';
1260
- console.log(` 1. Start ${nextEditorLabel}`);
1261
-
1262
- // Show platform-specific hints
1263
- for (const platformKey of platforms) {
1264
- const platform = PLATFORMS[platformKey];
1265
- console.log(`\n For ${platform.name}:`);
1266
- if (platformKey === 'claude') {
1267
- console.log(' Use skill: /hapo:specs <feature-description>');
1268
- } else {
1269
- console.log(` Instruct the agent to start a new feature or brainstorm`);
1270
- }
1271
- }
1272
-
1273
- if (platforms.includes('claude')) {
1274
- console.log('\n 2. Claude Code will automatically sync docs/ (Continuous Documentation)');
1275
- } else {
1276
- console.log('\n 2. CafeKit skills and AGENTS.md are installed for the selected runtime');
1277
- }
1278
- const envHosts = platforms.map(key => `${PLATFORMS[key].folder}/.env`).join(' and ');
1279
- console.log(` 3. Project API Keys are now securely isolated in ${envHosts}`);
1280
- if (!installerOptions.upgrade) {
1281
- console.log(' 4. To refresh managed templates later, run installer with --upgrade');
1282
- }
1283
- console.log();
1284
- console.log('Documentation: https://github.com/haposoft/cafekit');
1285
- if (results.missingDependencies > 0) {
1286
- console.log('Note: some dependency templates could not be installed. Please check command/agent directories.');
1287
- }
1288
- console.log();
1289
-
1290
- process.exit(results.errors > 0 ? 1 : 0);
1291
- } catch (error) {
1292
- console.error(`\n✗ Installation failed: ${error.message}`);
1293
- process.exit(1);
1294
- }
133
+ process.exit(exitCode);
1295
134
  }
1296
135
 
1297
136
  main();