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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +29 -4
- package/src/runtime/hooks.mjs +84 -5
- package/src/service/manage.mjs +42 -5
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
19
|
+
"cli": "0.2.1"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
25
|
+
"cli": "0.2.1"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/package.json
CHANGED
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(
|
|
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
|
-
|
|
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
|
|
package/src/runtime/hooks.mjs
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
|
package/src/service/manage.mjs
CHANGED
|
@@ -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: "
|
|
190
|
+
state: "stopped",
|
|
165
191
|
managed: false,
|
|
166
192
|
lock: null,
|
|
167
193
|
health,
|
|
168
|
-
port:
|
|
194
|
+
port: null,
|
|
169
195
|
host,
|
|
170
|
-
|
|
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
|
|
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, {
|
|
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
|
}
|