superdev-cli 0.2.0 → 0.2.1

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.
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Superdev - tell it what you want to build, and it keeps the product map, documentation, decisions and status in step while specialist providers do the specialist work.",
8
- "version": "0.2.0",
8
+ "version": "0.2.1",
9
9
  "homepage": "https://github.com/superdev-ai/superdev"
10
10
  },
11
11
  "plugins": [
@@ -13,7 +13,7 @@
13
13
  "name": "superdev",
14
14
  "source": "./",
15
15
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence - plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
16
- "version": "0.2.0",
16
+ "version": "0.2.1",
17
17
  "author": {
18
18
  "name": "Rahul Retnan"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
5
5
  "author": {
6
6
  "name": "Rahul Retnan"
@@ -16,6 +16,6 @@
16
16
  "homepage": "https://github.com/superdev-ai/superdev",
17
17
  "repository": "https://github.com/superdev-ai/superdev",
18
18
  "requires": {
19
- "cli": "0.2.0"
19
+ "cli": "0.2.1"
20
20
  }
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard.",
5
5
  "author": {
6
6
  "name": "Rahul Retnan"
@@ -22,6 +22,6 @@
22
22
  "homepage": "https://github.com/superdev-ai/superdev",
23
23
  "repository": "https://github.com/superdev-ai/superdev",
24
24
  "requires": {
25
- "cli": "0.2.0"
25
+ "cli": "0.2.1"
26
26
  }
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Local-first product-building control system for humans and coding agents.",
6
6
  "author": "Rahul Retnan",
package/src/cli.mjs CHANGED
@@ -242,6 +242,10 @@ Flags worth knowing
242
242
  feature specify --not <text>
243
243
  What the feature deliberately does not do. Repeatable.
244
244
  Its counterpart is --in, and --out stays global
245
+ ui | start --port <number>
246
+ Serve on this port instead of 4317. Every project defaults
247
+ to the same port, so the second one to start is refused
248
+ and needs this
245
249
 
246
250
  Exit codes
247
251
  0 it worked, 1 something was found or refused, 2 the command was misused`;
@@ -757,6 +761,23 @@ const service = () => import("./service/manage.mjs");
757
761
  // later rename of one field cannot silently make `ui` claim nothing is running.
758
762
  const isRunning = (report) => report?.state === "running" || report?.running === true;
759
763
 
764
+ /**
765
+ * The port to serve on, when the default one is taken.
766
+ *
767
+ * The refusal for a held port told the reader to start on another port and no
768
+ * command could. This is that flag, validated here rather than in two places:
769
+ * `ui` and `start` are the same decision made twice, and a port that is not a
770
+ * usable number has to be refused before a process is spawned against it.
771
+ */
772
+ function portFrom(ctx) {
773
+ if (ctx.flags.port === undefined) return null;
774
+ const value = Number(ctx.flags.port);
775
+ if (!Number.isInteger(value) || value < 1024 || value > 65535) {
776
+ throw new UsageError(`--port takes a whole number between 1024 and 65535. ${JSON.stringify(String(ctx.flags.port))} is not one.`);
777
+ }
778
+ return value;
779
+ }
780
+
760
781
  async function cmdUi(ctx) {
761
782
  const { serviceStatus, startService } = await service();
762
783
  const current = await serviceStatus(ctx.root);
@@ -769,21 +790,25 @@ async function cmdUi(ctx) {
769
790
  ]),
770
791
  };
771
792
  }
793
+ const port = portFrom(ctx);
772
794
  if (!ctx.apply) {
773
795
  return planned(current, "start the control center",
774
- R.wrap("The control center is not running. Starting it opens one local process for this project and listens on the loopback address only."));
796
+ R.wrap(`The control center is not running. Starting it opens one local process for this project and listens on the loopback address only${port ? `, on port ${port}` : ""}.`));
775
797
  }
776
- const started = await startService(ctx.root, { actor: ctx.actor });
798
+ const started = await startService(ctx.root, { actor: ctx.actor, ...(port ? { port } : {}) });
777
799
  return { data: { applied: true, service: started }, text: report(ctx, "Control center started", started) };
778
800
  }
779
801
 
780
802
  async function cmdStart(ctx) {
781
803
  const { startService, serviceStatus } = await service();
804
+ const port = portFrom(ctx);
782
805
  if (!ctx.apply) {
783
806
  return planned(await serviceStatus(ctx.root), "start the local service",
784
- "Starting the service opens one local process for this project.");
807
+ port
808
+ ? `Starting the service opens one local process for this project, on port ${port}.`
809
+ : "Starting the service opens one local process for this project.");
785
810
  }
786
- const started = await startService(ctx.root, { actor: ctx.actor });
811
+ const started = await startService(ctx.root, { actor: ctx.actor, ...(port ? { port } : {}) });
787
812
  return { data: { applied: true, service: started }, text: report(ctx, "Service started", started) };
788
813
  }
789
814
 
@@ -22,7 +22,7 @@
22
22
  // Secrets never reach the output: everything user facing goes through
23
23
  // sanitizeExternal, which strips secret-shaped values and home paths.
24
24
 
25
- import { existsSync, mkdirSync, readFileSync, writeFileSync, writeSync, realpathSync } from "node:fs";
25
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, writeSync, realpathSync } from "node:fs";
26
26
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
27
  import { paths, query } from "../db/store.mjs";
28
28
  import { availableMigrations } from "../db/migrate.mjs";
@@ -472,11 +472,28 @@ async function postToolUse(root, payload) {
472
472
  if (!MUTATING_TOOLS.has(tool) && !viaShell) return EMPTY;
473
473
 
474
474
  let file = payload?.tool_input?.file_path ?? payload?.tool_input?.notebook_path ?? payload?.tool_input?.path;
475
+ // Whether anything is newly changed, decided before the paths are marked.
476
+ //
477
+ // git reports a file as changed until it is committed, so a shell command run
478
+ // after a task is completed re-reported the very files that task had produced,
479
+ // and the readiness report raised its only high-severity warning about work
480
+ // that had just been finished, evidenced and recorded. Any redirect or heredoc
481
+ // is enough to make the hook look, so this happened on the first command after
482
+ // every completion.
483
+ //
484
+ // The paths already seen are the answer. Work noticed twice is one piece of
485
+ // work; a path not seen before is a genuine change. Computed here because
486
+ // markTouched below would otherwise record these paths and make every one of
487
+ // them look familiar.
488
+ let fresh = true;
475
489
  if (viaShell) {
476
490
  const changed = await changedFiles(root);
477
491
  // A command that looked like a write and changed nothing is not work.
478
492
  if (!changed.length) return EMPTY;
493
+ const moved = movedSince(root, changed, readTouched(root).attributed);
494
+ fresh = moved.length > 0;
479
495
  markTouched(root, changed);
496
+ attribute(root, moved);
480
497
  file = file ?? changed[0];
481
498
  }
482
499
  markTouched(root, [file]);
@@ -487,7 +504,7 @@ async function postToolUse(root, payload) {
487
504
  // files touched by the active task; when there is no active task, that
488
505
  // absence is the fact worth keeping. Rate limited so a long editing run
489
506
  // leaves one honest marker rather than one per keystroke.
490
- await noteUntrackedWork(root, file).catch(() => {});
507
+ if (fresh) await noteUntrackedWork(root, file).catch(() => {});
491
508
  await markDocsPossiblyStale(root, file).catch(() => {});
492
509
  return EMPTY;
493
510
  }
@@ -521,9 +538,12 @@ function readTouched(root) {
521
538
  total: Number.isFinite(parsed.total) ? parsed.total : 0,
522
539
  since: typeof parsed.since === "string" ? parsed.since : null,
523
540
  lastEventAt: typeof parsed.lastEventAt === "string" ? parsed.lastEventAt : null,
541
+ // When each path was last accounted for, which `paths` cannot answer
542
+ // because a flush empties it.
543
+ attributed: parsed.attributed && typeof parsed.attributed === "object" ? parsed.attributed : {},
524
544
  };
525
545
  } catch {
526
- return { paths: [], total: 0, since: null, lastEventAt: null };
546
+ return { paths: [], total: 0, since: null, lastEventAt: null, attributed: {} };
527
547
  }
528
548
  }
529
549
 
@@ -537,6 +557,61 @@ function writeTouched(root, state) {
537
557
  }
538
558
  }
539
559
 
560
+ /**
561
+ * Whether a path is the product, rather than Superdev's own bookkeeping about it.
562
+ *
563
+ * markTouched has always skipped `.superdev/`, and the freshness comparison did
564
+ * not, so the runtime directory was picking up modification stamps. One of those
565
+ * files is the database write-ahead log, which moves on every single command, so
566
+ * every command would have looked like new work and the comparison would have
567
+ * decided nothing. Both callers now ask the same question in the same place.
568
+ */
569
+ const isProjectWork = (rel) => Boolean(rel) && !rel.startsWith(".superdev/");
570
+
571
+ /**
572
+ * Which of these paths have actually moved since they were last accounted for.
573
+ *
574
+ * git reports a file as changed until it is committed, so every shell command run
575
+ * between an edit and its commit re-reports the same files. That made the hook
576
+ * record fresh untracked work for edits a completed task had already produced and
577
+ * evidenced, and the readiness report then raised its only high-severity warning
578
+ * about work that was finished. Any redirect or heredoc is enough to make the hook
579
+ * look, so it happened on the first command after every completion.
580
+ *
581
+ * The pending path list cannot answer this, because a flush empties it. A
582
+ * modification time can: a path re-reported without moving is the same work seen
583
+ * again, and a path whose file has changed since is new work whoever is holding
584
+ * the task. Missing stats count as moved, because failing to notice real work is
585
+ * the worse mistake of the two.
586
+ */
587
+ export function movedSince(root, candidates, attributed) {
588
+ candidates = candidates.filter(isProjectWork);
589
+ const moved = [];
590
+ for (const path of candidates) {
591
+ let stamp = null;
592
+ try {
593
+ stamp = statSync(join(root, path)).mtimeMs;
594
+ } catch {
595
+ // Deleted, or unreadable. Either way it is not the file we accounted for.
596
+ moved.push([path, Date.now()]);
597
+ continue;
598
+ }
599
+ if (stamp > (Number(attributed[path]) || 0)) moved.push([path, stamp]);
600
+ }
601
+ return moved;
602
+ }
603
+
604
+ /** Record that these paths are accounted for, at the state they are in now. */
605
+ function attribute(root, moved) {
606
+ if (!moved.length) return;
607
+ const state = readTouched(root);
608
+ const next = { ...state.attributed };
609
+ for (const [path, stamp] of moved) next[path] = stamp;
610
+ // Bounded, oldest first, so a long-lived project does not grow this forever.
611
+ const entries = Object.entries(next).sort((a, b) => b[1] - a[1]).slice(0, MAX_TOUCHED);
612
+ writeTouched(root, { ...state, attributed: Object.fromEntries(entries) });
613
+ }
614
+
540
615
  /**
541
616
  * Record which project-relative paths changed.
542
617
  *
@@ -550,7 +625,7 @@ export function markTouched(root, files) {
550
625
  if (!file) continue;
551
626
  const rel = relative(root, canonical(root, file)).split(sep).join("/");
552
627
  if (!rel || rel.startsWith("../") || isAbsolute(rel)) continue;
553
- if (rel.startsWith(".superdev/")) continue; // Superdev's own runtime state is not project work.
628
+ if (!isProjectWork(rel)) continue;
554
629
  kept.push(rel);
555
630
  }
556
631
  if (!kept.length) return null;
@@ -562,6 +637,7 @@ export function markTouched(root, files) {
562
637
  total: state.total + kept.length,
563
638
  since: state.since ?? new Date().toISOString(),
564
639
  lastEventAt: state.lastEventAt,
640
+ attributed: state.attributed,
565
641
  };
566
642
  writeTouched(root, next);
567
643
  return next;
@@ -633,7 +709,10 @@ export async function flushTouched(root, { force = false } = {}) {
633
709
  metadata: { files: state.paths.length, changes: state.total },
634
710
  }).catch((err) => ({ recorded: false, reason: message(err) }));
635
711
 
636
- writeTouched(root, { paths: [], total: 0, since: null, lastEventAt: new Date().toISOString() });
712
+ writeTouched(root, {
713
+ paths: [], total: 0, since: null, lastEventAt: new Date().toISOString(),
714
+ attributed: state.attributed,
715
+ });
637
716
  return result;
638
717
  }
639
718
 
@@ -135,6 +135,19 @@ export function probeHealth(host, port, timeout = PROBE_TIMEOUT_MS) {
135
135
  });
136
136
  }
137
137
 
138
+ /**
139
+ * A path a reader can retype, shortened at home so a long absolute path does not
140
+ * bury the part that identifies the project.
141
+ *
142
+ * Every message about a port used to describe it without saying whose it was,
143
+ * even though the health probe had already been handed the answer, so finding out
144
+ * meant opening candidate directories one at a time.
145
+ */
146
+ export function displayRoot(path) {
147
+ const home = homedir();
148
+ return home && path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
149
+ }
150
+
138
151
  /**
139
152
  * What is actually true for this project right now. Distinguishes a service we
140
153
  * manage from a stale lock, from a start in progress, from someone else holding
@@ -160,14 +173,31 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
160
173
  };
161
174
  }
162
175
  if (health) {
176
+ // Not running, and the default port happens to be busy. Those are two
177
+ // separate facts and only the first one is about this project.
178
+ //
179
+ // This used to report `foreign`, which made both commands lie. `start`
180
+ // never blocked on it: the server scans forward from the requested port on
181
+ // EADDRINUSE, so a second project quietly lands on the next free one, and
182
+ // the message told the reader to do something the tool was already doing.
183
+ // `stop` did worse. On a project with nothing running it refused, and told
184
+ // the reader to go and stop a different project, which they have no reason
185
+ // to touch.
186
+ //
187
+ // Reserving `foreign` for the case that is genuinely somebody else's
188
+ // service in our place is what makes the word mean something.
163
189
  return {
164
- state: "foreign",
190
+ state: "stopped",
165
191
  managed: false,
166
192
  lock: null,
167
193
  health,
168
- port: candidate,
194
+ port: null,
169
195
  host,
170
- explanation: `Port ${candidate} is held by a Superdev service for a different project. Start this one on another port.`,
196
+ portInUse: candidate,
197
+ heldBy: health.projectRoot ?? null,
198
+ explanation: `Nothing is running for this project. Port ${candidate} is held by ${
199
+ health.projectRoot ? `the Superdev service for ${displayRoot(health.projectRoot)}` : "another Superdev service"
200
+ }, so starting this one will use the next free port instead. Pass --port <number> to choose.`,
171
201
  };
172
202
  }
173
203
  return {
@@ -217,7 +247,9 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
217
247
  health,
218
248
  port: lock.port,
219
249
  host: lock.host ?? host,
220
- explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes. Something else took the port.`,
250
+ explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes${
251
+ health.projectRoot ? `: it belongs to ${displayRoot(health.projectRoot)}` : ""
252
+ }. This project's service is gone and something else took its port. Run \`superdev start --apply\` to bring this one back on a free port.`,
221
253
  };
222
254
  }
223
255
  return {
@@ -335,7 +367,12 @@ export async function startService(root, options = {}) {
335
367
  );
336
368
  if (!held) {
337
369
  if (view.state === "foreign") {
338
- throw new ServiceError(E.FOREIGN, view.explanation, { port: view.port });
370
+ throw new ServiceError(E.FOREIGN, view.explanation, {
371
+ port: view.port,
372
+ // The caller cannot act on a refusal whose subject it cannot see, and the
373
+ // probe already knows it. `superdev services` prints it; a script reads it.
374
+ heldBy: view.health?.projectRoot ?? null,
375
+ });
339
376
  }
340
377
  return { ...summarize(root, view), alreadyRunning: true };
341
378
  }