orbital-command 1.0.1 → 1.1.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 (45) hide show
  1. package/README.md +1 -1
  2. package/bin/orbital.js +3 -3
  3. package/dist/assets/Landing-B6q9U0Vd.js +11 -0
  4. package/dist/assets/{PrimitivesConfig-DThSipFy.js → PrimitivesConfig-TlFvypvg.js} +9 -14
  5. package/dist/assets/{QualityGates-B4kxM5UU.js → QualityGates-D8uvclW4.js} +1 -1
  6. package/dist/assets/SessionTimeline-QUaJw6Aa.js +1 -0
  7. package/dist/assets/Settings-CAEnAZAk.js +12 -0
  8. package/dist/assets/{SourceControl-BMNIz7Lt.js → SourceControl-DPeSBaMV.js} +7 -12
  9. package/dist/assets/{WorkflowVisualizer-CxuSBOYu.js → WorkflowVisualizer-DHrIjx6W.js} +1 -1
  10. package/dist/assets/{arrow-down-DVPp6_qp.js → arrow-down-DnfKgF33.js} +1 -1
  11. package/dist/assets/{bot-NFaJBDn_.js → bot-DUPnHZfM.js} +1 -1
  12. package/dist/assets/{circle-x-IsFCkBZu.js → circle-x-B4nA79je.js} +1 -1
  13. package/dist/assets/{file-text-J1cebZXF.js → file-text-PnzRO4pO.js} +1 -1
  14. package/dist/assets/{globe-WzeyHsUc.js → globe-CIX_GBr0.js} +1 -1
  15. package/dist/assets/index-B-B-tTjw.css +1 -0
  16. package/dist/assets/index-DQVAzHMu.js +354 -0
  17. package/dist/assets/{key-CKR8JJSj.js → key-DpAZM-3d.js} +1 -1
  18. package/dist/assets/{minus-CHBsJyjp.js → minus-CJlpKNUB.js} +1 -1
  19. package/dist/assets/{radio-xqZaR-Uk.js → radio-DhynLcSx.js} +1 -1
  20. package/dist/assets/{rocket-D_xvvNG6.js → rocket-DnuQh3sF.js} +1 -1
  21. package/dist/assets/{shield-TdB1yv_a.js → shield-DLMQmvFQ.js} +1 -1
  22. package/dist/assets/{ui-BmsSg9jU.js → ui-QhrRH5wk.js} +6 -6
  23. package/dist/assets/{useSocketListener-0L5yiN5i.js → useSocketListener-DPrExNDV.js} +1 -1
  24. package/dist/assets/{useWorkflowEditor-CqeRWVQX.js → useWorkflowEditor-BxN7phfr.js} +2 -2
  25. package/dist/assets/{workflow-constants-Rw-GmgHZ.js → workflow-constants-ZcCN11vm.js} +1 -1
  26. package/dist/assets/{zap-C9wqYMpl.js → zap-eJ3z8HRI.js} +1 -1
  27. package/dist/index.html +3 -3
  28. package/dist/server/server/index.js +8 -1
  29. package/dist/server/server/routes/sync-routes.js +175 -0
  30. package/dist/server/server/wizard/index.js +18 -24
  31. package/dist/server/server/wizard/phases/setup-wizard.js +4 -34
  32. package/dist/server/server/wizard/types.js +1 -22
  33. package/dist/server/shared/workflow-presets.js +22 -0
  34. package/package.json +1 -1
  35. package/server/index.ts +7 -1
  36. package/server/routes/sync-routes.ts +205 -0
  37. package/server/wizard/index.ts +17 -31
  38. package/server/wizard/phases/setup-wizard.ts +7 -38
  39. package/server/wizard/types.ts +2 -28
  40. package/shared/workflow-presets.ts +28 -0
  41. package/dist/assets/Landing-CfQdHR0N.js +0 -11
  42. package/dist/assets/SessionTimeline-Bz1iZnmg.js +0 -1
  43. package/dist/assets/Settings-DLcZwbCT.js +0 -12
  44. package/dist/assets/index-BdJ57EhC.css +0 -1
  45. package/dist/assets/index-o4ScMAuR.js +0 -349
@@ -1,4 +1,4 @@
1
- import{c as a}from"./index-o4ScMAuR.js";/**
1
+ import{c as a}from"./index-DQVAzHMu.js";/**
2
2
  * @license lucide-react v0.577.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
package/dist/index.html CHANGED
@@ -8,11 +8,11 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-o4ScMAuR.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-DQVAzHMu.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/vendor-Bqt8AJn2.js">
13
- <link rel="modulepreload" crossorigin href="/assets/ui-BmsSg9jU.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/ui-QhrRH5wk.js">
14
14
  <link rel="modulepreload" crossorigin href="/assets/charts-LGLb8hyU.js">
15
- <link rel="stylesheet" crossorigin href="/assets/index-BdJ57EhC.css">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-B-B-tTjw.css">
16
16
  </head>
17
17
  <body class="h-full">
18
18
  <div id="root" class="h-full"></div>
@@ -104,7 +104,14 @@ export async function startCentralServer(overrides) {
104
104
  app.use('/api/orbital', createAggregateRoutes({ projectManager, io, syncService }));
105
105
  // ─── Static File Serving ─────────────────────────────────
106
106
  const __selfDir = path.dirname(fileURLToPath(import.meta.url));
107
- const distDir = path.resolve(__selfDir, '../dist');
107
+ // Find package root — works from both source (server/) and compiled (dist/server/server/)
108
+ let pkgRoot = __selfDir;
109
+ while (pkgRoot !== path.dirname(pkgRoot)) {
110
+ if (fs.existsSync(path.join(pkgRoot, 'package.json')))
111
+ break;
112
+ pkgRoot = path.dirname(pkgRoot);
113
+ }
114
+ const distDir = path.join(pkgRoot, 'dist');
108
115
  const hasBuiltFrontend = fs.existsSync(path.join(distDir, 'index.html'));
109
116
  const devMode = !hasBuiltFrontend;
110
117
  if (hasBuiltFrontend) {
@@ -1,5 +1,11 @@
1
1
  import { Router } from 'express';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execFile } from 'child_process';
2
5
  import { isValidRelativePath } from '../utils/route-helpers.js';
6
+ import { runInit } from '../init.js';
7
+ import { loadGlobalConfig } from '../global-config.js';
8
+ import { getPackageVersion } from '../utils/package-info.js';
3
9
  export function createSyncRoutes({ syncService, projectManager }) {
4
10
  const router = Router();
5
11
  // ─── Sync State ─────────────────────────────────────────
@@ -130,5 +136,174 @@ export function createSyncRoutes({ syncService, projectManager }) {
130
136
  return res.status(404).json({ error: 'Project not found' });
131
137
  res.json(updated);
132
138
  });
139
+ // ─── Project Creation (Frontend Setup) ───────────────────
140
+ /** POST /projects/browse — open native folder picker (macOS) */
141
+ router.post('/projects/browse', (_req, res) => {
142
+ if (process.platform !== 'darwin') {
143
+ return res.json({ error: 'not_supported' });
144
+ }
145
+ execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select your project folder")'], { timeout: 60_000 }, (err, stdout) => {
146
+ if (err) {
147
+ // User pressed Cancel — osascript exits with code 1
148
+ if (err.code === 1) {
149
+ return res.json({ cancelled: true });
150
+ }
151
+ return res.json({ error: err.message });
152
+ }
153
+ const selectedPath = stdout.trim();
154
+ if (!selectedPath)
155
+ return res.json({ cancelled: true });
156
+ res.json({ path: selectedPath });
157
+ });
158
+ });
159
+ /** POST /projects/check-path — validate a path and detect git status */
160
+ router.post('/projects/check-path', (req, res) => {
161
+ const { path: rawPath } = req.body;
162
+ if (!rawPath || !rawPath.trim()) {
163
+ return res.json({ valid: false, error: 'Path is required' });
164
+ }
165
+ const absPath = path.resolve(rawPath.trim());
166
+ if (!fs.existsSync(absPath)) {
167
+ return res.json({ valid: false, absPath, error: 'Directory does not exist' });
168
+ }
169
+ try {
170
+ const stat = fs.statSync(absPath);
171
+ if (!stat.isDirectory()) {
172
+ return res.json({ valid: false, absPath, error: 'Path must be a directory' });
173
+ }
174
+ }
175
+ catch {
176
+ return res.json({ valid: false, absPath, error: 'Cannot access path' });
177
+ }
178
+ const hasGit = fs.existsSync(path.join(absPath, '.git'));
179
+ const suggestedName = path.basename(absPath)
180
+ .replace(/[-_]/g, ' ')
181
+ .replace(/\b\w/g, c => c.toUpperCase());
182
+ // Check if already registered
183
+ const config = loadGlobalConfig();
184
+ const alreadyRegistered = config.projects.some(p => p.path === absPath);
185
+ res.json({
186
+ valid: true,
187
+ absPath,
188
+ hasGit,
189
+ suggestedName,
190
+ alreadyRegistered,
191
+ });
192
+ });
193
+ /** POST /projects/create — full project initialization (init + register + seed) */
194
+ router.post('/projects/create', async (req, res) => {
195
+ const { path: rawPath, name, color, preset, initGit } = req.body;
196
+ if (!rawPath || !name || !color) {
197
+ return res.status(400).json({ error: 'path, name, and color are required' });
198
+ }
199
+ const absPath = path.resolve(rawPath.trim());
200
+ // Validate directory exists
201
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) {
202
+ return res.status(400).json({ error: 'Path must be an existing directory' });
203
+ }
204
+ // Check if already registered
205
+ const config = loadGlobalConfig();
206
+ if (config.projects.some(p => p.path === absPath)) {
207
+ return res.status(409).json({ error: 'A project is already registered at this path' });
208
+ }
209
+ try {
210
+ // 1. Optional git init
211
+ if (initGit && !fs.existsSync(path.join(absPath, '.git'))) {
212
+ await new Promise((resolve, reject) => {
213
+ execFile('git', ['init'], { cwd: absPath, timeout: 10_000 }, (err) => {
214
+ if (err)
215
+ reject(new Error(`git init failed: ${err.message}`));
216
+ else
217
+ resolve();
218
+ });
219
+ });
220
+ }
221
+ // 2. Run full project init (templates, hooks, skills, agents, config, etc.)
222
+ const selectedPreset = preset || 'default';
223
+ runInit(absPath, {
224
+ quiet: true,
225
+ preset: selectedPreset,
226
+ projectName: name,
227
+ });
228
+ // 3. Stamp template version
229
+ const pkgVersion = getPackageVersion();
230
+ const configPath = path.join(absPath, '.claude', 'orbital.config.json');
231
+ if (fs.existsSync(configPath)) {
232
+ try {
233
+ const projConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
234
+ projConfig.templateVersion = pkgVersion;
235
+ const tmp = configPath + `.tmp.${process.pid}`;
236
+ fs.writeFileSync(tmp, JSON.stringify(projConfig, null, 2) + '\n', 'utf8');
237
+ fs.renameSync(tmp, configPath);
238
+ }
239
+ catch { /* ignore malformed config */ }
240
+ }
241
+ // 4. Register + initialize context + emit socket event
242
+ const summary = await projectManager.addProject(absPath, { name, color });
243
+ // 5. Seed welcome scope card
244
+ seedWelcomeCard(absPath, selectedPreset);
245
+ res.status(201).json(summary);
246
+ }
247
+ catch (err) {
248
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
249
+ }
250
+ });
133
251
  return router;
134
252
  }
253
+ // ─── Helpers ──────────────────────────────────────────────
254
+ function seedWelcomeCard(projectRoot, preset) {
255
+ // Determine the planning directory from the preset
256
+ const presetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), 'templates', 'presets');
257
+ let planningDir = 'planning'; // default fallback
258
+ try {
259
+ const presetPath = path.join(presetsDir, `${preset}.json`);
260
+ if (fs.existsSync(presetPath)) {
261
+ const workflow = JSON.parse(fs.readFileSync(presetPath, 'utf8'));
262
+ // Find the first list that has a directory and isn't an entry point
263
+ const entryId = workflow.entryPoint;
264
+ const entryList = workflow.lists?.find((l) => l.id === entryId);
265
+ // Use the first forward target of the entry point, or the second list
266
+ if (entryList?.forwardTargets?.[0]) {
267
+ planningDir = entryList.forwardTargets[0];
268
+ }
269
+ else if (workflow.lists?.[1]?.hasDirectory) {
270
+ planningDir = workflow.lists[1].id;
271
+ }
272
+ }
273
+ }
274
+ catch { /* use default */ }
275
+ const scopesDir = path.join(projectRoot, 'scopes', planningDir);
276
+ if (!fs.existsSync(scopesDir)) {
277
+ fs.mkdirSync(scopesDir, { recursive: true });
278
+ }
279
+ const cardPath = path.join(scopesDir, '001-welcome.md');
280
+ if (fs.existsSync(cardPath))
281
+ return; // don't overwrite
282
+ const content = `---
283
+ title: Welcome to Orbital Command
284
+ status: ${planningDir}
285
+ priority: low
286
+ tags: [onboarding]
287
+ ---
288
+
289
+ # Welcome to Orbital Command
290
+
291
+ Your project is set up and ready to go. Here are some things to try:
292
+
293
+ ## Getting Started
294
+
295
+ 1. **Create a scope** — Scopes are units of work. Use \`/scope-create\` in Claude Code or create a markdown file in the \`scopes/\` directory.
296
+ 2. **Explore the dashboard** — Use the sidebar to navigate between views: Kanban, Primitives, Guards, Repo, Sessions, and Workflow.
297
+ 3. **Launch a session** — Start a Claude Code session from the Sessions view to begin working on this scope.
298
+
299
+ ## Key Concepts
300
+
301
+ - **Scopes** are markdown files with YAML frontmatter that track units of work
302
+ - **Workflow** defines the columns and transitions on your Kanban board
303
+ - **Guards** enforce quality gates before status transitions
304
+ - **Primitives** are the hooks, skills, and agents that power your setup
305
+
306
+ You can archive this card once you're comfortable with the basics.
307
+ `;
308
+ fs.writeFileSync(cardPath, content, 'utf8');
309
+ }
@@ -18,7 +18,6 @@ import { phaseWelcome } from './phases/welcome.js';
18
18
  import { phaseProjectSetup } from './phases/project-setup.js';
19
19
  import { phaseWorkflowSetup } from './phases/workflow-setup.js';
20
20
  import { phaseConfirm, showPostInstall } from './phases/confirm.js';
21
- import { NOTES } from './ui.js';
22
21
  import { runConfigEditor } from './config-editor.js';
23
22
  import { runDoctor } from './doctor.js';
24
23
  import { isITerm2Available } from '../adapters/iterm2-adapter.js';
@@ -26,24 +25,14 @@ import { registerProject } from '../global-config.js';
26
25
  export { runConfigEditor, runDoctor };
27
26
  // ─── Phase 1: Setup Wizard ─────────────────────────────────────
28
27
  /**
29
- * First-time setup. Creates ~/.orbital/, seeds primitives,
30
- * optionally links projects (running Phase 2 for each).
28
+ * First-time setup. Creates ~/.orbital/ and seeds primitives.
29
+ * Project setup is now handled by the frontend Add Project modal.
31
30
  */
32
31
  export async function runSetupWizard(packageVersion) {
33
32
  const state = buildSetupState(packageVersion);
34
33
  p.intro(`${pc.bgCyan(pc.black(' Orbital Command '))} ${pc.dim(`v${packageVersion}`)}`);
35
34
  await phaseSetupWizard(state);
36
- // If user linked projects, run Phase 2 for each
37
- for (const projectRoot of state.linkedProjects) {
38
- p.log.step(`Setting up ${pc.cyan(path.basename(projectRoot))}...`);
39
- await runProjectSetupInline(projectRoot, packageVersion);
40
- }
41
- if (state.linkedProjects.length === 0) {
42
- p.note(NOTES.setupComplete, 'Done');
43
- }
44
- p.outro(state.linkedProjects.length > 0
45
- ? `Run ${pc.cyan('orbital')} to launch the dashboard.`
46
- : `Run ${pc.cyan('orbital')} in a project directory to get started.`);
35
+ p.outro(`Run ${pc.cyan('orbital')} to launch the dashboard and add your first project.`);
47
36
  }
48
37
  // ─── Phase 2: Project Setup ────────────────────────────────────
49
38
  /**
@@ -94,21 +83,26 @@ async function runProjectPhases(state, useForce) {
94
83
  }
95
84
  showPostInstall(state);
96
85
  }
97
- /**
98
- * Inline project setup — called from Phase 1 when user links a project.
99
- * Skips intro/outro since the setup wizard already has those.
100
- */
101
- async function runProjectSetupInline(projectRoot, packageVersion) {
102
- const state = buildProjectState(projectRoot, packageVersion);
103
- // Skip welcome gate for inline this is a fresh project being linked
104
- await runProjectPhases(state, false);
86
+ /** Returns true if `a` is older than `b` (semver comparison). */
87
+ function isOlderThan(a, b) {
88
+ const pa = a.match(/^(\d+)\.(\d+)\.(\d+)/);
89
+ const pb = b.match(/^(\d+)\.(\d+)\.(\d+)/);
90
+ if (!pa || !pb)
91
+ return false;
92
+ for (let i = 1; i <= 3; i++) {
93
+ if (parseInt(pa[i]) < parseInt(pb[i]))
94
+ return true;
95
+ if (parseInt(pa[i]) > parseInt(pb[i]))
96
+ return false;
97
+ }
98
+ return false;
105
99
  }
106
100
  async function checkForUpdate(currentVersion, cache) {
107
101
  // Use cache if checked within 24 hours
108
102
  if (cache.lastUpdateCheck && cache.latestVersion) {
109
103
  const age = Date.now() - new Date(cache.lastUpdateCheck).getTime();
110
104
  if (age < 24 * 60 * 60 * 1000) {
111
- const isOutdated = cache.latestVersion !== currentVersion;
105
+ const isOutdated = isOlderThan(currentVersion, cache.latestVersion);
112
106
  return {
113
107
  info: { current: currentVersion, latest: cache.latestVersion, isOutdated },
114
108
  cacheChanged: false,
@@ -123,7 +117,7 @@ async function checkForUpdate(currentVersion, cache) {
123
117
  const data = await res.json();
124
118
  const latest = data.version;
125
119
  return {
126
- info: { current: currentVersion, latest, isOutdated: latest !== currentVersion },
120
+ info: { current: currentVersion, latest, isOutdated: isOlderThan(currentVersion, latest) },
127
121
  cacheChanged: true,
128
122
  };
129
123
  }
@@ -6,10 +6,9 @@
6
6
  */
7
7
  import fs from 'fs';
8
8
  import * as p from '@clack/prompts';
9
- import pc from 'picocolors';
10
9
  import { NOTES } from '../ui.js';
11
- import { isValidProjectPath, resolveProjectPath, ORBITAL_HOME } from '../detect.js';
12
- export async function phaseSetupWizard(state) {
10
+ import { ORBITAL_HOME } from '../detect.js';
11
+ export async function phaseSetupWizard(_state) {
13
12
  // Welcome and core concepts
14
13
  p.note(NOTES.setupWelcome, 'Welcome');
15
14
  // Create ~/.orbital/ and seed primitives
@@ -32,35 +31,6 @@ export async function phaseSetupWizard(state) {
32
31
  p.log.error(err instanceof Error ? err.message : String(err));
33
32
  process.exit(1);
34
33
  }
35
- // Offer to link projects
36
- p.note(NOTES.addProject, 'Projects');
37
- let addMore = true;
38
- while (addMore) {
39
- const wantsProject = await p.confirm({
40
- message: state.linkedProjects.length === 0
41
- ? 'Add a project now?'
42
- : 'Add another project?',
43
- initialValue: state.linkedProjects.length === 0,
44
- });
45
- if (p.isCancel(wantsProject) || !wantsProject) {
46
- addMore = false;
47
- break;
48
- }
49
- const projectPath = await p.text({
50
- message: 'Project path',
51
- placeholder: '~/Code/my-project',
52
- validate: (val) => {
53
- if (!val || !val.trim())
54
- return 'Path is required';
55
- return isValidProjectPath(val.trim());
56
- },
57
- });
58
- if (p.isCancel(projectPath)) {
59
- addMore = false;
60
- break;
61
- }
62
- const resolved = resolveProjectPath(projectPath.trim());
63
- state.linkedProjects.push(resolved);
64
- p.log.success(`Added: ${pc.cyan(resolved)}`);
65
- }
34
+ // Direct user to the dashboard for project setup
35
+ p.note('Launch the dashboard to add your first project.\nThe setup wizard will guide you through it.', 'Next Steps');
66
36
  }
@@ -5,25 +5,4 @@
5
5
  * Phase 1 (SetupState) — first-time Orbital setup, ~/.orbital/ creation
6
6
  * Phase 2 (ProjectSetupState) — per-project scaffolding into .claude/
7
7
  */
8
- export const WORKFLOW_PRESETS = [
9
- {
10
- value: 'default',
11
- label: 'Default',
12
- hint: '7 lists, trunk-based — Icebox → Planning → Backlog → Implementing → Review → Completed → Main',
13
- },
14
- {
15
- value: 'minimal',
16
- label: 'Minimal',
17
- hint: '3 lists — To Do → In Progress → Done',
18
- },
19
- {
20
- value: 'development',
21
- label: 'Development',
22
- hint: '5 lists, dev branch — Backlog → Implementing → Review → Completed → Dev',
23
- },
24
- {
25
- value: 'gitflow',
26
- label: 'Gitflow',
27
- hint: '9 lists, multi-branch — Full pipeline with Dev, Staging, and Production',
28
- },
29
- ];
8
+ export { WORKFLOW_PRESETS } from '../../shared/workflow-presets.js';
@@ -0,0 +1,22 @@
1
+ export const WORKFLOW_PRESETS = [
2
+ {
3
+ value: 'default',
4
+ label: 'Default',
5
+ hint: '7 lists, trunk-based — Icebox → Planning → Backlog → Implementing → Review → Completed → Main',
6
+ },
7
+ {
8
+ value: 'minimal',
9
+ label: 'Minimal',
10
+ hint: '3 lists — To Do → In Progress → Done',
11
+ },
12
+ {
13
+ value: 'development',
14
+ label: 'Development',
15
+ hint: '5 lists, dev branch — Backlog → Implementing → Review → Completed → Dev',
16
+ },
17
+ {
18
+ value: 'gitflow',
19
+ label: 'Gitflow',
20
+ hint: '9 lists, multi-branch — Full pipeline with Dev, Staging, and Production',
21
+ },
22
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orbital-command",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Orbital Command — mission control dashboard for Claude Code projects",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server/index.ts CHANGED
@@ -148,7 +148,13 @@ export async function startCentralServer(overrides?: CentralServerOverrides): Pr
148
148
  // ─── Static File Serving ─────────────────────────────────
149
149
 
150
150
  const __selfDir = path.dirname(fileURLToPath(import.meta.url));
151
- const distDir = path.resolve(__selfDir, '../dist');
151
+ // Find package root — works from both source (server/) and compiled (dist/server/server/)
152
+ let pkgRoot = __selfDir;
153
+ while (pkgRoot !== path.dirname(pkgRoot)) {
154
+ if (fs.existsSync(path.join(pkgRoot, 'package.json'))) break;
155
+ pkgRoot = path.dirname(pkgRoot);
156
+ }
157
+ const distDir = path.join(pkgRoot, 'dist');
152
158
  const hasBuiltFrontend = fs.existsSync(path.join(distDir, 'index.html'));
153
159
  const devMode = !hasBuiltFrontend;
154
160
  if (hasBuiltFrontend) {
@@ -1,7 +1,13 @@
1
1
  import { Router } from 'express';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execFile } from 'child_process';
2
5
  import type { SyncService } from '../services/sync-service.js';
3
6
  import type { ProjectManager } from '../project-manager.js';
4
7
  import { isValidRelativePath } from '../utils/route-helpers.js';
8
+ import { runInit } from '../init.js';
9
+ import { loadGlobalConfig } from '../global-config.js';
10
+ import { getPackageVersion } from '../utils/package-info.js';
5
11
 
6
12
  interface SyncRouteDeps {
7
13
  syncService: SyncService;
@@ -171,5 +177,204 @@ export function createSyncRoutes({ syncService, projectManager }: SyncRouteDeps)
171
177
  res.json(updated);
172
178
  });
173
179
 
180
+ // ─── Project Creation (Frontend Setup) ───────────────────
181
+
182
+ /** POST /projects/browse — open native folder picker (macOS) */
183
+ router.post('/projects/browse', (_req, res) => {
184
+ if (process.platform !== 'darwin') {
185
+ return res.json({ error: 'not_supported' });
186
+ }
187
+
188
+ execFile(
189
+ 'osascript',
190
+ ['-e', 'POSIX path of (choose folder with prompt "Select your project folder")'],
191
+ { timeout: 60_000 },
192
+ (err, stdout) => {
193
+ if (err) {
194
+ // User pressed Cancel — osascript exits with code 1
195
+ if (err.code === 1) {
196
+ return res.json({ cancelled: true });
197
+ }
198
+ return res.json({ error: err.message });
199
+ }
200
+ const selectedPath = stdout.trim();
201
+ if (!selectedPath) return res.json({ cancelled: true });
202
+ res.json({ path: selectedPath });
203
+ },
204
+ );
205
+ });
206
+
207
+ /** POST /projects/check-path — validate a path and detect git status */
208
+ router.post('/projects/check-path', (req, res) => {
209
+ const { path: rawPath } = req.body as { path: string };
210
+ if (!rawPath || !rawPath.trim()) {
211
+ return res.json({ valid: false, error: 'Path is required' });
212
+ }
213
+
214
+ const absPath = path.resolve(rawPath.trim());
215
+
216
+ if (!fs.existsSync(absPath)) {
217
+ return res.json({ valid: false, absPath, error: 'Directory does not exist' });
218
+ }
219
+
220
+ try {
221
+ const stat = fs.statSync(absPath);
222
+ if (!stat.isDirectory()) {
223
+ return res.json({ valid: false, absPath, error: 'Path must be a directory' });
224
+ }
225
+ } catch {
226
+ return res.json({ valid: false, absPath, error: 'Cannot access path' });
227
+ }
228
+
229
+ const hasGit = fs.existsSync(path.join(absPath, '.git'));
230
+ const suggestedName = path.basename(absPath)
231
+ .replace(/[-_]/g, ' ')
232
+ .replace(/\b\w/g, c => c.toUpperCase());
233
+
234
+ // Check if already registered
235
+ const config = loadGlobalConfig();
236
+ const alreadyRegistered = config.projects.some(p => p.path === absPath);
237
+
238
+ res.json({
239
+ valid: true,
240
+ absPath,
241
+ hasGit,
242
+ suggestedName,
243
+ alreadyRegistered,
244
+ });
245
+ });
246
+
247
+ /** POST /projects/create — full project initialization (init + register + seed) */
248
+ router.post('/projects/create', async (req, res) => {
249
+ const { path: rawPath, name, color, preset, initGit } = req.body as {
250
+ path: string;
251
+ name: string;
252
+ color: string;
253
+ preset?: string;
254
+ initGit?: boolean;
255
+ };
256
+
257
+ if (!rawPath || !name || !color) {
258
+ return res.status(400).json({ error: 'path, name, and color are required' });
259
+ }
260
+
261
+ const absPath = path.resolve(rawPath.trim());
262
+
263
+ // Validate directory exists
264
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) {
265
+ return res.status(400).json({ error: 'Path must be an existing directory' });
266
+ }
267
+
268
+ // Check if already registered
269
+ const config = loadGlobalConfig();
270
+ if (config.projects.some(p => p.path === absPath)) {
271
+ return res.status(409).json({ error: 'A project is already registered at this path' });
272
+ }
273
+
274
+ try {
275
+ // 1. Optional git init
276
+ if (initGit && !fs.existsSync(path.join(absPath, '.git'))) {
277
+ await new Promise<void>((resolve, reject) => {
278
+ execFile('git', ['init'], { cwd: absPath, timeout: 10_000 }, (err) => {
279
+ if (err) reject(new Error(`git init failed: ${err.message}`));
280
+ else resolve();
281
+ });
282
+ });
283
+ }
284
+
285
+ // 2. Run full project init (templates, hooks, skills, agents, config, etc.)
286
+ const selectedPreset = preset || 'default';
287
+ runInit(absPath, {
288
+ quiet: true,
289
+ preset: selectedPreset,
290
+ projectName: name,
291
+ });
292
+
293
+ // 3. Stamp template version
294
+ const pkgVersion = getPackageVersion();
295
+ const configPath = path.join(absPath, '.claude', 'orbital.config.json');
296
+ if (fs.existsSync(configPath)) {
297
+ try {
298
+ const projConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
299
+ projConfig.templateVersion = pkgVersion;
300
+ const tmp = configPath + `.tmp.${process.pid}`;
301
+ fs.writeFileSync(tmp, JSON.stringify(projConfig, null, 2) + '\n', 'utf8');
302
+ fs.renameSync(tmp, configPath);
303
+ } catch { /* ignore malformed config */ }
304
+ }
305
+
306
+ // 4. Register + initialize context + emit socket event
307
+ const summary = await projectManager.addProject(absPath, { name, color });
308
+
309
+ // 5. Seed welcome scope card
310
+ seedWelcomeCard(absPath, selectedPreset);
311
+
312
+ res.status(201).json(summary);
313
+ } catch (err) {
314
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
315
+ }
316
+ });
317
+
174
318
  return router;
175
319
  }
320
+
321
+ // ─── Helpers ──────────────────────────────────────────────
322
+
323
+ function seedWelcomeCard(projectRoot: string, preset: string): void {
324
+ // Determine the planning directory from the preset
325
+ const presetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), 'templates', 'presets');
326
+ let planningDir = 'planning'; // default fallback
327
+
328
+ try {
329
+ const presetPath = path.join(presetsDir, `${preset}.json`);
330
+ if (fs.existsSync(presetPath)) {
331
+ const workflow = JSON.parse(fs.readFileSync(presetPath, 'utf8'));
332
+ // Find the first list that has a directory and isn't an entry point
333
+ const entryId = workflow.entryPoint;
334
+ const entryList = workflow.lists?.find((l: { id: string }) => l.id === entryId);
335
+ // Use the first forward target of the entry point, or the second list
336
+ if (entryList?.forwardTargets?.[0]) {
337
+ planningDir = entryList.forwardTargets[0];
338
+ } else if (workflow.lists?.[1]?.hasDirectory) {
339
+ planningDir = workflow.lists[1].id;
340
+ }
341
+ }
342
+ } catch { /* use default */ }
343
+
344
+ const scopesDir = path.join(projectRoot, 'scopes', planningDir);
345
+ if (!fs.existsSync(scopesDir)) {
346
+ fs.mkdirSync(scopesDir, { recursive: true });
347
+ }
348
+
349
+ const cardPath = path.join(scopesDir, '001-welcome.md');
350
+ if (fs.existsSync(cardPath)) return; // don't overwrite
351
+
352
+ const content = `---
353
+ title: Welcome to Orbital Command
354
+ status: ${planningDir}
355
+ priority: low
356
+ tags: [onboarding]
357
+ ---
358
+
359
+ # Welcome to Orbital Command
360
+
361
+ Your project is set up and ready to go. Here are some things to try:
362
+
363
+ ## Getting Started
364
+
365
+ 1. **Create a scope** — Scopes are units of work. Use \`/scope-create\` in Claude Code or create a markdown file in the \`scopes/\` directory.
366
+ 2. **Explore the dashboard** — Use the sidebar to navigate between views: Kanban, Primitives, Guards, Repo, Sessions, and Workflow.
367
+ 3. **Launch a session** — Start a Claude Code session from the Sessions view to begin working on this scope.
368
+
369
+ ## Key Concepts
370
+
371
+ - **Scopes** are markdown files with YAML frontmatter that track units of work
372
+ - **Workflow** defines the columns and transitions on your Kanban board
373
+ - **Guards** enforce quality gates before status transitions
374
+ - **Primitives** are the hooks, skills, and agents that power your setup
375
+
376
+ You can archive this card once you're comfortable with the basics.
377
+ `;
378
+
379
+ fs.writeFileSync(cardPath, content, 'utf8');
380
+ }