projectstore-claude 0.29.3 → 0.30.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/README.md +13 -12
- package/bin/projectstore-claude.mjs +2 -2
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +153 -77
- package/node_modules/projectstore/commands/bind.md +6 -17
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/extending.md +11 -6
- package/node_modules/projectstore/docs/getting-started.md +21 -1
- package/node_modules/projectstore/docs/harnesses.md +13 -10
- package/node_modules/projectstore/docs/how-it-works.md +6 -4
- package/node_modules/projectstore/harnesses/claude-code.json +16 -15
- package/node_modules/projectstore/harnesses/codex.json +12 -13
- package/node_modules/projectstore/hooks/band.tsx +48 -20
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +50 -18
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +11 -2
- package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
- package/node_modules/projectstore/scripts/band.mjs +72 -21
- package/node_modules/projectstore/scripts/binding.mjs +616 -85
- package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
- package/node_modules/projectstore/scripts/cli.mjs +1015 -185
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +253 -83
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
- package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +123 -48
- package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
- package/node_modules/projectstore/scripts/kanban.mjs +2 -1
- package/node_modules/projectstore/scripts/lib.mjs +694 -159
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +378 -80
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +62 -17
- package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +5 -1
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +32 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
- package/node_modules/projectstore/scripts/term.mjs +382 -97
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/package.json +3 -3
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
// railAvailable(stdin, caps) → true when the question rail may be drawn
|
|
32
32
|
// openRail(opts) → { select, multi, confirm, path, answered, close, cancelled }
|
|
33
33
|
// CANCELLED, BACK → what a rail question resolves to on Ctrl+C or end of input, and on Esc
|
|
34
|
+
// drainInput(stdin, opts) → discards what was typed ahead, before a line-mode question
|
|
34
35
|
|
|
35
|
-
import { createInterface } from "node:readline
|
|
36
|
-
import { emitKeypressEvents } from "node:readline";
|
|
36
|
+
import { createInterface, emitKeypressEvents } from "node:readline";
|
|
37
37
|
|
|
38
38
|
// FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
|
|
39
39
|
// "2", "3" or "true" turn colour on even without a terminal, anything else
|
|
@@ -50,7 +50,10 @@ export function caps(stream = process.stdout, env = process.env) {
|
|
|
50
50
|
const tty = Boolean(stream && stream.isTTY);
|
|
51
51
|
const dumb = env.TERM === "dumb";
|
|
52
52
|
const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
|
|
53
|
-
const color =
|
|
53
|
+
const color =
|
|
54
|
+
env.FORCE_COLOR !== undefined
|
|
55
|
+
? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR))
|
|
56
|
+
: tty && !dumb && !off;
|
|
54
57
|
const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
|
|
55
58
|
const ascii = dumb || (env.PROJECTSTORE_ASCII !== undefined && env.PROJECTSTORE_ASCII !== "");
|
|
56
59
|
// `width` lays out prose (never below 40); `columns` is the terminal's
|
|
@@ -61,8 +64,15 @@ export function caps(stream = process.stdout, env = process.env) {
|
|
|
61
64
|
// The sixteen ANSI colours, bold and dim — never 256-colour or truecolor, so
|
|
62
65
|
// the reader's terminal theme decides the shades, light or dark.
|
|
63
66
|
const SGR = {
|
|
64
|
-
bold: [1, 22],
|
|
65
|
-
|
|
67
|
+
bold: [1, 22],
|
|
68
|
+
dim: [2, 22],
|
|
69
|
+
red: [31, 39],
|
|
70
|
+
green: [32, 39],
|
|
71
|
+
yellow: [33, 39],
|
|
72
|
+
blue: [34, 39],
|
|
73
|
+
magenta: [35, 39],
|
|
74
|
+
cyan: [36, 39],
|
|
75
|
+
gray: [90, 39],
|
|
66
76
|
};
|
|
67
77
|
|
|
68
78
|
// The colour roles (presentation spec contract 3), each one style above.
|
|
@@ -74,7 +84,14 @@ const SGR = {
|
|
|
74
84
|
// error ✕, a failed step, an issue count
|
|
75
85
|
// explanation notes, reasons, consent lines, the rail, the project path
|
|
76
86
|
// emphasis headings, a question's title, a finding's title
|
|
77
|
-
export const ROLES = Object.freeze({
|
|
87
|
+
export const ROLES = Object.freeze({
|
|
88
|
+
action: "cyan",
|
|
89
|
+
done: "green",
|
|
90
|
+
attention: "yellow",
|
|
91
|
+
error: "red",
|
|
92
|
+
explanation: "gray",
|
|
93
|
+
emphasis: "bold",
|
|
94
|
+
});
|
|
78
95
|
|
|
79
96
|
// Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
|
|
80
97
|
// the package promises >=20.0.0. `style` is a role or a style name; anything
|
|
@@ -126,9 +143,17 @@ export const GLYPHS = Object.freeze({
|
|
|
126
143
|
// warning / refusal / note, unchanged / skipped) and the plan's own action
|
|
127
144
|
// names, so a caller says what happened rather than which shape to draw.
|
|
128
145
|
export const GLYPH_ALIASES = Object.freeze({
|
|
129
|
-
issue: "fail",
|
|
130
|
-
|
|
131
|
-
|
|
146
|
+
issue: "fail",
|
|
147
|
+
refuse: "warning",
|
|
148
|
+
refusal: "warning",
|
|
149
|
+
note: "warning",
|
|
150
|
+
skip: "unchanged",
|
|
151
|
+
skipped: "unchanged",
|
|
152
|
+
create: "add",
|
|
153
|
+
migrate: "update",
|
|
154
|
+
refresh: "update",
|
|
155
|
+
cleanup: "remove",
|
|
156
|
+
prune: "remove",
|
|
132
157
|
// doctor's info finding: not a warning, so not ▲ — the two would differ by
|
|
133
158
|
// colour alone (decided 2026-10-11 with doctor's report).
|
|
134
159
|
info: "dot",
|
|
@@ -165,7 +190,8 @@ export function glyphText(c, text) {
|
|
|
165
190
|
export function duration(ms) {
|
|
166
191
|
if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
167
192
|
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
168
|
-
const m = Math.floor(ms / 60_000),
|
|
193
|
+
const m = Math.floor(ms / 60_000),
|
|
194
|
+
s = Math.round((ms % 60_000) / 1000);
|
|
169
195
|
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
170
196
|
}
|
|
171
197
|
|
|
@@ -182,8 +208,10 @@ export function wrap(text, width, indent = "") {
|
|
|
182
208
|
const lines = [];
|
|
183
209
|
let line = "";
|
|
184
210
|
for (const word of String(text).split(" ")) {
|
|
185
|
-
if (line && plain(line).length + 1 + plain(word).length > room) {
|
|
186
|
-
|
|
211
|
+
if (line && plain(line).length + 1 + plain(word).length > room) {
|
|
212
|
+
lines.push(line);
|
|
213
|
+
line = word;
|
|
214
|
+
} else line = line ? `${line} ${word}` : word;
|
|
187
215
|
}
|
|
188
216
|
lines.push(line);
|
|
189
217
|
return lines.map((l, k) => (k ? indent + l : l)).join("\n");
|
|
@@ -264,7 +292,14 @@ export function rows(c, list, opts = {}) {
|
|
|
264
292
|
|
|
265
293
|
export function rowLines(c, list, { indent = 2 } = {}) {
|
|
266
294
|
const paint = painter(c);
|
|
267
|
-
const cells = list.map((r) => ({
|
|
295
|
+
const cells = list.map((r) => ({
|
|
296
|
+
glyph: r.glyph ? icon(c, r.glyph) : "",
|
|
297
|
+
role: r.role || null,
|
|
298
|
+
action: String(r.action ?? ""),
|
|
299
|
+
target: String(r.target ?? ""),
|
|
300
|
+
note: String(r.note ?? ""),
|
|
301
|
+
prose: Boolean(r.prose),
|
|
302
|
+
}));
|
|
268
303
|
const widest = (xs) => Math.max(0, ...xs.map((s) => plain(s).length));
|
|
269
304
|
const gw = widest(cells.map((r) => r.glyph));
|
|
270
305
|
const aw = widest(cells.map((r) => r.action));
|
|
@@ -278,11 +313,24 @@ export function rowLines(c, list, { indent = 2 } = {}) {
|
|
|
278
313
|
if (aw) lead += cell(r.action, aw, r.role) + " ";
|
|
279
314
|
const col = plain(lead).length;
|
|
280
315
|
const pad = " ".repeat(col);
|
|
281
|
-
const alone = (r.prose && width ? lead + wrap(r.target, width, pad) : lead + r.target)
|
|
282
|
-
|
|
316
|
+
const alone = (r.prose && width ? lead + wrap(r.target, width, pad) : lead + r.target)
|
|
317
|
+
.split("\n")
|
|
318
|
+
.map((l) => l.trimEnd());
|
|
319
|
+
if (!r.note) {
|
|
320
|
+
out.push(alone);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
283
323
|
const line = `${lead}${cell(r.target, tw, null)} ${paint("explanation", r.note)}`;
|
|
284
|
-
if (!width || plain(line).length <= width) {
|
|
285
|
-
|
|
324
|
+
if (!width || plain(line).length <= width) {
|
|
325
|
+
out.push([line]);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
out.push([
|
|
329
|
+
...alone,
|
|
330
|
+
...wrap(r.note, width, pad)
|
|
331
|
+
.split("\n")
|
|
332
|
+
.map((l, k) => pad + paint("explanation", k ? l.slice(col) : l)),
|
|
333
|
+
]);
|
|
286
334
|
}
|
|
287
335
|
return out;
|
|
288
336
|
}
|
|
@@ -332,9 +380,16 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
|
|
|
332
380
|
const paint = painter(c);
|
|
333
381
|
let open = null;
|
|
334
382
|
const line = rowFor(c, indent);
|
|
335
|
-
const finished = (ok, note, kept, label, ms, over) => {
|
|
336
|
-
// A step that ran and left its target in place is neither ✓ nor
|
|
337
|
-
|
|
383
|
+
const finished = (ok, note, kept, label, ms, over, warn = false) => {
|
|
384
|
+
// A step that ran and left its target in place is neither ✓ nor ✕; one
|
|
385
|
+
// that failed without stopping the run (setup's git init) is ▲.
|
|
386
|
+
const mark = !ok
|
|
387
|
+
? paint("error", icon(c, "fail"))
|
|
388
|
+
: warn
|
|
389
|
+
? paint("attention", icon(c, "warning"))
|
|
390
|
+
: kept
|
|
391
|
+
? paint("explanation", icon(c, "skip"))
|
|
392
|
+
: paint("done", icon(c, "ok"));
|
|
338
393
|
const right = paint("explanation", [note, duration(ms)].filter(Boolean).join(" "));
|
|
339
394
|
stream.write((over && c.live ? "\r\x1b[2K" : "") + line(mark, label, right) + "\n");
|
|
340
395
|
};
|
|
@@ -346,15 +401,19 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
|
|
|
346
401
|
open = { label, t0: now() };
|
|
347
402
|
if (c.live) stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
|
|
348
403
|
},
|
|
349
|
-
end(ok = true, note = "", { kept = false, label = null } = {}) {
|
|
404
|
+
end(ok = true, note = "", { kept = false, label = null, warn = false } = {}) {
|
|
350
405
|
if (!open) return;
|
|
351
|
-
finished(ok, note, kept, label ?? open.label, now() - open.t0, true);
|
|
406
|
+
finished(ok, note, kept, label ?? open.label, now() - open.t0, true, warn);
|
|
352
407
|
open = null;
|
|
353
408
|
},
|
|
354
|
-
finish(label, t0, { ok = true, note = "" } = {}) {
|
|
409
|
+
finish(label, t0, { ok = true, note = "" } = {}) {
|
|
410
|
+
finished(ok, note, false, label, now() - t0, false);
|
|
411
|
+
},
|
|
355
412
|
// An exception left a step open: end its line as failed, so whatever is
|
|
356
413
|
// printed next starts on a line of its own.
|
|
357
|
-
abort() {
|
|
414
|
+
abort() {
|
|
415
|
+
if (open) self.end(false);
|
|
416
|
+
},
|
|
358
417
|
};
|
|
359
418
|
return self;
|
|
360
419
|
}
|
|
@@ -374,29 +433,53 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
|
|
|
374
433
|
// is known. The timers are unref'd and cleared by both `end` and `abort`, so
|
|
375
434
|
// a forgotten line never holds the process; the cursor is never hidden, so no
|
|
376
435
|
// exit path has anything to restore. `now` and `timers` are the test's clock.
|
|
377
|
-
export function liveLine(
|
|
436
|
+
export function liveLine(
|
|
437
|
+
stream = process.stdout,
|
|
438
|
+
c = caps(stream),
|
|
439
|
+
label = "",
|
|
440
|
+
{ env = process.env, now = Date.now, timers = globalThis, indent = 2 } = {},
|
|
441
|
+
) {
|
|
378
442
|
const paint = painter(c);
|
|
379
443
|
const line = rowFor(c, indent);
|
|
380
444
|
const t0 = now();
|
|
381
445
|
const still = Boolean(env.PROJECTSTORE_NO_ANIMATION);
|
|
382
|
-
let drawn = false,
|
|
383
|
-
|
|
446
|
+
let drawn = false,
|
|
447
|
+
frame = 0,
|
|
448
|
+
wait = null,
|
|
449
|
+
tick = null,
|
|
450
|
+
settled = false;
|
|
451
|
+
const unref = (h) => {
|
|
452
|
+
try {
|
|
453
|
+
h?.unref?.();
|
|
454
|
+
} catch {}
|
|
455
|
+
return h;
|
|
456
|
+
};
|
|
384
457
|
const stop = () => {
|
|
385
|
-
if (wait !== null) {
|
|
386
|
-
|
|
458
|
+
if (wait !== null) {
|
|
459
|
+
timers.clearTimeout(wait);
|
|
460
|
+
wait = null;
|
|
461
|
+
}
|
|
462
|
+
if (tick !== null) {
|
|
463
|
+
timers.clearInterval(tick);
|
|
464
|
+
tick = null;
|
|
465
|
+
}
|
|
387
466
|
};
|
|
388
467
|
const draw = () => {
|
|
389
468
|
const mark = c.ascii ? icon(c, "running") : paint("action", SPINNER[frame++ % SPINNER.length]);
|
|
390
469
|
stream.write("\r\x1b[2K" + line(mark, label, paint("explanation", duration(now() - t0)), { cut: true }));
|
|
391
470
|
drawn = true;
|
|
392
471
|
};
|
|
393
|
-
if (c.live && still) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
472
|
+
if (c.live && still) {
|
|
473
|
+
stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
|
|
474
|
+
drawn = true;
|
|
475
|
+
} else if (c.live) {
|
|
476
|
+
wait = unref(
|
|
477
|
+
timers.setTimeout(() => {
|
|
478
|
+
wait = null;
|
|
479
|
+
draw();
|
|
480
|
+
tick = unref(timers.setInterval(draw, SPIN_MS));
|
|
481
|
+
}, QUIET_MS),
|
|
482
|
+
);
|
|
400
483
|
}
|
|
401
484
|
return {
|
|
402
485
|
end(ok = true, doneLabel = label, note = "") {
|
|
@@ -423,17 +506,27 @@ export function liveLine(stream = process.stdout, c = caps(stream), label = "",
|
|
|
423
506
|
// the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
|
|
424
507
|
// anything is written). End of input — Ctrl+D, a closed pipe — is null, which
|
|
425
508
|
// every caller reads as no; it never leaves the question pending (an unsettled
|
|
426
|
-
// promise is exit 13 with a warning). The
|
|
427
|
-
//
|
|
509
|
+
// promise is exit 13 with a warning). The question is node:readline's callback
|
|
510
|
+
// form: readline/promises is experimental before Node 22.17 and 24, below the
|
|
511
|
+
// engines floor. Its callback never runs on close, so the close handler
|
|
512
|
+
// settles it.
|
|
428
513
|
export async function askLine(question, input, output) {
|
|
429
514
|
const rl = createInterface({ input, output, terminal: false });
|
|
430
515
|
return await new Promise((settle) => {
|
|
431
516
|
let done = false;
|
|
432
|
-
const finish = (answer) => {
|
|
517
|
+
const finish = (answer) => {
|
|
518
|
+
if (done) return;
|
|
519
|
+
done = true;
|
|
520
|
+
settle(answer);
|
|
521
|
+
rl.close();
|
|
522
|
+
};
|
|
433
523
|
// End of input echoes no newline: end the question's line, so whatever is
|
|
434
524
|
// printed next ("Nothing written.") starts on a line of its own.
|
|
435
|
-
rl.on("close", () => {
|
|
436
|
-
|
|
525
|
+
rl.on("close", () => {
|
|
526
|
+
if (!done) output.write("\n");
|
|
527
|
+
finish(null);
|
|
528
|
+
});
|
|
529
|
+
rl.question(question, (a) => finish(a));
|
|
437
530
|
});
|
|
438
531
|
}
|
|
439
532
|
|
|
@@ -448,7 +541,10 @@ export async function askLine(question, input, output) {
|
|
|
448
541
|
// isInteractive). `caps` is the asking stream's; without one the question is
|
|
449
542
|
// unpainted, in Unicode. `ask` stands in for the terminal in tests and means
|
|
450
543
|
// "this is one"; it gets the question and returns the answer.
|
|
451
|
-
export async function askApply(
|
|
544
|
+
export async function askApply(
|
|
545
|
+
n,
|
|
546
|
+
{ stdin = null, stdout = null, ask = null, caps: c = { color: false, ascii: false } } = {},
|
|
547
|
+
) {
|
|
452
548
|
const paint = painter(c);
|
|
453
549
|
const question = `${paint("action", icon(c, "ask"))} ${paint("emphasis", `Apply ${n === 1 ? "1 change" : `${n} changes`}?`)} ${paint("explanation", "[Y/n]")} `;
|
|
454
550
|
const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
|
|
@@ -498,12 +594,20 @@ export function railAvailable(stdin, c) {
|
|
|
498
594
|
}
|
|
499
595
|
|
|
500
596
|
// A choice as the rail draws and answers it: a string is its own value.
|
|
501
|
-
const choiceOf = (x) =>
|
|
502
|
-
|
|
503
|
-
|
|
597
|
+
const choiceOf = (x) =>
|
|
598
|
+
typeof x === "object" && x !== null
|
|
599
|
+
? {
|
|
600
|
+
value: x.value ?? x.label,
|
|
601
|
+
label: String(x.label ?? x.value),
|
|
602
|
+
reason: x.reason ? String(x.reason) : "",
|
|
603
|
+
answer: String(x.answer ?? x.label ?? x.value),
|
|
604
|
+
on: Boolean(x.on),
|
|
605
|
+
}
|
|
606
|
+
: { value: x, label: String(x), reason: "", answer: String(x), on: false };
|
|
504
607
|
|
|
505
608
|
const isEnter = (k) => k.name === "return" || k.name === "enter";
|
|
506
|
-
const printable = (k) =>
|
|
609
|
+
const printable = (k) =>
|
|
610
|
+
typeof k.str === "string" && k.str !== "" && !k.ctrl && !k.meta && !/[\x00-\x1f\x7f]/.test(k.str);
|
|
507
611
|
|
|
508
612
|
// Open a rail on a terminal: draws its top line and holds the terminal for its
|
|
509
613
|
// questions until close(). Everything the run prints while the rail is open
|
|
@@ -536,8 +640,17 @@ const printable = (k) => typeof k.str === "string" && k.str !== "" && !k.ctrl &&
|
|
|
536
640
|
// process ends as the signal would have ended it. `proc` is the process those
|
|
537
641
|
// handlers are added to, a test's emitter in tests. Never process.exit: a
|
|
538
642
|
// cancel is an answer, and the caller returns 130 (contract 9).
|
|
539
|
-
export function openRail({
|
|
540
|
-
|
|
643
|
+
export function openRail({
|
|
644
|
+
stdin = process.stdin,
|
|
645
|
+
stdout = process.stdout,
|
|
646
|
+
caps: c = caps(stdout),
|
|
647
|
+
proc = process,
|
|
648
|
+
head = "projectstore",
|
|
649
|
+
parts = [],
|
|
650
|
+
escapeMs = ESCAPE_MS,
|
|
651
|
+
} = {}) {
|
|
652
|
+
if (!railAvailable(stdin, c))
|
|
653
|
+
throw new Error("no question rail: stdin must be a terminal with setRawMode and stdout a live one (railAvailable)");
|
|
541
654
|
const paint = painter(c);
|
|
542
655
|
const bar = paint("explanation", icon(c, "rail"));
|
|
543
656
|
const sep = ` ${icon(c, "dot")} `;
|
|
@@ -547,15 +660,35 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
547
660
|
// that miscounted them would leave half a frame behind.
|
|
548
661
|
const columns = () => (Number(stdout.columns) > 0 ? Number(stdout.columns) : c.columns || 100);
|
|
549
662
|
const room = () => Math.max(20, Math.min(columns(), 100) - 1);
|
|
550
|
-
const rowsOf = (lines) => {
|
|
663
|
+
const rowsOf = (lines) => {
|
|
664
|
+
const w = columns();
|
|
665
|
+
return lines.reduce((n, l) => n + Math.max(1, Math.ceil([...plain(l)].length / w)), 0);
|
|
666
|
+
};
|
|
551
667
|
|
|
552
668
|
// ── The keys ──
|
|
553
669
|
const queue = [];
|
|
554
670
|
let waiter = null;
|
|
555
|
-
const wake = () => {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
671
|
+
const wake = () => {
|
|
672
|
+
if (waiter) {
|
|
673
|
+
const w = waiter;
|
|
674
|
+
waiter = null;
|
|
675
|
+
w();
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
const onKey = (str, key) => {
|
|
679
|
+
queue.push({ ...(key || {}), str });
|
|
680
|
+
wake();
|
|
681
|
+
};
|
|
682
|
+
const onEnd = () => {
|
|
683
|
+
queue.push(EOF);
|
|
684
|
+
wake();
|
|
685
|
+
};
|
|
686
|
+
const next = () =>
|
|
687
|
+
queue.length
|
|
688
|
+
? Promise.resolve(queue.shift())
|
|
689
|
+
: new Promise((settle) => {
|
|
690
|
+
waiter = () => settle(queue.shift());
|
|
691
|
+
});
|
|
559
692
|
emitKeypressEvents(stdin, { escapeCodeTimeout: escapeMs });
|
|
560
693
|
stdin.on("keypress", onKey);
|
|
561
694
|
stdin.on("end", onEnd);
|
|
@@ -566,10 +699,16 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
566
699
|
|
|
567
700
|
// ── The restore, on every path ──
|
|
568
701
|
// `live`: the question being asked, or null between questions.
|
|
569
|
-
let closed = false,
|
|
702
|
+
let closed = false,
|
|
703
|
+
cancelled = false,
|
|
704
|
+
live = null;
|
|
570
705
|
const restore = () => {
|
|
571
|
-
try {
|
|
572
|
-
|
|
706
|
+
try {
|
|
707
|
+
stdin.setRawMode(false);
|
|
708
|
+
} catch {}
|
|
709
|
+
try {
|
|
710
|
+
write(SHOW_CURSOR);
|
|
711
|
+
} catch {}
|
|
573
712
|
};
|
|
574
713
|
const onExit = () => restore();
|
|
575
714
|
const onSignal = {};
|
|
@@ -582,9 +721,14 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
582
721
|
for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) {
|
|
583
722
|
onSignal[sig] = () => {
|
|
584
723
|
restore();
|
|
585
|
-
if (live)
|
|
724
|
+
if (live)
|
|
725
|
+
try {
|
|
726
|
+
write("\n");
|
|
727
|
+
} catch {}
|
|
586
728
|
release();
|
|
587
|
-
try {
|
|
729
|
+
try {
|
|
730
|
+
proc.kill(proc.pid, sig);
|
|
731
|
+
} catch {}
|
|
588
732
|
};
|
|
589
733
|
}
|
|
590
734
|
proc.once("exit", onExit);
|
|
@@ -594,17 +738,32 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
594
738
|
// `shown`: the rows of the question on screen, written with no newline
|
|
595
739
|
// after it, so the cursor is on its last row. `last`: the rows of the line
|
|
596
740
|
// answered before it, which a path's Esc takes back.
|
|
597
|
-
let shown = 0,
|
|
741
|
+
let shown = 0,
|
|
742
|
+
last = 0;
|
|
598
743
|
const erase = () => {
|
|
599
744
|
if (!shown) return;
|
|
600
745
|
write(`\r${shown > 1 ? `\x1b[${shown - 1}A` : ""}\x1b[J`);
|
|
601
746
|
shown = 0;
|
|
602
747
|
};
|
|
603
|
-
const frame = (lines) => {
|
|
604
|
-
|
|
748
|
+
const frame = (lines) => {
|
|
749
|
+
erase();
|
|
750
|
+
write(lines.join("\n"));
|
|
751
|
+
shown = rowsOf(lines);
|
|
752
|
+
};
|
|
753
|
+
const item = (lines) => {
|
|
754
|
+
erase();
|
|
755
|
+
write(`${lines.join("\n")}\n`);
|
|
756
|
+
last = rowsOf(lines);
|
|
757
|
+
};
|
|
605
758
|
|
|
606
|
-
const titled = (title) =>
|
|
607
|
-
|
|
759
|
+
const titled = (title) =>
|
|
760
|
+
wrap(String(title), room() - 2)
|
|
761
|
+
.split("\n")
|
|
762
|
+
.map((t, k) => `${k ? bar : paint("action", icon(c, "ask"))} ${paint("emphasis", t)}`);
|
|
763
|
+
const prose = (text, role = "explanation") =>
|
|
764
|
+
wrap(String(text), room() - 2)
|
|
765
|
+
.split("\n")
|
|
766
|
+
.map((t) => `${bar} ${paint(role, t)}`);
|
|
608
767
|
const warning = (text) => prose(`${icon(c, "warning")} ${text}`, "attention");
|
|
609
768
|
// The key hint: one line, broken only between its keys on a terminal too
|
|
610
769
|
// narrow for it, each separator ending the line it closes.
|
|
@@ -617,8 +776,10 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
617
776
|
}
|
|
618
777
|
return lines.map((t) => `${bar} ${paint("explanation", t)}`);
|
|
619
778
|
};
|
|
620
|
-
const answeredLine = (title, text, glyph = "answered", role = "done", answerRole = "explanation") =>
|
|
621
|
-
|
|
779
|
+
const answeredLine = (title, text, glyph = "answered", role = "done", answerRole = "explanation") => [
|
|
780
|
+
bar,
|
|
781
|
+
`${paint(role, icon(c, glyph))} ${title}${text ? ` ${paint(answerRole, text)}` : ""}`,
|
|
782
|
+
];
|
|
622
783
|
// Choice rows: the marks (one plain width), the label, and the reason in a
|
|
623
784
|
// column after the widest label that keeps its reason beside it. A row that
|
|
624
785
|
// would pass the width puts its reason beneath the label, wrapped, and is
|
|
@@ -638,11 +799,15 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
638
799
|
const lw = widest(list.filter((_, i) => beside[i]));
|
|
639
800
|
const out = [];
|
|
640
801
|
list.forEach((x, i) => {
|
|
641
|
-
if (beside[i]) {
|
|
802
|
+
if (beside[i]) {
|
|
803
|
+
out.push(`${lead(i)}${x.label}${" ".repeat(lw - plain(x.label).length)} ${paint("explanation", x.reason)}`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
642
806
|
out.push(lead(i) + x.label);
|
|
643
807
|
if (!x.reason) return;
|
|
644
808
|
const col = plain(lead(i)).length;
|
|
645
|
-
for (const t of wrap(x.reason, room() - col).split("\n"))
|
|
809
|
+
for (const t of wrap(x.reason, room() - col).split("\n"))
|
|
810
|
+
out.push(`${bar} ${" ".repeat(col - 2)}${paint("explanation", t)}`);
|
|
646
811
|
});
|
|
647
812
|
return out;
|
|
648
813
|
};
|
|
@@ -665,7 +830,10 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
665
830
|
for (;;) {
|
|
666
831
|
const k = await next();
|
|
667
832
|
// close() cancelled this question while it waited: it drew the line.
|
|
668
|
-
if (closed) {
|
|
833
|
+
if (closed) {
|
|
834
|
+
settled = true;
|
|
835
|
+
return CANCELLED;
|
|
836
|
+
}
|
|
669
837
|
if (k === EOF || (k.ctrl && k.name === "c")) {
|
|
670
838
|
cancelled = true;
|
|
671
839
|
item(answeredLine(q.title, "cancelled", "warning", "attention", "attention"));
|
|
@@ -673,12 +841,21 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
673
841
|
return CANCELLED;
|
|
674
842
|
}
|
|
675
843
|
const r = await q.key(k);
|
|
676
|
-
if (closed) {
|
|
677
|
-
|
|
844
|
+
if (closed) {
|
|
845
|
+
settled = true;
|
|
846
|
+
return CANCELLED;
|
|
847
|
+
}
|
|
848
|
+
if (r === "redraw") {
|
|
849
|
+
draw();
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
678
852
|
if (!r) continue;
|
|
679
853
|
if (r.back) {
|
|
680
854
|
erase();
|
|
681
|
-
if (last) {
|
|
855
|
+
if (last) {
|
|
856
|
+
write(`\x1b[${last}A\x1b[J`);
|
|
857
|
+
last = 0;
|
|
858
|
+
}
|
|
682
859
|
settled = true;
|
|
683
860
|
return BACK;
|
|
684
861
|
}
|
|
@@ -689,9 +866,16 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
689
866
|
} finally {
|
|
690
867
|
live = null;
|
|
691
868
|
// A throw left the frame drawn: end its line, so what follows starts on its own.
|
|
692
|
-
if (!settled && shown) {
|
|
869
|
+
if (!settled && shown) {
|
|
870
|
+
try {
|
|
871
|
+
write("\n");
|
|
872
|
+
} catch {}
|
|
873
|
+
shown = 0;
|
|
874
|
+
}
|
|
693
875
|
restore();
|
|
694
|
-
try {
|
|
876
|
+
try {
|
|
877
|
+
stdin.pause();
|
|
878
|
+
} catch {}
|
|
695
879
|
}
|
|
696
880
|
}
|
|
697
881
|
|
|
@@ -699,25 +883,46 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
699
883
|
if (!list.length) throw new TypeError(`${title}: no choices`);
|
|
700
884
|
return ask({
|
|
701
885
|
title,
|
|
702
|
-
draw: () => [
|
|
703
|
-
|
|
704
|
-
...
|
|
886
|
+
draw: () => [
|
|
887
|
+
bar,
|
|
888
|
+
...titled(title),
|
|
889
|
+
...(note ? prose(note) : []),
|
|
890
|
+
...choiceLines(
|
|
891
|
+
list.map((_, i) =>
|
|
892
|
+
i === at ? paint("action", icon(c, "choiceOn")) : paint("explanation", icon(c, "choiceOff")),
|
|
893
|
+
),
|
|
894
|
+
list,
|
|
895
|
+
),
|
|
896
|
+
...hint(keys),
|
|
897
|
+
],
|
|
705
898
|
key: (k) => {
|
|
706
|
-
if (k.name === "up") {
|
|
707
|
-
|
|
899
|
+
if (k.name === "up") {
|
|
900
|
+
at = (at + list.length - 1) % list.length;
|
|
901
|
+
return "redraw";
|
|
902
|
+
}
|
|
903
|
+
if (k.name === "down") {
|
|
904
|
+
at = (at + 1) % list.length;
|
|
905
|
+
return "redraw";
|
|
906
|
+
}
|
|
708
907
|
if (isEnter(k)) return { value: list[at].value, answer: list[at].answer };
|
|
709
908
|
return null;
|
|
710
909
|
},
|
|
711
910
|
});
|
|
712
911
|
};
|
|
713
|
-
const defaultAt = (list, value) =>
|
|
912
|
+
const defaultAt = (list, value) =>
|
|
913
|
+
Math.max(
|
|
914
|
+
0,
|
|
915
|
+
list.findIndex((x) => Object.is(x.value, value)),
|
|
916
|
+
);
|
|
714
917
|
|
|
715
918
|
// The top: `┌` and the badge — `┌ projectstore setup` — in place of the
|
|
716
919
|
// badge line a screen without a rail starts with.
|
|
717
920
|
write(`${paint("explanation", icon(c, "railTop"))} ${badge(c, head, parts)}\n`);
|
|
718
921
|
|
|
719
922
|
return {
|
|
720
|
-
get cancelled() {
|
|
923
|
+
get cancelled() {
|
|
924
|
+
return cancelled;
|
|
925
|
+
},
|
|
721
926
|
|
|
722
927
|
select(title, choices, { default: value, note = "" } = {}) {
|
|
723
928
|
const list = choices.map(choiceOf);
|
|
@@ -733,27 +938,60 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
733
938
|
const need = Math.min(Math.max(0, Number(min) || 0), list.length);
|
|
734
939
|
const on = list.map((x) => x.on);
|
|
735
940
|
const pointer = icon(c, "pointer");
|
|
736
|
-
let at = 0,
|
|
941
|
+
let at = 0,
|
|
942
|
+
refused = "";
|
|
737
943
|
return ask({
|
|
738
944
|
title,
|
|
739
|
-
draw: () => [
|
|
740
|
-
|
|
945
|
+
draw: () => [
|
|
946
|
+
bar,
|
|
947
|
+
...titled(title),
|
|
948
|
+
...(note ? prose(note) : []),
|
|
949
|
+
...choiceLines(
|
|
950
|
+
list.map(
|
|
951
|
+
(_, i) =>
|
|
952
|
+
`${i === at ? paint("action", pointer) : " ".repeat(pointer.length)} ${paint(on[i] ? "action" : "explanation", icon(c, on[i] ? "checkOn" : "checkOff"))}`,
|
|
953
|
+
),
|
|
954
|
+
list,
|
|
955
|
+
),
|
|
741
956
|
...(refused ? warning(refused) : []),
|
|
742
|
-
...hint(["up/down move", "space toggle", "enter confirm", "ctrl+c cancel"])
|
|
957
|
+
...hint(["up/down move", "space toggle", "enter confirm", "ctrl+c cancel"]),
|
|
958
|
+
],
|
|
743
959
|
key: (k) => {
|
|
744
|
-
if (k.name === "up") {
|
|
745
|
-
|
|
746
|
-
|
|
960
|
+
if (k.name === "up") {
|
|
961
|
+
at = (at + list.length - 1) % list.length;
|
|
962
|
+
return "redraw";
|
|
963
|
+
}
|
|
964
|
+
if (k.name === "down") {
|
|
965
|
+
at = (at + 1) % list.length;
|
|
966
|
+
return "redraw";
|
|
967
|
+
}
|
|
968
|
+
if (k.name === "space") {
|
|
969
|
+
on[at] = !on[at];
|
|
970
|
+
refused = "";
|
|
971
|
+
return "redraw";
|
|
972
|
+
}
|
|
747
973
|
if (!isEnter(k)) return null;
|
|
748
974
|
const picked = list.filter((_, i) => on[i]);
|
|
749
|
-
if (picked.length < need) {
|
|
750
|
-
|
|
975
|
+
if (picked.length < need) {
|
|
976
|
+
refused = need === 1 ? "tick at least one with space" : `tick at least ${need} with space`;
|
|
977
|
+
return "redraw";
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
value: picked.map((x) => x.value),
|
|
981
|
+
answer: picked.length ? picked.map((x) => x.answer).join(", ") : "none",
|
|
982
|
+
};
|
|
751
983
|
},
|
|
752
984
|
});
|
|
753
985
|
},
|
|
754
986
|
|
|
755
|
-
confirm(
|
|
756
|
-
|
|
987
|
+
confirm(
|
|
988
|
+
title,
|
|
989
|
+
{ default: yes = true, yes: yesLabel = "Yes", no: noLabel = "No", yesReason = "", noReason = "", note = "" } = {},
|
|
990
|
+
) {
|
|
991
|
+
const list = [
|
|
992
|
+
choiceOf({ value: true, label: yesLabel, reason: yesReason }),
|
|
993
|
+
choiceOf({ value: false, label: noLabel, reason: noReason }),
|
|
994
|
+
];
|
|
757
995
|
return choose(title, list, yes ? 0 : 1, note, ["up/down move", "enter choose", "ctrl+c cancel"]);
|
|
758
996
|
},
|
|
759
997
|
|
|
@@ -763,21 +1001,33 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
763
1001
|
// Enter with nothing typed takes it. Backspace takes back a character;
|
|
764
1002
|
// Esc goes back.
|
|
765
1003
|
path(title, { default: fallback = "", check = null, note = "", back = true } = {}) {
|
|
766
|
-
let value = "",
|
|
1004
|
+
let value = "",
|
|
1005
|
+
refused = "";
|
|
767
1006
|
return ask({
|
|
768
1007
|
title,
|
|
769
1008
|
cursor: true,
|
|
770
|
-
draw: () => [
|
|
1009
|
+
draw: () => [
|
|
1010
|
+
bar,
|
|
1011
|
+
...titled(title),
|
|
1012
|
+
...(note ? prose(note) : []),
|
|
1013
|
+
...(refused ? warning(refused) : []),
|
|
771
1014
|
...(fallback ? prose(`default ${fallback}`) : []),
|
|
772
1015
|
...hint(["enter confirm", back ? "esc back" : null, "ctrl+c cancel"]),
|
|
773
|
-
`${bar} ${value}
|
|
1016
|
+
`${bar} ${value}`,
|
|
1017
|
+
],
|
|
774
1018
|
key: async (k) => {
|
|
775
1019
|
if (k.name === "escape") return back ? { back: true } : null;
|
|
776
1020
|
if (isEnter(k)) {
|
|
777
1021
|
const chosen = value.trim() || String(fallback);
|
|
778
|
-
if (!chosen) {
|
|
1022
|
+
if (!chosen) {
|
|
1023
|
+
refused = "type a path";
|
|
1024
|
+
return "redraw";
|
|
1025
|
+
}
|
|
779
1026
|
const why = check ? await check(chosen) : null;
|
|
780
|
-
if (why) {
|
|
1027
|
+
if (why) {
|
|
1028
|
+
refused = String(why);
|
|
1029
|
+
return "redraw";
|
|
1030
|
+
}
|
|
781
1031
|
return { value: chosen, answer: chosen };
|
|
782
1032
|
}
|
|
783
1033
|
if (k.name === "backspace") {
|
|
@@ -831,9 +1081,44 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
|
|
|
831
1081
|
queue.length = 0;
|
|
832
1082
|
const woken = waiter;
|
|
833
1083
|
waiter = null;
|
|
834
|
-
try {
|
|
1084
|
+
try {
|
|
1085
|
+
stdin.pause();
|
|
1086
|
+
} catch {}
|
|
835
1087
|
release();
|
|
836
1088
|
if (woken) woken();
|
|
837
1089
|
},
|
|
838
1090
|
};
|
|
839
1091
|
}
|
|
1092
|
+
|
|
1093
|
+
// What was typed ahead of a line-mode question, discarded (the setup story,
|
|
1094
|
+
// rule 2: the rail's raw mode ends at `└`, and the confirm after it is
|
|
1095
|
+
// askLine's). Keys typed while the run fetched, read the registry and drew
|
|
1096
|
+
// PLAN wait in the terminal's buffer — an Enter double-tapped on the last rail
|
|
1097
|
+
// question among them — and the next askLine would take them as the answer
|
|
1098
|
+
// to a question nobody saw. So: raw mode on (the terminal hands over what it
|
|
1099
|
+
// holds, line or not), the stream resumed for `ticks` turns of the event loop,
|
|
1100
|
+
// then paused, raw mode off, and whatever arrived dropped. Returns the bytes
|
|
1101
|
+
// dropped. A stream that cannot be put in raw mode is left alone (no rail ran
|
|
1102
|
+
// on it). `wait` is the test's turn of the loop.
|
|
1103
|
+
export async function drainInput(stdin, { ticks = 2, wait = () => new Promise(setImmediate) } = {}) {
|
|
1104
|
+
if (!stdin || typeof stdin.setRawMode !== "function") return 0;
|
|
1105
|
+
let dropped = 0;
|
|
1106
|
+
const sink = (chunk) => {
|
|
1107
|
+
dropped += Buffer.byteLength(chunk);
|
|
1108
|
+
};
|
|
1109
|
+
try {
|
|
1110
|
+
stdin.setRawMode(true);
|
|
1111
|
+
stdin.on("data", sink);
|
|
1112
|
+
stdin.resume();
|
|
1113
|
+
for (let k = 0; k < ticks; k++) await wait();
|
|
1114
|
+
} finally {
|
|
1115
|
+
stdin.removeListener("data", sink);
|
|
1116
|
+
try {
|
|
1117
|
+
stdin.pause();
|
|
1118
|
+
} catch {}
|
|
1119
|
+
try {
|
|
1120
|
+
stdin.setRawMode(false);
|
|
1121
|
+
} catch {}
|
|
1122
|
+
}
|
|
1123
|
+
return dropped;
|
|
1124
|
+
}
|