orbital-command 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +67 -42
  2. package/bin/commands/config.js +19 -0
  3. package/bin/commands/events.js +40 -0
  4. package/bin/commands/launch.js +126 -0
  5. package/bin/commands/manifest.js +283 -0
  6. package/bin/commands/registry.js +104 -0
  7. package/bin/commands/update.js +24 -0
  8. package/bin/lib/helpers.js +229 -0
  9. package/bin/orbital.js +95 -870
  10. package/dist/assets/Landing-CfQdHR0N.js +11 -0
  11. package/dist/assets/PrimitivesConfig-DThSipFy.js +32 -0
  12. package/dist/assets/QualityGates-B4kxM5UU.js +26 -0
  13. package/dist/assets/SessionTimeline-Bz1iZnmg.js +1 -0
  14. package/dist/assets/Settings-DLcZwbCT.js +12 -0
  15. package/dist/assets/SourceControl-BMNIz7Lt.js +36 -0
  16. package/dist/assets/WorkflowVisualizer-CxuSBOYu.js +69 -0
  17. package/dist/assets/{arrow-down-CPy85_J6.js → arrow-down-DVPp6_qp.js} +1 -1
  18. package/dist/assets/bot-NFaJBDn_.js +6 -0
  19. package/dist/assets/{charts-DbDg0Psc.js → charts-LGLb8hyU.js} +1 -1
  20. package/dist/assets/{circle-x-Cwz6ZQDV.js → circle-x-IsFCkBZu.js} +1 -1
  21. package/dist/assets/{file-text-C46Xr65c.js → file-text-J1cebZXF.js} +1 -1
  22. package/dist/assets/{globe-Cn2yNZUD.js → globe-WzeyHsUc.js} +1 -1
  23. package/dist/assets/index-BdJ57EhC.css +1 -0
  24. package/dist/assets/index-o4ScMAuR.js +349 -0
  25. package/dist/assets/{key-OPaNTWJ5.js → key-CKR8JJSj.js} +1 -1
  26. package/dist/assets/{minus-GMsbpKym.js → minus-CHBsJyjp.js} +1 -1
  27. package/dist/assets/radio-xqZaR-Uk.js +6 -0
  28. package/dist/assets/rocket-D_xvvNG6.js +6 -0
  29. package/dist/assets/{shield-DwAFkDYI.js → shield-TdB1yv_a.js} +1 -1
  30. package/dist/assets/useSocketListener-0L5yiN5i.js +1 -0
  31. package/dist/assets/useWorkflowEditor-CqeRWVQX.js +11 -0
  32. package/dist/assets/workflow-constants-Rw-GmgHZ.js +6 -0
  33. package/dist/assets/zap-C9wqYMpl.js +6 -0
  34. package/dist/index.html +3 -3
  35. package/dist/server/server/__tests__/data-routes.test.js +2 -0
  36. package/dist/server/server/__tests__/scope-routes.test.js +1 -0
  37. package/dist/server/server/config-migrator.js +0 -3
  38. package/dist/server/server/config.js +35 -6
  39. package/dist/server/server/database.js +0 -22
  40. package/dist/server/server/index.js +26 -814
  41. package/dist/server/server/init.js +32 -399
  42. package/dist/server/server/launch.js +1 -1
  43. package/dist/server/server/parsers/event-parser.js +4 -1
  44. package/dist/server/server/project-context.js +19 -9
  45. package/dist/server/server/project-manager.js +6 -6
  46. package/dist/server/server/routes/aggregate-routes.js +871 -0
  47. package/dist/server/server/routes/config-routes.js +41 -88
  48. package/dist/server/server/routes/data-routes.js +5 -15
  49. package/dist/server/server/routes/dispatch-routes.js +24 -8
  50. package/dist/server/server/routes/manifest-routes.js +1 -1
  51. package/dist/server/server/routes/scope-routes.js +10 -7
  52. package/dist/server/server/schema.js +1 -0
  53. package/dist/server/server/services/batch-orchestrator.js +17 -3
  54. package/dist/server/server/services/config-service.js +10 -1
  55. package/dist/server/server/services/scope-service.js +7 -7
  56. package/dist/server/server/services/sprint-orchestrator.js +24 -11
  57. package/dist/server/server/services/sprint-service.js +2 -2
  58. package/dist/server/server/uninstall.js +195 -0
  59. package/dist/server/server/update.js +212 -0
  60. package/dist/server/server/utils/dispatch-utils.js +8 -6
  61. package/dist/server/server/utils/flag-builder.js +54 -0
  62. package/dist/server/server/utils/json-fields.js +14 -0
  63. package/dist/server/server/utils/json-fields.test.js +73 -0
  64. package/dist/server/server/utils/route-helpers.js +37 -0
  65. package/dist/server/server/utils/route-helpers.test.js +115 -0
  66. package/dist/server/server/watchers/event-watcher.js +28 -13
  67. package/dist/server/server/wizard/config-editor.js +4 -4
  68. package/dist/server/server/wizard/doctor.js +2 -2
  69. package/dist/server/server/wizard/index.js +224 -39
  70. package/dist/server/server/wizard/phases/welcome.js +1 -4
  71. package/dist/server/server/wizard/ui.js +6 -7
  72. package/dist/server/shared/api-types.js +80 -1
  73. package/dist/server/shared/workflow-engine.js +1 -1
  74. package/package.json +20 -20
  75. package/schemas/orbital.config.schema.json +1 -19
  76. package/scripts/postinstall.js +6 -42
  77. package/scripts/release.sh +53 -0
  78. package/server/__tests__/data-routes.test.ts +2 -0
  79. package/server/__tests__/scope-routes.test.ts +1 -0
  80. package/server/config-migrator.ts +0 -3
  81. package/server/config.ts +39 -11
  82. package/server/database.ts +0 -26
  83. package/server/global-config.ts +4 -0
  84. package/server/index.ts +29 -894
  85. package/server/init.ts +32 -443
  86. package/server/launch.ts +1 -1
  87. package/server/parsers/event-parser.ts +4 -1
  88. package/server/project-context.ts +26 -10
  89. package/server/project-manager.ts +5 -6
  90. package/server/routes/aggregate-routes.ts +968 -0
  91. package/server/routes/config-routes.ts +41 -81
  92. package/server/routes/data-routes.ts +7 -16
  93. package/server/routes/dispatch-routes.ts +29 -8
  94. package/server/routes/manifest-routes.ts +1 -1
  95. package/server/routes/scope-routes.ts +12 -7
  96. package/server/schema.ts +1 -0
  97. package/server/services/batch-orchestrator.ts +18 -2
  98. package/server/services/config-service.ts +10 -1
  99. package/server/services/scope-service.ts +6 -6
  100. package/server/services/sprint-orchestrator.ts +24 -9
  101. package/server/services/sprint-service.ts +2 -2
  102. package/server/uninstall.ts +214 -0
  103. package/server/update.ts +263 -0
  104. package/server/utils/dispatch-utils.ts +8 -6
  105. package/server/utils/flag-builder.ts +56 -0
  106. package/server/utils/json-fields.test.ts +83 -0
  107. package/server/utils/json-fields.ts +14 -0
  108. package/server/utils/route-helpers.test.ts +144 -0
  109. package/server/utils/route-helpers.ts +38 -0
  110. package/server/watchers/event-watcher.ts +24 -12
  111. package/server/wizard/config-editor.ts +4 -4
  112. package/server/wizard/doctor.ts +2 -2
  113. package/server/wizard/index.ts +291 -40
  114. package/server/wizard/phases/welcome.ts +1 -5
  115. package/server/wizard/ui.ts +6 -7
  116. package/shared/api-types.ts +106 -0
  117. package/shared/workflow-engine.ts +1 -1
  118. package/templates/agents/QUICK-REFERENCE.md +1 -0
  119. package/templates/agents/README.md +1 -0
  120. package/templates/agents/SKILL-TRIGGERS.md +11 -0
  121. package/templates/agents/green-team/deep-dive.md +361 -0
  122. package/templates/hooks/end-session.sh +1 -0
  123. package/templates/hooks/init-session.sh +1 -0
  124. package/templates/hooks/scope-commit-logger.sh +2 -2
  125. package/templates/hooks/scope-create-gate.sh +2 -4
  126. package/templates/hooks/scope-gate.sh +4 -6
  127. package/templates/hooks/scope-helpers.sh +10 -1
  128. package/templates/hooks/scope-lifecycle-gate.sh +14 -5
  129. package/templates/hooks/scope-prepare.sh +1 -1
  130. package/templates/hooks/scope-transition.sh +14 -6
  131. package/templates/hooks/time-tracker.sh +2 -5
  132. package/templates/orbital.config.json +1 -4
  133. package/templates/presets/development.json +4 -4
  134. package/templates/presets/gitflow.json +7 -0
  135. package/templates/prompts/README.md +23 -0
  136. package/templates/prompts/deep-dive-audit.md +94 -0
  137. package/templates/quick/rules.md +56 -5
  138. package/templates/skills/git-commit/SKILL.md +21 -6
  139. package/templates/skills/git-dev/SKILL.md +8 -4
  140. package/templates/skills/git-main/SKILL.md +8 -4
  141. package/templates/skills/git-production/SKILL.md +6 -3
  142. package/templates/skills/git-staging/SKILL.md +6 -3
  143. package/templates/skills/scope-fix-review/SKILL.md +8 -4
  144. package/templates/skills/scope-implement/SKILL.md +13 -5
  145. package/templates/skills/scope-post-review/SKILL.md +16 -4
  146. package/templates/skills/scope-pre-review/SKILL.md +6 -2
  147. package/dist/assets/PrimitivesConfig-CrmQXYh4.js +0 -32
  148. package/dist/assets/QualityGates-BbasOsF3.js +0 -21
  149. package/dist/assets/SessionTimeline-CGeJsVvy.js +0 -1
  150. package/dist/assets/Settings-oiM496mc.js +0 -12
  151. package/dist/assets/SourceControl-B1fP2nJL.js +0 -41
  152. package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +0 -74
  153. package/dist/assets/formatDistanceToNow-BMqsSP44.js +0 -1
  154. package/dist/assets/index-Aj4sV8Al.css +0 -1
  155. package/dist/assets/index-Bc9dK3MW.js +0 -354
  156. package/dist/assets/useWorkflowEditor-BJkTX_NR.js +0 -16
  157. package/dist/assets/zap-DfbUoOty.js +0 -11
  158. package/dist/server/server/services/telemetry-service.js +0 -143
  159. package/server/services/telemetry-service.ts +0 -195
  160. /package/{shared/default-workflow.json → templates/presets/default.json} +0 -0
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { errMsg, isValidRelativePath, inferErrorStatus, catchRoute } from './route-helpers.js';
5
+ // ─── errMsg ─────────────────────────────────────────────────
6
+ describe('errMsg', () => {
7
+ it('extracts message from Error instances', () => {
8
+ expect(errMsg(new Error('something broke'))).toBe('something broke');
9
+ });
10
+ it('converts non-Error values to strings', () => {
11
+ expect(errMsg('raw string')).toBe('raw string');
12
+ expect(errMsg(42)).toBe('42');
13
+ expect(errMsg(null)).toBe('null');
14
+ expect(errMsg(undefined)).toBe('undefined');
15
+ });
16
+ });
17
+ // ─── isValidRelativePath ────────────────────────────────────
18
+ describe('isValidRelativePath', () => {
19
+ it('accepts normal relative paths', () => {
20
+ expect(isValidRelativePath('hooks/init.sh')).toBe(true);
21
+ expect(isValidRelativePath('agents/attacker/AGENT.md')).toBe(true);
22
+ expect(isValidRelativePath('file.txt')).toBe(true);
23
+ });
24
+ it('rejects directory traversal', () => {
25
+ expect(isValidRelativePath('../etc/passwd')).toBe(false);
26
+ expect(isValidRelativePath('hooks/../../secret')).toBe(false);
27
+ });
28
+ it('rejects absolute paths', () => {
29
+ expect(isValidRelativePath('/etc/passwd')).toBe(false);
30
+ });
31
+ it('rejects null bytes', () => {
32
+ expect(isValidRelativePath('file\0.txt')).toBe(false);
33
+ });
34
+ });
35
+ // ─── inferErrorStatus ───────────────────────────────────────
36
+ describe('inferErrorStatus', () => {
37
+ it('returns 403 for traversal errors', () => {
38
+ expect(inferErrorStatus('Path traversal detected')).toBe(403);
39
+ expect(inferErrorStatus('directory traversal not allowed')).toBe(403);
40
+ });
41
+ it('returns 404 for not-found errors', () => {
42
+ expect(inferErrorStatus('File not found')).toBe(404);
43
+ expect(inferErrorStatus('ENOENT: no such file')).toBe(404);
44
+ });
45
+ it('returns 409 for already-exists errors', () => {
46
+ expect(inferErrorStatus('File already exists at path')).toBe(409);
47
+ });
48
+ it('returns 400 for directory errors', () => {
49
+ expect(inferErrorStatus('Cannot delete directory')).toBe(400);
50
+ });
51
+ it('returns 500 for unrecognized errors', () => {
52
+ expect(inferErrorStatus('something unexpected')).toBe(500);
53
+ expect(inferErrorStatus('')).toBe(500);
54
+ });
55
+ it('matches the first keyword when multiple are present', () => {
56
+ // "traversal" comes first in the chain, should win
57
+ expect(inferErrorStatus('traversal not found')).toBe(403);
58
+ });
59
+ });
60
+ // ─── catchRoute ─────────────────────────────────────────────
61
+ describe('catchRoute', () => {
62
+ function createApp(handler) {
63
+ const app = express();
64
+ app.get('/test', handler);
65
+ return app;
66
+ }
67
+ it('returns normal response when handler succeeds', async () => {
68
+ const app = createApp(catchRoute((_req, res) => {
69
+ res.json({ ok: true });
70
+ }));
71
+ const res = await request(app).get('/test');
72
+ expect(res.status).toBe(200);
73
+ expect(res.body).toEqual({ ok: true });
74
+ });
75
+ it('catches sync throws and returns 500', async () => {
76
+ const app = createApp(catchRoute(() => {
77
+ throw new Error('sync failure');
78
+ }));
79
+ const res = await request(app).get('/test');
80
+ expect(res.status).toBe(500);
81
+ expect(res.body).toEqual({ success: false, error: 'sync failure' });
82
+ });
83
+ it('catches async throws and returns 500', async () => {
84
+ const app = createApp(catchRoute(async () => {
85
+ throw new Error('async failure');
86
+ }));
87
+ const res = await request(app).get('/test');
88
+ expect(res.status).toBe(500);
89
+ expect(res.body).toEqual({ success: false, error: 'async failure' });
90
+ });
91
+ it('uses custom statusFn for error status codes', async () => {
92
+ const app = createApp(catchRoute(() => {
93
+ throw new Error('File not found at path');
94
+ }, inferErrorStatus));
95
+ const res = await request(app).get('/test');
96
+ expect(res.status).toBe(404);
97
+ expect(res.body).toEqual({ success: false, error: 'File not found at path' });
98
+ });
99
+ it('uses custom statusFn for traversal errors', async () => {
100
+ const app = createApp(catchRoute(() => {
101
+ throw new Error('Path traversal detected');
102
+ }, inferErrorStatus));
103
+ const res = await request(app).get('/test');
104
+ expect(res.status).toBe(403);
105
+ });
106
+ it('handles non-Error thrown values', async () => {
107
+ const app = createApp(catchRoute(() => {
108
+ // eslint-disable-next-line no-throw-literal
109
+ throw 'raw string error';
110
+ }));
111
+ const res = await request(app).get('/test');
112
+ expect(res.status).toBe(500);
113
+ expect(res.body).toEqual({ success: false, error: 'raw string error' });
114
+ });
115
+ });
@@ -5,6 +5,7 @@ import { parseEventFile } from '../parsers/event-parser.js';
5
5
  import { createLogger } from '../utils/logger.js';
6
6
  const log = createLogger('event');
7
7
  const ARCHIVE_DIR_NAME = 'processed';
8
+ const processingFiles = new Set();
8
9
  /**
9
10
  * Watch .claude/orbital-events/ for new JSON event files.
10
11
  * On startup, processes any existing unprocessed events.
@@ -59,23 +60,37 @@ function processExistingEvents(eventsDir, eventService, archiveDir) {
59
60
  }
60
61
  }
61
62
  function processEventFile(filePath, eventService, archiveDir) {
62
- const event = parseEventFile(filePath);
63
- if (!event)
63
+ if (processingFiles.has(filePath))
64
64
  return;
65
- eventService.ingest(event);
66
- // Move to archive
67
- const fileName = path.basename(filePath);
65
+ processingFiles.add(filePath);
68
66
  try {
69
- fs.renameSync(filePath, path.join(archiveDir, fileName));
70
- }
71
- catch (err) {
72
- log.warn('Failed to archive event file', { file: filePath, error: err.message });
73
- // If rename fails (cross-device), just delete the source
67
+ const event = parseEventFile(filePath);
68
+ if (!event)
69
+ return;
70
+ eventService.ingest(event);
71
+ // Move to archive
72
+ const fileName = path.basename(filePath);
74
73
  try {
75
- fs.unlinkSync(filePath);
74
+ fs.renameSync(filePath, path.join(archiveDir, fileName));
76
75
  }
77
- catch (unlinkErr) {
78
- log.warn('Failed to delete event file', { file: filePath, error: unlinkErr.message });
76
+ catch (err) {
77
+ const code = err.code;
78
+ if (code === 'ENOENT')
79
+ return; // Already archived by concurrent handler
80
+ log.warn('Failed to archive event file', { file: filePath, error: err.message });
81
+ // If rename fails (cross-device), just delete the source
82
+ try {
83
+ fs.unlinkSync(filePath);
84
+ }
85
+ catch (unlinkErr) {
86
+ const unlinkCode = unlinkErr.code;
87
+ if (unlinkCode !== 'ENOENT') {
88
+ log.warn('Failed to delete event file', { file: filePath, error: unlinkErr.message });
89
+ }
90
+ }
79
91
  }
80
92
  }
93
+ finally {
94
+ processingFiles.delete(filePath);
95
+ }
81
96
  }
@@ -12,7 +12,7 @@ export async function runConfigEditor(projectRoot, packageVersion, args) {
12
12
  if (subcommand === 'show') {
13
13
  const config = loadProjectConfig(projectRoot);
14
14
  if (!config) {
15
- console.error('No config found. Run `orbital init` first.');
15
+ console.error('No config found. Run `orbital` first.');
16
16
  process.exit(1);
17
17
  }
18
18
  console.log(JSON.stringify(config, null, 2));
@@ -32,7 +32,7 @@ export async function runConfigEditor(projectRoot, packageVersion, args) {
32
32
  // Interactive mode
33
33
  const config = loadProjectConfig(projectRoot);
34
34
  if (!config) {
35
- p.log.error('No config found. Run `orbital init` first.');
35
+ p.log.error('No config found. Run `orbital` first.');
36
36
  process.exit(1);
37
37
  }
38
38
  p.intro(`${pc.bgCyan(pc.black(' Orbital Config '))} ${pc.dim(`v${packageVersion}`)}`);
@@ -158,7 +158,7 @@ async function editGlobalSection() {
158
158
  const homedir = process.env.HOME || process.env.USERPROFILE || '~';
159
159
  const registryPath = path.join(homedir, '.orbital', 'config.json');
160
160
  if (!fs.existsSync(registryPath)) {
161
- p.log.info('No global registry found. Run `orbital init` in a project first.');
161
+ p.log.info('No global registry found. Run `orbital` in a project first.');
162
162
  return;
163
163
  }
164
164
  try {
@@ -198,7 +198,7 @@ function saveProjectConfig(projectRoot, config) {
198
198
  function setConfigValue(projectRoot, key, value) {
199
199
  const config = loadProjectConfig(projectRoot);
200
200
  if (!config) {
201
- console.error('No config found. Run `orbital init` first.');
201
+ console.error('No config found. Run `orbital` first.');
202
202
  process.exit(1);
203
203
  }
204
204
  // Parse value: try number, then boolean, then string
@@ -47,7 +47,7 @@ export async function runDoctor(projectRoot, packageVersion) {
47
47
  }
48
48
  }
49
49
  else {
50
- checks.push({ label: 'Global', status: pc.dim('~/.orbital/ not found (run `orbital init` to create)') });
50
+ checks.push({ label: 'Global', status: pc.dim('~/.orbital/ not found (run `orbital` to create)') });
51
51
  }
52
52
  // 4. Project initialization
53
53
  const configPath = path.join(projectRoot, '.claude', 'orbital.config.json');
@@ -62,7 +62,7 @@ export async function runDoctor(projectRoot, packageVersion) {
62
62
  }
63
63
  }
64
64
  else {
65
- checks.push({ label: 'Project', status: pc.dim('not initialized (run `orbital init`)') });
65
+ checks.push({ label: 'Project', status: pc.dim('not initialized (run `orbital`)') });
66
66
  }
67
67
  // 5. Workflow
68
68
  const workflowPath = path.join(projectRoot, '.claude', 'config', 'workflow.json');
@@ -9,10 +9,10 @@
9
9
  */
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
- import crypto from 'crypto';
12
+ import { spawn, execFileSync } from 'child_process';
13
13
  import * as p from '@clack/prompts';
14
14
  import pc from 'picocolors';
15
- import { buildSetupState, buildProjectState, ORBITAL_HOME } from './detect.js';
15
+ import { buildSetupState, buildProjectState } from './detect.js';
16
16
  import { phaseSetupWizard } from './phases/setup-wizard.js';
17
17
  import { phaseWelcome } from './phases/welcome.js';
18
18
  import { phaseProjectSetup } from './phases/project-setup.js';
@@ -21,6 +21,8 @@ import { phaseConfirm, showPostInstall } from './phases/confirm.js';
21
21
  import { NOTES } from './ui.js';
22
22
  import { runConfigEditor } from './config-editor.js';
23
23
  import { runDoctor } from './doctor.js';
24
+ import { isITerm2Available } from '../adapters/iterm2-adapter.js';
25
+ import { registerProject } from '../global-config.js';
24
26
  export { runConfigEditor, runDoctor };
25
27
  // ─── Phase 1: Setup Wizard ─────────────────────────────────────
26
28
  /**
@@ -40,8 +42,8 @@ export async function runSetupWizard(packageVersion) {
40
42
  p.note(NOTES.setupComplete, 'Done');
41
43
  }
42
44
  p.outro(state.linkedProjects.length > 0
43
- ? `Run ${pc.cyan('orbital launch --open')} to open the dashboard.`
44
- : `Run ${pc.cyan('orbital init')} in a project directory to get started.`);
45
+ ? `Run ${pc.cyan('orbital')} to launch the dashboard.`
46
+ : `Run ${pc.cyan('orbital')} in a project directory to get started.`);
45
47
  }
46
48
  // ─── Phase 2: Project Setup ────────────────────────────────────
47
49
  /**
@@ -56,7 +58,7 @@ export async function runProjectSetup(projectRoot, packageVersion, args) {
56
58
  const forceFromWelcome = await phaseWelcome(state);
57
59
  const useForce = force || forceFromWelcome;
58
60
  await runProjectPhases(state, useForce);
59
- p.outro(`Run ${pc.cyan('orbital launch --open')} to open the dashboard.`);
61
+ p.outro(`Run ${pc.cyan('orbital')} to launch the dashboard.`);
60
62
  }
61
63
  // ─── Shared project phases (used by both flows) ────────────────
62
64
  /**
@@ -81,7 +83,7 @@ async function runProjectPhases(state, useForce) {
81
83
  clientPort: state.clientPort,
82
84
  commands: state.selectedCommands,
83
85
  });
84
- registerProject(state.projectRoot, state.projectName);
86
+ registerProject(state.projectRoot, { name: state.projectName });
85
87
  stampTemplateVersion(state.projectRoot, state.packageVersion);
86
88
  s.stop('Project ready.');
87
89
  }
@@ -101,43 +103,224 @@ async function runProjectSetupInline(projectRoot, packageVersion) {
101
103
  // Skip welcome gate for inline — this is a fresh project being linked
102
104
  await runProjectPhases(state, false);
103
105
  }
104
- // ─── Registration ──────────────────────────────────────────────
105
- function registerProject(projectRoot, projectName) {
106
- const registryPath = path.join(ORBITAL_HOME, 'config.json');
107
- let registry;
106
+ async function checkForUpdate(currentVersion, cache) {
107
+ // Use cache if checked within 24 hours
108
+ if (cache.lastUpdateCheck && cache.latestVersion) {
109
+ const age = Date.now() - new Date(cache.lastUpdateCheck).getTime();
110
+ if (age < 24 * 60 * 60 * 1000) {
111
+ const isOutdated = cache.latestVersion !== currentVersion;
112
+ return {
113
+ info: { current: currentVersion, latest: cache.latestVersion, isOutdated },
114
+ cacheChanged: false,
115
+ };
116
+ }
117
+ }
118
+ // Fetch from npm registry
108
119
  try {
109
- registry = fs.existsSync(registryPath)
110
- ? JSON.parse(fs.readFileSync(registryPath, 'utf8'))
111
- : { version: 1, projects: [] };
120
+ const res = await fetch('https://registry.npmjs.org/orbital-command/latest', {
121
+ signal: AbortSignal.timeout(3000),
122
+ });
123
+ const data = await res.json();
124
+ const latest = data.version;
125
+ return {
126
+ info: { current: currentVersion, latest, isOutdated: latest !== currentVersion },
127
+ cacheChanged: true,
128
+ };
112
129
  }
113
130
  catch {
114
- registry = { version: 1, projects: [] };
131
+ return { info: null, cacheChanged: false };
115
132
  }
116
- if (!registry.projects)
117
- registry.projects = [];
118
- if (registry.projects.some((proj) => proj.path === projectRoot))
119
- return;
120
- const COLORS = [
121
- '210 80% 55%', '340 75% 55%', '160 60% 45%', '30 90% 55%',
122
- '270 65% 55%', '50 85% 50%', '180 55% 45%', '0 70% 55%',
123
- ];
124
- const usedColors = registry.projects.map((proj) => proj.color);
125
- const color = COLORS.find(c => !usedColors.includes(c)) || COLORS[0];
126
- const name = projectName || path.basename(projectRoot);
127
- const baseSlug = path.basename(projectRoot).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'project';
128
- const existingIds = registry.projects.map((proj) => proj.id);
129
- const slug = existingIds.includes(baseSlug)
130
- ? `${baseSlug}-${crypto.createHash('sha256').update(projectRoot).digest('hex').slice(0, 4)}`
131
- : baseSlug;
132
- registry.projects.push({
133
- id: slug,
134
- path: projectRoot,
135
- name,
136
- color,
137
- registeredAt: new Date().toISOString(),
138
- enabled: true,
133
+ }
134
+ /**
135
+ * Context-aware hub menu — the main entry point for `orbital` (no args).
136
+ * Checks for updates, offers template sync, shows iTerm2 recommendation, then menu.
137
+ */
138
+ export async function runHub(opts) {
139
+ const result = { action: 'launch' };
140
+ p.intro(`${pc.bgCyan(pc.black(' Orbital Command '))} ${pc.dim(`v${opts.packageVersion}`)}`);
141
+ // ── Update check ──
142
+ const updateCheck = await checkForUpdate(opts.packageVersion, {
143
+ lastUpdateCheck: opts.lastUpdateCheck,
144
+ latestVersion: opts.latestVersion,
145
+ });
146
+ if (updateCheck.cacheChanged) {
147
+ result.updateCache = {
148
+ lastUpdateCheck: new Date().toISOString(),
149
+ latestVersion: updateCheck.info?.latest,
150
+ };
151
+ }
152
+ if (updateCheck.info?.isOutdated) {
153
+ p.log.info(`Update available: ${pc.dim(`v${updateCheck.info.current}`)} → ${pc.cyan(`v${updateCheck.info.latest}`)}`);
154
+ const updateChoice = await p.select({
155
+ message: 'Update Orbital Command now?',
156
+ options: [
157
+ { value: 'update', label: 'Yes, update' },
158
+ { value: 'skip', label: 'Skip for now' },
159
+ ],
160
+ });
161
+ if (!p.isCancel(updateChoice) && updateChoice === 'update') {
162
+ const s = p.spinner();
163
+ s.start('Updating Orbital Command...');
164
+ try {
165
+ execFileSync('npm', ['update', '-g', 'orbital-command'], { stdio: 'pipe', timeout: 60000 });
166
+ s.stop(`Updated to v${updateCheck.info.latest}!`);
167
+ p.outro(`Run ${pc.cyan('orbital')} again to use the new version.`);
168
+ process.exit(0);
169
+ }
170
+ catch (err) {
171
+ s.stop('Update failed.');
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ if (msg.includes('EACCES') || msg.includes('permission denied')) {
174
+ p.log.error('Permission denied. Try running with sudo or ensure npm is installed via nvm.');
175
+ }
176
+ else if (msg.includes('ETIMEDOUT') || msg.includes('timeout')) {
177
+ p.log.error('Update timed out. Check your network connection and try again.');
178
+ }
179
+ else {
180
+ p.log.error(msg);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ // ── Template staleness check ──
186
+ if (opts.projectPaths.length > 0) {
187
+ const mod = await import('../manifest.js');
188
+ const initMod = await import('../init.js');
189
+ const outdatedProjects = [];
190
+ for (const proj of opts.projectPaths) {
191
+ if (!fs.existsSync(proj.path)) {
192
+ p.log.warn(`${proj.name}: project path not found (${proj.path})`);
193
+ continue;
194
+ }
195
+ const manifest = mod.loadManifest(proj.path);
196
+ if (!manifest)
197
+ continue;
198
+ const claudeDir = path.join(proj.path, '.claude');
199
+ mod.refreshFileStatuses(manifest, claudeDir);
200
+ const summary = mod.summarizeManifest(manifest);
201
+ const parts = Object.entries(summary.byType)
202
+ .filter(([, counts]) => counts.outdated > 0)
203
+ .map(([type, counts]) => `${counts.outdated} ${type}`);
204
+ if (parts.length > 0) {
205
+ outdatedProjects.push({ name: proj.name, path: proj.path, details: parts });
206
+ }
207
+ }
208
+ if (outdatedProjects.length > 0) {
209
+ const lines = outdatedProjects.map(proj => ` ${pc.cyan(proj.name.padEnd(16))} ${proj.details.join(', ')} outdated`);
210
+ const count = outdatedProjects.length;
211
+ p.note(lines.join('\n'), `${count} project${count > 1 ? 's have' : ' has'} outdated templates`);
212
+ const syncChoice = await p.select({
213
+ message: 'Update project templates now?',
214
+ options: [
215
+ { value: 'update', label: 'Yes, update all safe files', hint: 'skips modified and pinned' },
216
+ { value: 'skip', label: 'Skip for now' },
217
+ ],
218
+ });
219
+ if (!p.isCancel(syncChoice) && syncChoice === 'update') {
220
+ for (const proj of outdatedProjects) {
221
+ const s = p.spinner();
222
+ s.start(`Updating ${proj.name}...`);
223
+ try {
224
+ initMod.runUpdate(proj.path, { dryRun: false });
225
+ s.stop(`${proj.name} updated.`);
226
+ }
227
+ catch (err) {
228
+ s.stop(`${proj.name} failed.`);
229
+ p.log.warn(err instanceof Error ? err.message : String(err));
230
+ }
231
+ }
232
+ }
233
+ }
234
+ }
235
+ // ── iTerm2 recommendation (macOS only, one-time) ──
236
+ if (opts.isMac && !opts.itermPromptShown && !isITerm2Available()) {
237
+ p.note(`Sprint dispatch, batch orchestration, and session management\n` +
238
+ `use iTerm2 tabs to run parallel Claude Code sessions.\n` +
239
+ `Without it, sessions fall back to basic subprocess mode.`, 'iTerm2 Recommended');
240
+ const itermChoice = await p.select({
241
+ message: 'Install iTerm2?',
242
+ options: [
243
+ { value: 'install', label: 'Open download page', hint: 'https://iterm2.com' },
244
+ { value: 'skip', label: 'Skip for now' },
245
+ ],
246
+ });
247
+ result.setItermPromptShown = true;
248
+ if (!p.isCancel(itermChoice) && itermChoice === 'install') {
249
+ spawn('open', ['https://iterm2.com'], { detached: true, stdio: 'ignore' }).unref();
250
+ p.log.info('Waiting for iTerm2 to install... (press any key to skip)');
251
+ await new Promise((resolve) => {
252
+ let done = false;
253
+ const cleanup = () => {
254
+ if (done)
255
+ return;
256
+ done = true;
257
+ process.stdin.setRawMode?.(false);
258
+ process.stdin.removeListener('data', onKey);
259
+ process.stdin.pause();
260
+ clearInterval(timer);
261
+ resolve();
262
+ };
263
+ const onKey = () => { cleanup(); };
264
+ const startTime = Date.now();
265
+ const MAX_WAIT = 10 * 60 * 1000; // 10 minutes
266
+ const timer = setInterval(() => {
267
+ if (isITerm2Available()) {
268
+ p.log.success('iTerm2 detected!');
269
+ cleanup();
270
+ }
271
+ else if (Date.now() - startTime > MAX_WAIT) {
272
+ cleanup();
273
+ }
274
+ }, 3000);
275
+ process.stdin.setRawMode?.(true);
276
+ process.stdin.resume();
277
+ process.stdin.on('data', onKey);
278
+ });
279
+ }
280
+ }
281
+ // ── Build menu options based on project state ──
282
+ const projectHint = opts.projectNames.length > 0
283
+ ? pc.dim(` (${opts.projectNames.join(', ')})`)
284
+ : '';
285
+ const options = [];
286
+ if (opts.isProjectInitialized) {
287
+ options.push({ value: 'launch', label: `Launch dashboard${projectHint}` }, { value: 'config', label: 'Config', hint: 'modify project settings' }, { value: 'doctor', label: 'Doctor', hint: 'health check & diagnostics' }, { value: 'update', label: 'Update templates', hint: 'sync to latest' }, { value: 'status', label: 'Status', hint: 'template sync status' }, { value: 'reset', label: 'Reset to defaults', hint: 'force-reset all templates' });
288
+ }
289
+ else {
290
+ options.push({ value: 'init', label: 'Initialize this project' }, { value: 'launch', label: `Launch dashboard${projectHint}` });
291
+ }
292
+ const action = await p.select({
293
+ message: 'What would you like to do?',
294
+ options,
139
295
  });
140
- fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf8');
296
+ if (p.isCancel(action)) {
297
+ p.cancel('Cancelled.');
298
+ process.exit(0);
299
+ }
300
+ // ── Double-confirm for destructive reset ──
301
+ if (action === 'reset') {
302
+ p.note('This will overwrite ALL hooks, skills, agents, and workflow config\n' +
303
+ 'with the default templates. Modified and pinned files will be replaced.\n' +
304
+ 'Your scopes, database, and orbital.config.json are preserved.', 'Warning');
305
+ const confirmReset = await p.confirm({
306
+ message: 'Are you sure you want to reset all templates?',
307
+ initialValue: false,
308
+ });
309
+ if (p.isCancel(confirmReset) || !confirmReset) {
310
+ p.cancel('Reset cancelled.');
311
+ process.exit(0);
312
+ }
313
+ const doubleConfirm = await p.confirm({
314
+ message: 'This cannot be undone. Continue?',
315
+ initialValue: false,
316
+ });
317
+ if (p.isCancel(doubleConfirm) || !doubleConfirm) {
318
+ p.cancel('Reset cancelled.');
319
+ process.exit(0);
320
+ }
321
+ }
322
+ result.action = action;
323
+ return result;
141
324
  }
142
325
  // ─── Template Version Stamping ─────────────────────────────────
143
326
  function stampTemplateVersion(projectRoot, packageVersion) {
@@ -148,7 +331,9 @@ function stampTemplateVersion(projectRoot, packageVersion) {
148
331
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
149
332
  if (config.templateVersion !== packageVersion) {
150
333
  config.templateVersion = packageVersion;
151
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
334
+ const tmp = configPath + `.tmp.${process.pid}`;
335
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
336
+ fs.renameSync(tmp, configPath);
152
337
  }
153
338
  }
154
339
  catch { /* ignore malformed config */ }
@@ -14,8 +14,7 @@ export async function phaseWelcome(state) {
14
14
  const action = await p.select({
15
15
  message: 'What would you like to do?',
16
16
  options: [
17
- { value: 'reinit', label: 'Re-initialize (--force)', hint: 'reset all templates to defaults' },
18
- { value: 'configure', label: 'Open config editor', hint: 'modify settings without resetting' },
17
+ { value: 'configure', label: 'Open config editor', hint: 'modify settings' },
19
18
  { value: 'cancel', label: 'Cancel' },
20
19
  ],
21
20
  });
@@ -27,8 +26,6 @@ export async function phaseWelcome(state) {
27
26
  await runConfigEditor(state.projectRoot, state.packageVersion, []);
28
27
  process.exit(0);
29
28
  }
30
- // Re-init — continue through the full project setup with force
31
- return true;
32
29
  }
33
30
  // Not initialized — continue normally
34
31
  return false;
@@ -15,14 +15,13 @@ Everything is driven by config files and hooks inside your project's
15
15
  ${pc.cyan('.claude/')} directory — no database or external service required.`,
16
16
  setupComplete: `${pc.bold('Setup complete.')}
17
17
 
18
- ${pc.cyan('orbital init')} Add a project (run in a project directory)
19
- ${pc.cyan('orbital launch --open')} Start the dashboard
18
+ ${pc.cyan('orbital')} Add a project or launch the dashboard
20
19
  ${pc.cyan('orbital doctor')} Health check & version info`,
21
- addProject: `You can add projects now or later with ${pc.cyan('orbital init')}.
20
+ addProject: `You can add projects now or later by running ${pc.cyan('orbital')} in a project directory.
22
21
  Each project gets its own workflow, scopes, and quality gates.`,
23
22
  // Phase 2: Project setup (runs per-project)
24
23
  reconfigure: `This project is already initialized with Orbital Command.
25
- You can reconfigure settings or run ${pc.cyan('orbital init --force')} to reset.`,
24
+ You can reconfigure settings or select ${pc.cyan('Reset to defaults')} from the hub menu.`,
26
25
  projectConfig: `${pc.bold('Project Config')} ${pc.dim('(.claude/orbital.config.json)')}
27
26
 
28
27
  Each project gets its own config inside ${pc.cyan('.claude/')}. The project
@@ -45,16 +44,16 @@ ${pc.cyan('Workflow')} Your selected preset defining lists and transitions
45
44
  ${pc.cyan('Quality Gates')} Automated checks (lint, typecheck, tests) before transitions`,
46
45
  nextSteps: `${pc.bold('Next Steps')}
47
46
 
48
- 1. ${pc.cyan('orbital launch --open')} Open the dashboard
47
+ 1. Run ${pc.cyan('orbital')} and select ${pc.bold('Launch dashboard')}
49
48
  2. Create a scope from the board or use ${pc.cyan('/scope-create')}
50
49
  3. Use ${pc.cyan('/scope-implement')} to start working on a scope
51
50
 
52
51
  ${pc.bold('Useful Commands')}
53
52
 
53
+ ${pc.cyan('orbital')} Hub menu — launch, config, doctor, etc.
54
54
  ${pc.cyan('orbital status')} See template sync status
55
55
  ${pc.cyan('orbital config')} Modify project settings
56
- ${pc.cyan('orbital update')} Sync to latest templates
57
- ${pc.cyan('orbital doctor')} Health check & version info`,
56
+ ${pc.cyan('orbital update')} Sync to latest templates`,
58
57
  };
59
58
  // ─── Formatting Helpers ─────────────────────────────────────────
60
59
  export function formatDetectedCommands(commands) {