@starlove/orbit 1.2.0 → 1.3.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/src/cli/orbit.js CHANGED
@@ -4,8 +4,9 @@
4
4
  * copies managed SKILL-ORBIT.md into the target repo. Does not import board.js.
5
5
  */
6
6
 
7
- import { spawnSync } from "node:child_process";
8
- import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, unlinkSync } from "node:fs";
9
+ import { homedir } from "node:os";
9
10
  import { dirname, join, resolve } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { removeOrbitAgentsSection, syncAgentsMd, syncSkillOrbitMd } from "../core/agents-md.js";
@@ -17,20 +18,23 @@ function printUsage() {
17
18
  orbit -v, --version Print the Orbit CLI version
18
19
  orbit init [options] Provision .orbit/board.db + SKILL-ORBIT.md + AGENTS.md
19
20
  orbit reset [options] Delete .orbit/ + SKILL-ORBIT.md + registry row
20
- orbit serve [options] Start the Orbit web app + HTTP API
21
+ orbit run [options] Start the Orbit web app + HTTP API
21
22
  orbit docker [options] Build/run Orbit's web app in Docker
22
23
  orbit mcp [options] Start the stdio MCP server (for agent clients)
23
24
  orbit dispatch [options] Dispatch a Hermes profile on a ticket
24
25
 
25
- Options (init / reset / serve / docker / mcp / dispatch):
26
+ Options (init / reset / run / docker / mcp / dispatch):
26
27
  --cwd <dir> Project root (default: process.cwd())
27
28
 
28
29
  Options (init only):
29
30
  --no-ai Create the board with AI collaboration disabled
30
31
  --example Create example onboarding tickets
31
32
 
32
- Options (serve / docker):
33
- --port <n> HTTP port (default: 3337, or $PORT)
33
+ Options (run / docker):
34
+ --port <n> HTTP port (run auto-starts at 13701; --port/$PORT is exact)
35
+
36
+ Options (run):
37
+ -d, --detach Run in the background and print the child PID/log path
34
38
 
35
39
  Options (dispatch):
36
40
  --board <slug-or-id> Local board to dispatch against (default: board for --cwd)
@@ -173,6 +177,7 @@ function parseArgs(argv) {
173
177
  ai: true,
174
178
  example: false,
175
179
  port: null,
180
+ detachRequested: false,
176
181
  image: "starscape-orbit:local",
177
182
  dataDir: null,
178
183
  containerName: null,
@@ -301,6 +306,7 @@ function parseArgs(argv) {
301
306
  args.buildImage = false;
302
307
  } else if (a === "-d" || a === "--detach") {
303
308
  args.detach = true;
309
+ args.detachRequested = true;
304
310
  } else if (a === "--foreground") {
305
311
  args.detach = false;
306
312
  args.foreground = true;
@@ -461,6 +467,37 @@ async function runServe(options) {
461
467
  await import("../server.js");
462
468
  }
463
469
 
470
+ function orbitDataDir() {
471
+ return process.env.DATA_DIR || join(homedir(), ".orbit");
472
+ }
473
+
474
+ function startDetachedRun(options) {
475
+ const dataDir = orbitDataDir();
476
+ mkdirSync(dataDir, { recursive: true });
477
+ const logPath = join(dataDir, "orbit-run.log");
478
+ const logFd = openSync(logPath, "a");
479
+ const cliPath = fileURLToPath(import.meta.url);
480
+ const childArgs = [cliPath, "run", "--cwd", resolve(options.cwd)];
481
+ if (options.port) childArgs.push("--port", String(options.port));
482
+ const child = spawn(process.execPath, childArgs, {
483
+ cwd: process.cwd(),
484
+ detached: true,
485
+ env: { ...process.env },
486
+ stdio: ["ignore", logFd, logFd]
487
+ });
488
+ child.unref();
489
+ closeSync(logFd);
490
+ const port = options.port || process.env.PORT || 13701;
491
+ console.log("Started Orbit in detached mode.");
492
+ console.log(`PID: ${child.pid}`);
493
+ console.log(`Log: ${logPath}`);
494
+ console.log(
495
+ options.port || process.env.PORT
496
+ ? `URL: http://localhost:${port}`
497
+ : `URL: starts at http://localhost:${port} (auto-increments if busy; see log)`
498
+ );
499
+ }
500
+
464
501
  function containerPathFor(hostPath, fallbackPath) {
465
502
  // Linux/macOS Docker can mount the same absolute path into the container,
466
503
  // which keeps existing Orbit board metadata meaningful. Windows paths are
@@ -473,7 +510,7 @@ function buildDockerPlan(options) {
473
510
  const dataDir = resolve(options.dataDir || resolve(projectRoot, ".orbit", "docker-data"));
474
511
  const containerProjectRoot = containerPathFor(projectRoot, "/workspace");
475
512
  const containerDataDir = containerPathFor(dataDir, "/data");
476
- const port = String(options.port || process.env.PORT || 3337);
513
+ const port = String(options.port || process.env.PORT || 13701);
477
514
 
478
515
  if (!existsSync(projectRoot)) {
479
516
  throw new Error(`Project root does not exist: ${projectRoot}`);
@@ -508,7 +545,7 @@ function buildDockerPlan(options) {
508
545
  "-w",
509
546
  containerProjectRoot,
510
547
  options.image,
511
- "serve",
548
+ "run",
512
549
  "--cwd",
513
550
  containerProjectRoot,
514
551
  "--port",
@@ -595,8 +632,9 @@ async function main() {
595
632
  await runInit(args);
596
633
  } else if (args.command === "reset") {
597
634
  await runReset(args);
598
- } else if (args.command === "serve") {
599
- await runServe(args);
635
+ } else if (args.command === "run" || args.command === "serve") {
636
+ if (args.detachRequested && args.detach) startDetachedRun(args);
637
+ else await runServe(args);
600
638
  } else if (args.command === "docker") {
601
639
  await runDocker(args);
602
640
  } else if (args.command === "mcp") {
@@ -33,6 +33,21 @@ export function syncSkillOrbitMd(projectRoot, skillSrc) {
33
33
  return { ok: true, status: "written", source: skillSrc, path: skillDest };
34
34
  }
35
35
 
36
+ /**
37
+ * Write both Orbit helper files (SKILL-ORBIT.md + the AGENTS.md Orbit section)
38
+ * into a project root. This is the shared core behind `orbit init` and the
39
+ * web-UI "Generate AI helper files" checkbox, so the two flows stay identical.
40
+ * Never throws — returns a result the caller can log or ignore.
41
+ */
42
+ export function generateHelperFiles(projectRoot, skillSrc, logger = console.log) {
43
+ if (!existsSync(skillSrc)) {
44
+ return { ok: false, status: "missing_source", source: skillSrc };
45
+ }
46
+ const skill = syncSkillOrbitMd(projectRoot, skillSrc);
47
+ syncAgentsMd(projectRoot, {}, logger);
48
+ return { ok: true, status: skill.status, skill };
49
+ }
50
+
36
51
  export function syncAgentsMd(projectRoot, _options = {}, logger = console.log) {
37
52
  const agentsPath = resolve(projectRoot, "AGENTS.md");
38
53
  const section = orbitAgentsSection();
package/src/core/board.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // 3. Iterate every registry board, ensuring the per-board schema exists
9
9
  // and reindexing FTS for that board's DB. No "active board" singleton:
10
10
  // each iteration opens its own connection.
11
- // 4. When launched through `orbit serve`, refresh Orbit-managed
11
+ // 4. When launched through `orbit run`, refresh Orbit-managed
12
12
  // SKILL-ORBIT.md copies for registered repo paths.
13
13
  //
14
14
  // Beyond startup this module just re-exports per-domain functions for
@@ -67,6 +67,9 @@ function syncManagedSkillOrbitFiles() {
67
67
  const skillSrc = resolve(ROOT_DIR, "SKILL-ORBIT.md");
68
68
  for (const row of listBoards()) {
69
69
  if (!row.repo_path) continue;
70
+ // Respect boards created without "Generate AI helper files" — don't
71
+ // re-push SKILL-ORBIT.md into a repo the user opted out of.
72
+ if (row.manage_helper_files === 0) continue;
70
73
  try {
71
74
  const result = syncSkillOrbitMd(row.repo_path, skillSrc);
72
75
  if (result.status === "missing_source") {
@@ -107,10 +110,10 @@ export const boardRuntime = {
107
110
  port: PORT
108
111
  };
109
112
 
110
- export function startupSummary() {
113
+ export function startupSummary(port = PORT) {
111
114
  const rows = listBoards();
112
115
  return {
113
- url: `http://localhost:${PORT}`,
116
+ url: `http://localhost:${port}`,
114
117
  registryPath: REGISTRY_DB_PATH,
115
118
  boardCount: rows.length,
116
119
  boardPaths: rows.map((row) => row.db_path)
@@ -9,8 +9,10 @@ import {
9
9
  import { requireBoardAccess, requirePermission } from "./auth.js";
10
10
  import { recordEvent } from "./events.js";
11
11
  import { buildBoardFromRepo } from "./seed.js";
12
+ import { generateHelperFiles } from "./agents-md.js";
12
13
  import { httpError, id, now, normalizePath, normalizeProjectEntryType, requiredString, slugify } from "./util.js";
13
14
  import { DATA_DIR, MCP_SERVER_PATH, ROOT_DIR } from "./paths.js";
15
+ import { resolve } from "node:path";
14
16
 
15
17
  /** Create a brand-new board on disk + register it. Does not require an
16
18
  * existing ctx.board / ctx.db: this is registry-only. */
@@ -20,7 +22,13 @@ export function createBoard(body, ctx) {
20
22
 
21
23
  const name = requiredString(body.name, "name");
22
24
  const slug = slugify(body.slug || name);
23
- const repoPath = normalizePath(requiredString(body.repo_path || body.system_path, "repo_path"));
25
+ // Folder is optional: a board without a repo_path is a pure kanban board (no
26
+ // agent dispatch, no managed helper files). When present it is the coding
27
+ // project where SKILL-ORBIT.md / AGENTS.md live for the AI agent's reference.
28
+ const repoPath = normalizePath(body.repo_path || body.system_path || "");
29
+ // Only meaningful with a folder; default on so the common "link my repo" path
30
+ // keeps writing the docs. Stored so the serve-time refresh respects opt-out.
31
+ const manageHelperFiles = repoPath ? body.manage_helper_files !== false : false;
24
32
  const reg = getRegistry();
25
33
  if (reg.prepare("SELECT id FROM boards WHERE slug = ?").get(slug)) {
26
34
  throw httpError(409, "board_slug_taken");
@@ -31,7 +39,7 @@ export function createBoard(body, ctx) {
31
39
 
32
40
  const boardId = id();
33
41
  const time = now();
34
- const sniff = buildBoardFromRepo(repoPath);
42
+ const sniff = repoPath ? buildBoardFromRepo(repoPath) : { repoUrl: "", defaultBranch: "main" };
35
43
 
36
44
  const aiEnabled = body.ai_enabled === undefined ? true : Boolean(body.ai_enabled);
37
45
  const defaultStates = body.states || (aiEnabled ? [
@@ -92,11 +100,23 @@ export function createBoard(body, ctx) {
92
100
  db_path: dbPath,
93
101
  repo_url: body.repo_url || sniff.repoUrl || "",
94
102
  default_branch: body.default_branch || sniff.defaultBranch || "main",
103
+ manage_helper_files: manageHelperFiles ? 1 : 0,
95
104
  last_active_at: time,
96
105
  created_at: time,
97
106
  updated_at: time
98
107
  });
99
108
 
109
+ // Mirror `orbit init`: write SKILL-ORBIT.md + the AGENTS.md Orbit section into
110
+ // the project so the browser flow leaves the same files on disk. Best-effort —
111
+ // a board is still created if the files can't be written.
112
+ if (manageHelperFiles) {
113
+ try {
114
+ generateHelperFiles(repoPath, resolve(ROOT_DIR, "SKILL-ORBIT.md"));
115
+ } catch (error) {
116
+ console.warn(`[orbit] Could not write helper files in ${repoPath}: ${error.message}`);
117
+ }
118
+ }
119
+
100
120
  return boardById(db, boardId);
101
121
  }
102
122
 
@@ -232,25 +252,47 @@ export function updateBoard(boardId, body, ctx) {
232
252
  values.push(body.ai_enabled ? 1 : 0);
233
253
  }
234
254
 
235
- if (sets.length === 0) return innerBoard;
236
- sets.push("updated_at = ?");
237
- values.push(now(), boardId);
238
- tx(db, () => {
239
- db.prepare(`UPDATE boards SET ${sets.join(", ")} WHERE id = ?`).run(...values);
240
- if (aiTurningOn) ensureAgentLanes(db, boardId);
241
- recordEvent(db, boardId, "board_updated", null, actor.name, { board_id: boardId, fields: Object.keys(body) });
242
- });
255
+ // manage_helper_files lives on the registry row (not the per-board DB), so it
256
+ // is handled separately from `sets`. A request that only toggles it must
257
+ // still be processed even when no per-board columns change.
258
+ const manageHelperFiles =
259
+ body.manage_helper_files === undefined ? undefined : Boolean(body.manage_helper_files);
260
+
261
+ if (sets.length === 0 && manageHelperFiles === undefined) return innerBoard;
262
+
263
+ if (sets.length > 0) {
264
+ sets.push("updated_at = ?");
265
+ values.push(now(), boardId);
266
+ tx(db, () => {
267
+ db.prepare(`UPDATE boards SET ${sets.join(", ")} WHERE id = ?`).run(...values);
268
+ if (aiTurningOn) ensureAgentLanes(db, boardId);
269
+ recordEvent(db, boardId, "board_updated", null, actor.name, { board_id: boardId, fields: Object.keys(body) });
270
+ });
271
+ }
243
272
 
244
273
  // Mirror the changed fields into the registry row so cross-board listings
245
274
  // stay coherent without needing a second source of truth.
246
275
  const after = boardById(db, boardId);
276
+ const repoPath = normalizePath(after.system_path || "");
247
277
  updateBoardMeta(boardId, {
248
278
  name: after.name,
249
279
  repo_url: after.repo_url,
250
280
  default_branch: after.default_branch,
251
- repo_path: normalizePath(after.system_path || "")
281
+ repo_path: repoPath,
282
+ ...(manageHelperFiles === undefined ? {} : { manage_helper_files: manageHelperFiles ? 1 : 0 })
252
283
  });
253
284
 
285
+ // Generating the helper files is the active half of turning the toggle on:
286
+ // write them now rather than waiting for the next `orbit run` refresh.
287
+ // Turning it off just stops the managed refresh; existing files are left.
288
+ if (manageHelperFiles && repoPath) {
289
+ try {
290
+ generateHelperFiles(repoPath, resolve(ROOT_DIR, "SKILL-ORBIT.md"));
291
+ } catch (error) {
292
+ console.warn(`[orbit] Could not write helper files in ${repoPath}: ${error.message}`);
293
+ }
294
+ }
295
+
254
296
  return after;
255
297
  }
256
298
 
@@ -284,6 +326,7 @@ export function boardManual(boardId, ctx, options = {}) {
284
326
  project_notes: innerBoard.project_notes,
285
327
  agent_instructions: innerBoard.agent_instructions,
286
328
  ai_enabled: innerBoard.ai_enabled,
329
+ manage_helper_files: ctx.board.manage_helper_files,
287
330
  created_at: innerBoard.created_at,
288
331
  updated_at: innerBoard.updated_at
289
332
  },
package/src/core/db.js CHANGED
@@ -103,6 +103,15 @@ export function getRegistry() {
103
103
  return openConnection(REGISTRY_DB_PATH);
104
104
  }
105
105
 
106
+ /** Add a column to an existing table only if it is missing. Idempotent: safe to
107
+ * call on every startup. SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe
108
+ * `PRAGMA table_info` first. */
109
+ function ensureColumn(reg, table, column, definition) {
110
+ const cols = reg.prepare(`PRAGMA table_info(${table})`).all();
111
+ if (cols.some((c) => c.name === column)) return;
112
+ reg.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
113
+ }
114
+
106
115
  /** Registry metadata table (not the per-board `boards` content row). */
107
116
  export function createRegistrySchema(reg = getRegistry()) {
108
117
  reg.exec(`
@@ -120,6 +129,10 @@ export function createRegistrySchema(reg = getRegistry()) {
120
129
  );
121
130
  CREATE INDEX IF NOT EXISTS idx_registry_boards_repo_path ON boards(repo_path);
122
131
  `);
132
+ // Whether Orbit keeps SKILL-ORBIT.md / AGENTS.md in sync inside repo_path.
133
+ // Defaults to 1 so pre-existing boards (created when files were always
134
+ // written) keep their managed copies refreshed on startup.
135
+ ensureColumn(reg, "boards", "manage_helper_files", "INTEGER NOT NULL DEFAULT 1");
123
136
  }
124
137
 
125
138
  /** Full Kanban schema on a per-board database connection. */
@@ -178,7 +178,7 @@ export function migrateInRepoBoards() {
178
178
  console.error(`\n[orbit] ⚠ Could not auto-migrate ${n} board${n > 1 ? "s" : ""} to central storage.`);
179
179
  console.error(`${sep} These boards continue to work from their current paths.`);
180
180
  console.error(`${sep} To complete the migration manually, run the commands below and`);
181
- console.error(`${sep} restart orbit serve:\n`);
181
+ console.error(`${sep} restart orbit run:\n`);
182
182
  for (const r of failed) {
183
183
  const slash = process.platform === "win32" ? "\\" : "/";
184
184
  const destDir = dirname(r.newPath).replace(/\//g, slash);
package/src/core/paths.js CHANGED
@@ -22,7 +22,7 @@ export const EXPORT_DIR = join(DATA_DIR, "exports");
22
22
  export const BACKUP_DIR = join(DATA_DIR, "backups");
23
23
  /** Central directory for dispatch run artifacts: DATA_DIR/dispatch-runs/<board-slug>/<run-id>/ */
24
24
  export const DISPATCH_RUNS_DIR = join(DATA_DIR, "dispatch-runs");
25
- export const PORT = Number(process.env.PORT || 3337);
25
+ export const PORT = Number(process.env.PORT || 13701);
26
26
 
27
27
  mkdirSync(DATA_DIR, { recursive: true });
28
28
  mkdirSync(BOARDS_DIR, { recursive: true });
@@ -60,8 +60,8 @@ export function insertBoard(row) {
60
60
  const t = row.created_at || now();
61
61
  getRegistry()
62
62
  .prepare(
63
- `INSERT INTO boards (id, slug, name, repo_path, db_path, repo_url, default_branch, last_active_at, created_at, updated_at)
64
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
63
+ `INSERT INTO boards (id, slug, name, repo_path, db_path, repo_url, default_branch, manage_helper_files, last_active_at, created_at, updated_at)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
65
65
  )
66
66
  .run(
67
67
  row.id,
@@ -71,6 +71,7 @@ export function insertBoard(row) {
71
71
  row.db_path,
72
72
  row.repo_url || "",
73
73
  row.default_branch || "main",
74
+ row.manage_helper_files === undefined ? 1 : row.manage_helper_files ? 1 : 0,
74
75
  row.last_active_at || t,
75
76
  t,
76
77
  row.updated_at || t
@@ -80,7 +81,7 @@ export function insertBoard(row) {
80
81
  export function updateBoardMeta(boardId, patch) {
81
82
  const sets = [];
82
83
  const vals = [];
83
- for (const k of ["slug", "name", "repo_path", "db_path", "repo_url", "default_branch"]) {
84
+ for (const k of ["slug", "name", "repo_path", "db_path", "repo_url", "default_branch", "manage_helper_files"]) {
84
85
  if (patch[k] !== undefined) {
85
86
  sets.push(`${k} = ?`);
86
87
  vals.push(patch[k]);
package/src/server.js CHANGED
@@ -40,8 +40,30 @@ const server = createServer(async (req, res) => {
40
40
  }
41
41
  });
42
42
 
43
- const summary = startupSummary();
44
- server.listen(boardRuntime.port, () => {
43
+ const requestedPort = boardRuntime.port;
44
+ const hasExplicitPort = Boolean(process.env.PORT);
45
+ const maxAutoPort = requestedPort + 99;
46
+ let currentPort = requestedPort;
47
+
48
+ server.on("error", (error) => {
49
+ if (!hasExplicitPort && error?.code === "EADDRINUSE" && currentPort < maxAutoPort) {
50
+ currentPort += 1;
51
+ console.warn(`[orbit] Port ${currentPort - 1} is in use; trying ${currentPort}.`);
52
+ server.listen(currentPort);
53
+ return;
54
+ }
55
+
56
+ console.error(error);
57
+ closeAllConnections();
58
+ process.exit(1);
59
+ });
60
+
61
+ server.listen(currentPort);
62
+
63
+ server.on("listening", () => {
64
+ const address = server.address();
65
+ const port = typeof address === "object" && address ? address.port : currentPort;
66
+ const summary = startupSummary(port);
45
67
  console.log(`Starscape Orbit listening on ${summary.url}`);
46
68
  console.log(`Registry: ${summary.registryPath}`);
47
69
  console.log(`Boards (${summary.boardCount}):`);