@cspeach/cli 1.1.30 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +121 -8
- package/dist/cli.js +36 -14
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team.js +375 -41
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +20 -1
- package/dist/projects/clean-core-confirm.js +156 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +83 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +25 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +24 -6
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +3 -0
- package/dist/register/status-html.js +3 -1
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +27 -7
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/journey-entry.js +25 -0
- package/dist/repl.js +157 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +77 -4
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +120 -120
package/dist/tools/transport.js
CHANGED
|
@@ -19,6 +19,7 @@ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
|
19
19
|
import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
|
|
20
20
|
import { setCurrentTransport, noteConfirmedTransport } from '../repl/current-transport.js';
|
|
21
21
|
import { enrichTransportList } from './transport-fit.js';
|
|
22
|
+
import { reusedRequestRefusal } from './transport-fit-check.js';
|
|
22
23
|
// ── Session ledger: transports CREATED by sap_transport_create this session ──
|
|
23
24
|
//
|
|
24
25
|
// Orphan-hint support (third junk transport this month, 2026-06-12 — D15/D20
|
|
@@ -100,7 +101,9 @@ registerTool({
|
|
|
100
101
|
type: 'string',
|
|
101
102
|
description: 'Target package (DEVCLASS) for the transport — typically the same package the '
|
|
102
103
|
+ 'objects will live in. Required by SAP\'s CreateCorrectionRequest endpoint. '
|
|
103
|
-
+ 'Empty / missing target_package on this endpoint commonly causes HTTP 400.'
|
|
104
|
+
+ 'Empty / missing target_package on this endpoint commonly causes HTTP 400. '
|
|
105
|
+
+ 'The package must already exist: for a NEW package use sap_create_object type DEVC, '
|
|
106
|
+
+ 'which creates the package and its first transport together.',
|
|
104
107
|
},
|
|
105
108
|
approval_id: {
|
|
106
109
|
type: 'string',
|
|
@@ -193,6 +196,38 @@ registerTool({
|
|
|
193
196
|
+ `(${err instanceof Error ? err.message : String(err)})`);
|
|
194
197
|
}
|
|
195
198
|
}
|
|
199
|
+
// ── Gate 0b: the target package must exist (§10a) ───────────────────────
|
|
200
|
+
// A request for a package that is not in the system has no transport
|
|
201
|
+
// target — SAP answers HTTP 500. A new package gets its first transport
|
|
202
|
+
// from sap_create_object DEVC (check → create from the package's own
|
|
203
|
+
// entry). Read-only lookup, before the approval gate like Gate 0.
|
|
204
|
+
// Best-effort: a failed lookup never blocks the create.
|
|
205
|
+
if (args.target_package) {
|
|
206
|
+
const pkg = String(args.target_package).trim().toUpperCase();
|
|
207
|
+
let missing = false;
|
|
208
|
+
try {
|
|
209
|
+
const isPkg = (hits) => hits.some((h) => (h.name ?? '').toUpperCase() === pkg && (!h.type || h.type.toUpperCase().startsWith('DEVC')));
|
|
210
|
+
let hits = await ctx.adt.searchObject(pkg, 'DEVC/K', 50);
|
|
211
|
+
// A type filter the system reads differently must never invent a
|
|
212
|
+
// "missing" package: an empty filtered answer is asked again unfiltered.
|
|
213
|
+
if (Array.isArray(hits) && hits.length === 0)
|
|
214
|
+
hits = await ctx.adt.searchObject(pkg, undefined, 50);
|
|
215
|
+
missing = Array.isArray(hits) && !isPkg(hits);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
missing = false;
|
|
219
|
+
}
|
|
220
|
+
if (missing) {
|
|
221
|
+
return {
|
|
222
|
+
content: JSON.stringify({
|
|
223
|
+
error: 'package_missing',
|
|
224
|
+
message: `Package ${pkg} does not exist yet. Create it with sap_create_object DEVC — `
|
|
225
|
+
+ 'CSPeach creates the package and its transport together.',
|
|
226
|
+
}),
|
|
227
|
+
is_error: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
196
231
|
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
197
232
|
const verify = await verifyAndSpendApprovalId(args.approval_id, args.description, 'create');
|
|
198
233
|
if (!verify.ok) {
|
|
@@ -215,7 +250,24 @@ registerTool({
|
|
|
215
250
|
// transportCreate(description, targetPackage?)
|
|
216
251
|
try {
|
|
217
252
|
const result = await ctx.adt.transportCreate(args.description, args.target_package);
|
|
253
|
+
if (result.status === 'error') {
|
|
254
|
+
// §10a: SAP's own sentence in plain words, audited as the error it is.
|
|
255
|
+
await auditLog('transportCreate', 'CTS', result.transportNumber ?? 'UNKNOWN', 'error', Date.now() - startedAt);
|
|
256
|
+
return {
|
|
257
|
+
content: JSON.stringify({
|
|
258
|
+
error: 'transport_create_failed',
|
|
259
|
+
message: `SAP refused to create the transport: ${result.error ?? 'no message'}`,
|
|
260
|
+
next: 'Next: check the package and its transport layer, or ask your Basis team.',
|
|
261
|
+
}),
|
|
262
|
+
is_error: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
218
265
|
await auditLog('transportCreate', 'CTS', result.transportNumber ?? 'UNKNOWN', 'success', Date.now() - startedAt);
|
|
266
|
+
// 1.1.31 — a request SAP handed back is refused in plain words; the
|
|
267
|
+
// advice is to create one for the package or name one that fits.
|
|
268
|
+
if (result.status === 'reused') {
|
|
269
|
+
return { content: JSON.stringify(reusedRequestRefusal(result)), is_error: true };
|
|
270
|
+
}
|
|
219
271
|
if (result.status !== 'created' || !result.transportNumber) {
|
|
220
272
|
return {
|
|
221
273
|
content: JSON.stringify({ error: 'transport_create_failed', detail: result.error }),
|
|
@@ -371,7 +423,8 @@ registerTool({
|
|
|
371
423
|
+ 'recommend a request with contents "other_work" or "not_checked" for reuse; list a '
|
|
372
424
|
+ '"not_checked" one as "not checked", never as a clean fit. A description that seems to name '
|
|
373
425
|
+ 'other work may only lower a request in your answer; it never overrides `suggested: true`. '
|
|
374
|
-
+ 'When no request is suggested, offer to create a new transport.'
|
|
426
|
+
+ 'When no request is suggested, offer to create a new transport. '
|
|
427
|
+
+ '`package_local: true` means the package has no transport layer: show `package_note` to the person and offer no request.',
|
|
375
428
|
isMutating: false,
|
|
376
429
|
requiresSap: true,
|
|
377
430
|
input_schema: {
|
|
@@ -405,6 +458,8 @@ registerTool({
|
|
|
405
458
|
filter_status: result.filterStatus,
|
|
406
459
|
count: enriched.transports.length,
|
|
407
460
|
...(enriched.packageTarget ? { package: String(args.package).toUpperCase(), package_target: enriched.packageTarget } : {}),
|
|
461
|
+
// 1.1.31 — a package with no transport layer is local: say so plainly.
|
|
462
|
+
...(enriched.packageLocal ? { package: String(args.package).toUpperCase(), package_local: true, package_note: enriched.packageNote } : {}),
|
|
408
463
|
transports: enriched.transports,
|
|
409
464
|
// Round 5 item 3 — sap-client says when the list may be incomplete.
|
|
410
465
|
...(result.note ? { note: result.note } : {}),
|
package/dist/ui/app.js
CHANGED
|
@@ -581,7 +581,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
581
581
|
cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns));
|
|
582
582
|
// C1 — the confirm card's real height (facts + choices) for the spec §2 cap.
|
|
583
583
|
if (occupant === 'confirm' && confirmSlot.pending)
|
|
584
|
-
cardRows = estimateConfirmCardRows(confirmSlot.pending);
|
|
584
|
+
cardRows = estimateConfirmCardRows(confirmSlot.pending, uiWidth(columns), rows - 6);
|
|
585
585
|
const paletteRows = lineResolution ? PALETTE_ROWS : 0;
|
|
586
586
|
// P2 — the checklist's WANTED height (header + rows); liveBudget folds it.
|
|
587
587
|
const showChecklist = todos.length > 0 && !planClosed;
|
|
@@ -610,7 +610,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
610
610
|
}
|
|
611
611
|
else if (occupant === 'confirm' && confirmSlot.pending) {
|
|
612
612
|
const req = confirmSlot.pending;
|
|
613
|
-
slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), onResolve: (r) => confirmSlot.finish(req, r), onInterruptTurn: () => { onInterrupt?.(); } }, confirmSlot.seq));
|
|
613
|
+
slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), maxRows: rows - 6, onResolve: (r) => confirmSlot.finish(req, r), onInterruptTurn: () => { onInterrupt?.(); } }, confirmSlot.seq));
|
|
614
614
|
}
|
|
615
615
|
else if (occupant === 'ask' && askQuestionPending) {
|
|
616
616
|
const req = askQuestionPending;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
4
|
import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
|
|
5
5
|
import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.js';
|
|
6
6
|
// BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
|
|
@@ -31,16 +31,75 @@ function diffLineColor(line, panels) {
|
|
|
31
31
|
return roleProps(line.startsWith('+') ? 'ok' : line.startsWith('-') ? 'bad' : 'dim').color;
|
|
32
32
|
return legacyInkColor(line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray');
|
|
33
33
|
}
|
|
34
|
+
/** After the review view closes, an Esc this soon is ignored (a double press), like CONFIRM_INPUT_GUARD_MS. */
|
|
35
|
+
export const BACK_GUARD_MS = 300;
|
|
36
|
+
/** The review view's key line, always at its bottom: how to go back and how to answer from here. */
|
|
37
|
+
export const viewKeysLine = (reviewKey) => `Esc or ${reviewKey}: back · a: approve · s: skip · c: cancel turn`;
|
|
34
38
|
export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn }) {
|
|
35
39
|
// BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
|
|
36
40
|
// when an inquirer gate ran earlier in the turn (see import block).
|
|
37
41
|
useReclaimRawStdin();
|
|
38
42
|
const [viewing, setViewing] = useState(false);
|
|
43
|
+
const [top, setTop] = useState(0);
|
|
39
44
|
const aff = viewAffordance(change);
|
|
45
|
+
const { stdout } = useStdout();
|
|
46
|
+
// Recording fix 2 (2026-10-07): a long diff ran off the top of the screen
|
|
47
|
+
// with its "Esc to return" line, and the person could not get back. The
|
|
48
|
+
// review view shows at most this many lines and scrolls; its key line stays
|
|
49
|
+
// at the bottom. ink-testing-library reports no rows: 24, as App does.
|
|
50
|
+
// The whole live frame must stay strictly below the terminal's rows, or Ink
|
|
51
|
+
// clears the screen on every paint and leaves stale rows behind (Ink 5,
|
|
52
|
+
// outputHeight >= rows). Budget: this view's chrome 6 + the input footer 3 +
|
|
53
|
+
// the footer line 1 + the checklist 1 + 1 spare + the diff lines.
|
|
54
|
+
const viewRows = Math.max(3, (stdout?.rows ?? 24) - 14);
|
|
55
|
+
// Back from the view: an Esc in the next 300 ms is the second of a double
|
|
56
|
+
// press, never a cancel of the turn.
|
|
57
|
+
const backAt = useRef(0);
|
|
58
|
+
const back = () => { backAt.current = Date.now(); setViewing(false); };
|
|
59
|
+
const viewLines = viewing && aff && change.diff
|
|
60
|
+
? (aff.key === 'd' ? change.diff.split('\n') : toSourceLines(change.diff))
|
|
61
|
+
: [];
|
|
62
|
+
const lastTop = Math.max(0, viewLines.length - viewRows);
|
|
40
63
|
useInput((input, key) => {
|
|
41
64
|
if (viewing) {
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
// The review view answers the card too: Esc or the review key go back;
|
|
66
|
+
// a / s / c (and y / n, as on the card) answer it from here.
|
|
67
|
+
if (key.escape || (aff && input === aff.key)) {
|
|
68
|
+
back();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (key.ctrl && input === 'c') {
|
|
72
|
+
onCancel();
|
|
73
|
+
onInterruptTurn?.();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (key.upArrow || input === 'k') {
|
|
77
|
+
setTop((t) => Math.max(0, t - 1));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (key.downArrow || input === 'j') {
|
|
81
|
+
setTop((t) => Math.min(lastTop, t + 1));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (key.pageUp) {
|
|
85
|
+
setTop((t) => Math.max(0, t - viewRows));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (key.pageDown || input === ' ') {
|
|
89
|
+
setTop((t) => Math.min(lastTop, t + viewRows));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (input === 'a' || input === 'y') {
|
|
93
|
+
onApply();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (input === 's' || input === 'n') {
|
|
97
|
+
onSkip();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (input === 'c') {
|
|
101
|
+
onCancel();
|
|
102
|
+
return;
|
|
44
103
|
}
|
|
45
104
|
return;
|
|
46
105
|
}
|
|
@@ -55,6 +114,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
|
|
|
55
114
|
// The review key is op-aware ([d] for modify, [v] for create/delete);
|
|
56
115
|
// display-only — it never changes the resolve contract.
|
|
57
116
|
if (aff && input === aff.key) {
|
|
117
|
+
setTop(0);
|
|
58
118
|
setViewing(true);
|
|
59
119
|
onDiff();
|
|
60
120
|
return;
|
|
@@ -68,6 +128,8 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
|
|
|
68
128
|
onInterruptTurn?.();
|
|
69
129
|
return;
|
|
70
130
|
}
|
|
131
|
+
if (key.escape && Date.now() - backAt.current < BACK_GUARD_MS)
|
|
132
|
+
return;
|
|
71
133
|
if (input === 'c' || key.escape) {
|
|
72
134
|
onCancel();
|
|
73
135
|
return;
|
|
@@ -78,7 +140,13 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
|
|
|
78
140
|
const isDiff = aff.key === 'd';
|
|
79
141
|
const panels = getLook() === 'panels';
|
|
80
142
|
const quiet = panels ? roleProps('dim').color : legacyInkColor('gray');
|
|
81
|
-
|
|
143
|
+
const from = Math.min(top, lastTop);
|
|
144
|
+
const shownLines = viewLines.slice(from, from + viewRows);
|
|
145
|
+
const scrolls = viewLines.length > viewRows;
|
|
146
|
+
const where = scrolls
|
|
147
|
+
? ` lines ${from + 1}–${from + shownLines.length} of ${viewLines.length} · ↑↓ scroll`
|
|
148
|
+
: '';
|
|
149
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
|
|
82
150
|
}
|
|
83
151
|
const raisedLabel = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
|
|
84
152
|
// Meta line: Package · Transport — each segment only when genuinely known.
|
|
@@ -28,10 +28,70 @@ export const CONFIRM_INPUT_GUARD_MS = 150;
|
|
|
28
28
|
* The card's full rendered height for the live-area budget (F3 liveBudget
|
|
29
29
|
* `cardRows`): top edge + title + one row per fact + the gap above the
|
|
30
30
|
* choices + one row per choice + the hint + bottom edge. Every row is
|
|
31
|
-
* truncate-end, so nothing wraps into extra rows
|
|
31
|
+
* truncate-end, so nothing wraps into extra rows — except the facts of a
|
|
32
|
+
* card that asks for `wrapFacts`: with `width` given, each counts the rows
|
|
33
|
+
* it is drawn on.
|
|
32
34
|
*/
|
|
33
|
-
export function estimateConfirmCardRows(req) {
|
|
34
|
-
|
|
35
|
+
export function estimateConfirmCardRows(req, width, maxRows) {
|
|
36
|
+
const facts = req.facts ?? [];
|
|
37
|
+
const wrap = width === undefined ? 1 : fittedWrap(req, width, maxRows);
|
|
38
|
+
const factRowCount = width !== undefined && wrap > 1
|
|
39
|
+
? facts.reduce((n, f) => n + factRows(f, Math.max(1, width - 4), wrap).length, 0)
|
|
40
|
+
: facts.length;
|
|
41
|
+
return 5 + factRowCount + req.choices.length;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The rows a long fact may wrap onto so the whole card stays within
|
|
45
|
+
* `maxRows` (App: rows − 6, so Ink never reaches its full-clear path): the
|
|
46
|
+
* card's own `wrapFacts`, one less each time it would not fit, down to one
|
|
47
|
+
* row (every fact cut with …, as on any card).
|
|
48
|
+
*/
|
|
49
|
+
export function fittedWrap(req, width, maxRows) {
|
|
50
|
+
let wrap = Math.max(1, req.wrapFacts ?? 1);
|
|
51
|
+
if (maxRows === undefined)
|
|
52
|
+
return wrap;
|
|
53
|
+
const cells = Math.max(1, width - 4);
|
|
54
|
+
const rowsAt = (w) => 5 + req.choices.length + (req.facts ?? []).reduce((n, f) => n + factRows(f, cells, w).length, 0);
|
|
55
|
+
while (wrap > 1 && rowsAt(wrap) > maxRows)
|
|
56
|
+
wrap -= 1;
|
|
57
|
+
return wrap;
|
|
58
|
+
}
|
|
59
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
60
|
+
/**
|
|
61
|
+
* One fact as the rows it is drawn on. `rows` 1 (the default): one row, cut
|
|
62
|
+
* with … — how every card looks. More: wrapped at spaces (a word wider than
|
|
63
|
+
* the row is cut where it must be) onto at most `rows` rows, the last one cut
|
|
64
|
+
* with … when there is still more. Control characters are neutralised first.
|
|
65
|
+
*/
|
|
66
|
+
export function factRows(text, cells, rows = 1) {
|
|
67
|
+
const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
|
|
68
|
+
if (rows <= 1 || stringWidth(flat) <= cells)
|
|
69
|
+
return [truncateToWidth(flat, cells)];
|
|
70
|
+
const out = [];
|
|
71
|
+
let rest = flat;
|
|
72
|
+
while (out.length < rows - 1 && stringWidth(rest) > cells) {
|
|
73
|
+
let used = 0;
|
|
74
|
+
let space = -1;
|
|
75
|
+
let end = rest.length;
|
|
76
|
+
for (const { segment, index } of graphemes.segment(rest)) {
|
|
77
|
+
const w = stringWidth(segment);
|
|
78
|
+
if (used + w > cells) {
|
|
79
|
+
end = index;
|
|
80
|
+
if (segment === ' ')
|
|
81
|
+
space = index;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
used += w;
|
|
85
|
+
if (segment === ' ')
|
|
86
|
+
space = index;
|
|
87
|
+
}
|
|
88
|
+
const at = space > 0 ? space : Math.max(1, end);
|
|
89
|
+
out.push(rest.slice(0, at).trimEnd());
|
|
90
|
+
rest = rest.slice(at).trimStart();
|
|
91
|
+
}
|
|
92
|
+
if (rest)
|
|
93
|
+
out.push(truncateToWidth(rest, cells));
|
|
94
|
+
return out;
|
|
35
95
|
}
|
|
36
96
|
/**
|
|
37
97
|
* Owner decision (fix-round-3 deferred M1): a digit PICKS only on ordinary
|
|
@@ -107,7 +167,7 @@ export function CardFrame({ tag, footnote, width, children }) {
|
|
|
107
167
|
const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
|
|
108
168
|
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: top.label }), _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
|
|
109
169
|
}
|
|
110
|
-
export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
|
|
170
|
+
export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn }) {
|
|
111
171
|
// Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
|
|
112
172
|
// raw-mode bookkeeping stale, and the card would then get no keys.
|
|
113
173
|
useReclaimRawStdin();
|
|
@@ -175,6 +235,7 @@ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
|
|
|
175
235
|
finish({ value: hit });
|
|
176
236
|
});
|
|
177
237
|
const inner = Math.max(1, width - 4);
|
|
238
|
+
const wrap = fittedWrap(request, width, maxRows);
|
|
178
239
|
const dim = roleProps('dim');
|
|
179
240
|
const titleProps = request.tag === 'CANNOT BE UNDONE' ? roleProps('bad', { bold: true }) : { bold: true };
|
|
180
241
|
const letters = Object.keys(request.aliases ?? {}).filter((k) => k.length === 1);
|
|
@@ -183,7 +244,9 @@ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
|
|
|
183
244
|
? `1-${digits} picks · ↑↓ move · Enter chooses · Esc cancels`
|
|
184
245
|
: `Enter chooses · Esc cancels · ↑↓ or 1-${digits} move`)
|
|
185
246
|
+ (letters.length > 0 ? ` · keys ${letters.join('/')}` : '');
|
|
186
|
-
return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).
|
|
247
|
+
return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).flatMap((f, i) => (wrap > 1
|
|
248
|
+
? factRows(f, inner, wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))
|
|
249
|
+
: [_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`)])), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
|
|
187
250
|
const on = i === cursor;
|
|
188
251
|
const main = fit(`${on ? g.cursor : ' '} ${i + 1}. ${c.label}`, inner);
|
|
189
252
|
const room = inner - stringWidth(main);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a local file or an https link in the default browser — the guided
|
|
3
|
+
* clean-core journey's status page (Task 6) and the `cspeach login` link.
|
|
4
|
+
*
|
|
5
|
+
* SECURITY: nothing here goes through a shell. `cmd /c start "" <path>` re-parses
|
|
6
|
+
* its argument, so a folder or project name with & ^ | % " ( ) could run
|
|
7
|
+
* commands. Instead:
|
|
8
|
+
* Windows local file → rundll32.exe url.dll,FileProtocolHandler <file URL>
|
|
9
|
+
* https link → rundll32.exe url.dll,FileProtocolHandler <url>
|
|
10
|
+
* macOS open <target>
|
|
11
|
+
* Linux xdg-open <target>
|
|
12
|
+
* always spawned with shell:false, the target as ONE argument, detached, stdio
|
|
13
|
+
* ignored. Local files must be absolute paths with no control characters; links
|
|
14
|
+
* must be https with no spaces, quotes or control characters (the login path
|
|
15
|
+
* also allows http://localhost and http://127.0.0.1). On Windows a local file
|
|
16
|
+
* must end in .html (defence in depth: FileProtocolHandler hands any other file
|
|
17
|
+
* to its own program, and a .bat or .exe runs). Nothing throws.
|
|
18
|
+
*
|
|
19
|
+
* Why not explorer.exe <path> for files: Explorer splits its command line on
|
|
20
|
+
* commas, and Node quotes only arguments with a space, tab or quote, so
|
|
21
|
+
* `C:\proj\tool.bat,x\…` would reach it unquoted and split. The encoded file
|
|
22
|
+
* URL has only [A-Za-z0-9%/._~-] after the drive, so nothing can split it.
|
|
23
|
+
* Fallback, NOT active: if a live check shows unicode paths fail through
|
|
24
|
+
* FileProtocolHandler, use explorer.exe with windowsVerbatimArguments:true and
|
|
25
|
+
* args [`"${p}"`], refusing any path containing ','.
|
|
26
|
+
*/
|
|
27
|
+
import nodePath from 'node:path';
|
|
28
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
29
|
+
export const BROWSER_FAILED_LINE = 'Could not open the browser — open the link above.';
|
|
30
|
+
// eslint-disable-next-line no-control-regex
|
|
31
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
32
|
+
/** https only; a host; no spaces, quotes, backticks, angle brackets, backslashes or control characters. */
|
|
33
|
+
export function isSafeHttpsUrl(url) {
|
|
34
|
+
if (typeof url !== 'string' || CONTROL.test(url))
|
|
35
|
+
return false;
|
|
36
|
+
if (!/^https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?([/?#][^\s"'`<>\\]*)?$/.test(url))
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url);
|
|
40
|
+
return u.protocol === 'https:' && u.hostname.length > 0;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** http://localhost or http://127.0.0.1, any port; same character rules as https. */
|
|
47
|
+
export function isLocalHttpUrl(url) {
|
|
48
|
+
if (typeof url !== 'string' || CONTROL.test(url))
|
|
49
|
+
return false;
|
|
50
|
+
if (!/^http:\/\/(localhost|127\.0\.0\.1)(:\d{1,5})?([/?#][^\s"'`<>\\]*)?$/.test(url))
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(url);
|
|
54
|
+
return u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && !u.username && !u.password;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isAbsoluteFile(p, platform) {
|
|
61
|
+
if (typeof p !== 'string' || p.length === 0 || CONTROL.test(p) || p.startsWith('-'))
|
|
62
|
+
return false;
|
|
63
|
+
return platform === 'win32' ? nodePath.win32.isAbsolute(p) && /^([A-Za-z]:\\|\\\\)/.test(p) : nodePath.posix.isAbsolute(p);
|
|
64
|
+
}
|
|
65
|
+
/** Percent-encode one path segment: everything but A–Z a–z 0–9 - . _ ~ */
|
|
66
|
+
function encodeSegment(s) {
|
|
67
|
+
return encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
68
|
+
}
|
|
69
|
+
/** `file:///…` for a local absolute path: spaces as %20, other reserved characters percent-encoded. */
|
|
70
|
+
export function fileUrlOf(p, platform = process.platform) {
|
|
71
|
+
if (platform === 'win32') {
|
|
72
|
+
const unc = /^\\\\([^\\]+)\\(.*)$/.exec(p);
|
|
73
|
+
if (unc)
|
|
74
|
+
return `file://${encodeSegment(unc[1])}/${unc[2].split(/[\\/]/).map(encodeSegment).join('/')}`;
|
|
75
|
+
const [drive, ...rest] = p.split(/[\\/]/);
|
|
76
|
+
return `file:///${drive.toUpperCase()}/${rest.map(encodeSegment).join('/')}`;
|
|
77
|
+
}
|
|
78
|
+
return `file://${p.split('/').map(encodeSegment).join('/')}`;
|
|
79
|
+
}
|
|
80
|
+
function commandFor(target, platform, allowLocalHttp) {
|
|
81
|
+
const isLink = /^[A-Za-z][A-Za-z0-9+.-]+:/.test(target); // a one-letter "scheme" is a drive letter
|
|
82
|
+
if (isLink) {
|
|
83
|
+
if (!isSafeHttpsUrl(target) && !(allowLocalHttp && isLocalHttpUrl(target)))
|
|
84
|
+
return null;
|
|
85
|
+
if (platform === 'win32')
|
|
86
|
+
return { cmd: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', target] };
|
|
87
|
+
}
|
|
88
|
+
else if (!isAbsoluteFile(target, platform)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
else if (platform === 'win32') {
|
|
92
|
+
// Only a page: FileProtocolHandler would run a .bat, .cmd or .exe.
|
|
93
|
+
if (!/\.html$/i.test(target))
|
|
94
|
+
return null;
|
|
95
|
+
return { cmd: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', fileUrlOf(target, 'win32')] };
|
|
96
|
+
}
|
|
97
|
+
return { cmd: platform === 'darwin' ? 'open' : 'xdg-open', args: [target] };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Open `target` (an absolute local file path or an https link). Resolves true
|
|
101
|
+
* when the opener started, false when the target was refused or nothing could
|
|
102
|
+
* start. Never throws.
|
|
103
|
+
*/
|
|
104
|
+
export async function openInBrowser(target, deps = {}) {
|
|
105
|
+
const platform = deps.platform ?? process.platform;
|
|
106
|
+
const spawn = deps.spawn ?? nodeSpawn;
|
|
107
|
+
const c = commandFor(target, platform, deps.allowLocalHttp === true);
|
|
108
|
+
if (!c)
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
const child = spawn(c.cmd, c.args, { shell: false, detached: true, stdio: 'ignore', windowsHide: true });
|
|
112
|
+
const started = await new Promise((resolve) => {
|
|
113
|
+
const timer = setTimeout(() => resolve(true), deps.settleMs ?? 1500);
|
|
114
|
+
child.once('spawn', () => { clearTimeout(timer); resolve(true); });
|
|
115
|
+
child.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
116
|
+
});
|
|
117
|
+
// A late error must never crash the session.
|
|
118
|
+
child.on?.('error', () => { });
|
|
119
|
+
child.unref();
|
|
120
|
+
return started;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The journey's REPORT stage, after the person chose Yes: the page's file link
|
|
128
|
+
* (to paste when no browser opens), then open it. Only this one path is shown.
|
|
129
|
+
*/
|
|
130
|
+
export async function openStatusPage(file, deps = {}) {
|
|
131
|
+
const platform = deps.platform ?? process.platform;
|
|
132
|
+
const lines = [` ${fileUrlOf(file, platform)}`];
|
|
133
|
+
if (!(await openInBrowser(file, deps)))
|
|
134
|
+
lines.push(` ${BROWSER_FAILED_LINE}`);
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
/** `cspeach login`'s opener (https, or http on localhost/127.0.0.1): rejects when nothing opened, so login prints its own "open it yourself" line. */
|
|
138
|
+
export function loginOpener(deps = {}) {
|
|
139
|
+
return async (url) => {
|
|
140
|
+
if (!(await openInBrowser(url, { ...deps, allowLocalHttp: true })))
|
|
141
|
+
throw new Error('The browser did not open.');
|
|
142
|
+
};
|
|
143
|
+
}
|