@crouton-kit/crouter 0.3.66 → 0.3.68
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/dist/build-root.js +1 -0
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +624 -624
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +40 -7
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +28 -0
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/revive.test.js +70 -1
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/command.d.ts +15 -0
- package/dist/core/command.js +72 -0
- package/dist/core/config.js +4 -3
- package/dist/core/profiles/select.d.ts +4 -1
- package/dist/core/profiles/select.js +356 -81
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/runtime/revive.js +176 -139
- package/dist/core/runtime/tmux.js +4 -1
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-local.js +12 -3
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
package/dist/core/command.js
CHANGED
|
@@ -10,6 +10,8 @@ import { renderResult } from './render.js';
|
|
|
10
10
|
import { CrtrError } from './errors.js';
|
|
11
11
|
import { ExitCode } from '../types.js';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { constants as osConstants } from 'node:os';
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
14
16
|
// Factory functions
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
@@ -34,6 +36,14 @@ function visibleSubCount(def) {
|
|
|
34
36
|
return (def.help.listing ?? []).filter((c) => c.tier !== 'hidden').length;
|
|
35
37
|
}
|
|
36
38
|
export function defineBranch(opts) {
|
|
39
|
+
// A passthrough branch must be childless: walk() only stops at the deepest
|
|
40
|
+
// matched node, so a passthrough branch with children would let some
|
|
41
|
+
// invocations descend into a child (tree dispatch) while others hit the
|
|
42
|
+
// passthrough interception — silently inconsistent transparency. Fail loudly
|
|
43
|
+
// at construction instead of letting that split happen at runtime.
|
|
44
|
+
if (opts.passthrough !== undefined && opts.children.length > 0) {
|
|
45
|
+
throw new Error(`defineBranch("${opts.name}"): a passthrough branch must declare no children (found ${opts.children.length})`);
|
|
46
|
+
}
|
|
37
47
|
// Assemble the parent-level listing straight from the child defs — each node
|
|
38
48
|
// owns its own description/whenToUse/tier, so the parent copies nothing
|
|
39
49
|
// (principle 16). Bottom-up construction guarantees a child branch's listing
|
|
@@ -57,6 +67,7 @@ export function defineBranch(opts) {
|
|
|
57
67
|
help: opts.help,
|
|
58
68
|
rootEntry: opts.rootEntry,
|
|
59
69
|
slash: opts.slash,
|
|
70
|
+
passthrough: opts.passthrough,
|
|
60
71
|
children: opts.children,
|
|
61
72
|
};
|
|
62
73
|
}
|
|
@@ -169,6 +180,59 @@ function renderNode(node) {
|
|
|
169
180
|
function helpRequested(remaining) {
|
|
170
181
|
return remaining.some((t) => t === '-h' || t === '--help');
|
|
171
182
|
}
|
|
183
|
+
/** Recover the RAW argv slice after a matched passthrough branch's path,
|
|
184
|
+
* unaffected by the `--json`-stripping done to build `tokens`. `path` is a
|
|
185
|
+
* prefix of non-`--json` tokens in order, so walking `rawTokens` while
|
|
186
|
+
* skipping `--json` occurrences until `path` is fully matched lands the
|
|
187
|
+
* cursor exactly where the branch's own args begin — including a literal
|
|
188
|
+
* `--json` the external binary itself accepts. */
|
|
189
|
+
function rawArgsAfterPath(rawTokens, path) {
|
|
190
|
+
let matched = 0;
|
|
191
|
+
for (let i = 0; i < rawTokens.length; i++) {
|
|
192
|
+
if (matched === path.length)
|
|
193
|
+
return rawTokens.slice(i);
|
|
194
|
+
if (rawTokens[i] === '--json')
|
|
195
|
+
continue;
|
|
196
|
+
if (rawTokens[i] === path[matched])
|
|
197
|
+
matched++;
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
/** Forward `args` to an external binary verbatim: stdio inherited (streaming
|
|
202
|
+
* preserved), and the child's exit code propagated. Never returns — either
|
|
203
|
+
* exits with the child's status or throws a structured CrtrError (ENOENT ->
|
|
204
|
+
* install hint) for the caller's existing error handler to render. */
|
|
205
|
+
function runPassthrough(passthrough, args) {
|
|
206
|
+
const result = spawnSync(passthrough.bin, args, { stdio: 'inherit' });
|
|
207
|
+
if (result.error !== undefined) {
|
|
208
|
+
// Any launch-time failure (not found, not executable, ...) means this
|
|
209
|
+
// binary isn't usable from here — surface one structured error with the
|
|
210
|
+
// install hint rather than branching on a specific errno. (Node/libuv can
|
|
211
|
+
// report EACCES instead of ENOENT for a genuinely-missing binary when PATH
|
|
212
|
+
// contains malformed entries — e.g. a literal `~/...` segment — so a
|
|
213
|
+
// ENOENT-only check would miss real missing-binary cases on such hosts.)
|
|
214
|
+
const err = result.error;
|
|
215
|
+
throw new CrtrError('binary_missing', `${passthrough.bin} could not be run (${err.code ?? err.message})`, ExitCode.USAGE, {
|
|
216
|
+
received: passthrough.bin,
|
|
217
|
+
next: passthrough.installHint,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (result.status !== null) {
|
|
221
|
+
process.exit(result.status);
|
|
222
|
+
}
|
|
223
|
+
if (result.signal !== null) {
|
|
224
|
+
// Signal-terminated: exit(1) would break the transparent exit-propagation
|
|
225
|
+
// contract (a direct shell invocation would show 128+signal, e.g. 143 for
|
|
226
|
+
// SIGTERM). Map to the standard 128+signal-number convention.
|
|
227
|
+
const signalNumber = osConstants.signals[result.signal];
|
|
228
|
+
if (signalNumber !== undefined) {
|
|
229
|
+
process.exit(128 + signalNumber);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Truly impossible per Node's spawnSync contract (status and signal are
|
|
233
|
+
// mutually exclusive and one is always set on a completed run) — fallback only.
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
172
236
|
/** Build a structured unknown-path error. Names valid children of the deepest
|
|
173
237
|
* matched node and names the entry command per the spec. The entry command is
|
|
174
238
|
* the full path to the matched node (not just its local name), so the recovery
|
|
@@ -367,6 +431,14 @@ export async function runCli(root, argv) {
|
|
|
367
431
|
}
|
|
368
432
|
const { node, path, remaining } = walk(root, tokens);
|
|
369
433
|
try {
|
|
434
|
+
// Passthrough branches (e.g. `crtr capture`) opt out of the tree model
|
|
435
|
+
// entirely: forward every token after the branch verbatim to an external
|
|
436
|
+
// binary before any of the normal unknown-path / help / dispatch logic
|
|
437
|
+
// runs, so `crtr capture -h`, `crtr capture <args>`, and bare `crtr capture`
|
|
438
|
+
// all reach the external CLI untouched.
|
|
439
|
+
if (node.kind === 'branch' && node.passthrough !== undefined) {
|
|
440
|
+
runPassthrough(node.passthrough, rawArgsAfterPath(rawTokens, path));
|
|
441
|
+
}
|
|
370
442
|
// A root/branch node's `remaining` is only non-empty because walk hit
|
|
371
443
|
// -h/--help or an unmatched child name (a leaf always consumes the rest
|
|
372
444
|
// of the path itself). So a non-help leading token here is always the
|
package/dist/core/config.js
CHANGED
|
@@ -38,6 +38,7 @@ export function readState(scope) {
|
|
|
38
38
|
plugins: existing.plugins ?? {},
|
|
39
39
|
last_self_check: existing.last_self_check,
|
|
40
40
|
bootstrap_done: existing.bootstrap_done,
|
|
41
|
+
activeCanvas: existing.activeCanvas ?? null,
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
export function writeConfig(scope, config) {
|
|
@@ -245,7 +246,7 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
|
|
|
245
246
|
}
|
|
246
247
|
/** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
|
|
247
248
|
* null) rather than throwing — same rule `normalizeKindEntry` follows. A
|
|
248
|
-
* valid entry needs a non-empty `previewEndpoint` and `
|
|
249
|
+
* valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
|
|
249
250
|
* token itself is never stored here (see `RemoteCanvasTarget`). */
|
|
250
251
|
function normalizeRemoteCanvasTarget(raw) {
|
|
251
252
|
if (raw === null || typeof raw !== 'object')
|
|
@@ -253,9 +254,9 @@ function normalizeRemoteCanvasTarget(raw) {
|
|
|
253
254
|
const r = raw;
|
|
254
255
|
if (typeof r.previewEndpoint !== 'string' || r.previewEndpoint.trim() === '')
|
|
255
256
|
return null;
|
|
256
|
-
if (typeof r.
|
|
257
|
+
if (typeof r.relayTokenRef !== 'string' || r.relayTokenRef.trim() === '')
|
|
257
258
|
return null;
|
|
258
|
-
const out = { previewEndpoint: r.previewEndpoint,
|
|
259
|
+
const out = { previewEndpoint: r.previewEndpoint, relayTokenRef: r.relayTokenRef };
|
|
259
260
|
if (typeof r.cpOrigin === 'string' && r.cpOrigin.trim() !== '')
|
|
260
261
|
out.cpOrigin = r.cpOrigin;
|
|
261
262
|
return out;
|
|
@@ -27,7 +27,10 @@ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): bool
|
|
|
27
27
|
* is covered). Default is the MRU covering profile.
|
|
28
28
|
* - Non-interactive: auto-pick the MRU covering profile, no prompt.
|
|
29
29
|
* 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
|
|
30
|
-
* create a profile here
|
|
30
|
+
* create a profile here, select an existing profile (a searchable list of
|
|
31
|
+
* every profile, for when you started somewhere none of them cover and
|
|
32
|
+
* can't recall the exact name — the pick then offers to adopt cwd), or use
|
|
33
|
+
* the root profile. Non-interactive (no TTY):
|
|
31
34
|
* default to the root profile and print the recovery instruction to STDERR —
|
|
32
35
|
* never stdout, which the caller may be piping. */
|
|
33
36
|
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string>;
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
// PINNED default (set from the menu with `d`, wins over MRU while it still
|
|
4
4
|
// covers cwd) > a profile whose project dir IS cwd exactly (auto) > an
|
|
5
5
|
// interactive menu of profiles covering cwd from a parent/workspace dir +
|
|
6
|
-
// create (MRU auto when headless) > synchronous create-
|
|
7
|
-
// nothing covers. This is the ONLY place that decision
|
|
6
|
+
// create (MRU auto when headless) > synchronous create / select-existing /
|
|
7
|
+
// root decision when nothing covers. This is the ONLY place that decision
|
|
8
|
+
// runs; front-door.ts/
|
|
8
9
|
// spawn.ts call it, never re-derive it.
|
|
9
10
|
import { existsSync, realpathSync } from 'node:fs';
|
|
10
11
|
import { homedir } from 'node:os';
|
|
11
12
|
import { basename, resolve as resolvePath, sep } from 'node:path';
|
|
12
13
|
import { createInterface } from 'node:readline/promises';
|
|
13
|
-
import {
|
|
14
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
15
|
+
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, ROOT_PROFILE_ID, } from './manifest.js';
|
|
14
16
|
import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, } from './default-binding.js';
|
|
15
17
|
import { stdoutColor } from '../output.js';
|
|
16
18
|
/** Resolve cwd to an absolute, realpath'd form so it compares against the
|
|
@@ -120,13 +122,134 @@ function writeHeader(title, subtitle) {
|
|
|
120
122
|
const sub = subtitle !== undefined ? ` ${dim(subtitle)}\n` : '';
|
|
121
123
|
process.stdout.write(`\n ${bold(title)}\n${sub}\n`);
|
|
122
124
|
}
|
|
123
|
-
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
function
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
125
|
+
/** Compact one-line summary of a profile's whole purview: its first project
|
|
126
|
+
* dir plus a `· N dirs total` tail when it owns more than one. Used where
|
|
127
|
+
* there is no cwd-match to point at (the searchable select list). */
|
|
128
|
+
function purviewSummary(entry) {
|
|
129
|
+
const n = entry.manifest.projects.length;
|
|
130
|
+
const first = entry.manifest.projects[0];
|
|
131
|
+
if (first === undefined)
|
|
132
|
+
return relativeUsed(entry.manifest.last_used_at);
|
|
133
|
+
return n > 1 ? `${tildify(first)} \u00b7 ${n} dirs total` : tildify(first);
|
|
134
|
+
}
|
|
135
|
+
/** ▸ marks the highlighted row. */
|
|
136
|
+
const HILITE = accent('\u25b8');
|
|
137
|
+
const cursorUp = (n) => (n > 0 ? `\u001b[${n}A\r` : '');
|
|
138
|
+
const CLEAR_DOWN = '\u001b[0J';
|
|
139
|
+
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
140
|
+
/** Terminal rows a just-written block occupies — i.e. how far the cursor
|
|
141
|
+
* advanced, so a redraw can move exactly that far back up. Counts each '\n'
|
|
142
|
+
* as one row AND adds the extra rows a long line wraps into at the current
|
|
143
|
+
* terminal width (ANSI color codes stripped so they don't count toward width).
|
|
144
|
+
* A logical-line count alone undershoots in a narrow pane, stacking redraws. */
|
|
145
|
+
function blockRows(block) {
|
|
146
|
+
const cols = process.stdout.columns ?? 80;
|
|
147
|
+
const segs = block.split('\n');
|
|
148
|
+
const newlines = segs.length - 1; // block ends with '\n' → last seg is ''
|
|
149
|
+
let extra = 0;
|
|
150
|
+
for (let i = 0; i < newlines; i++) {
|
|
151
|
+
const w = segs[i].replace(ANSI_RE, '').length;
|
|
152
|
+
if (cols > 0 && w > cols)
|
|
153
|
+
extra += Math.ceil(w / cols) - 1;
|
|
154
|
+
}
|
|
155
|
+
return newlines + extra;
|
|
156
|
+
}
|
|
157
|
+
function renderMenu(title, subtitle, rows, sel, hint) {
|
|
158
|
+
let out = `\n ${bold(title)}\n`;
|
|
159
|
+
if (subtitle !== undefined)
|
|
160
|
+
out += ` ${dim(subtitle)}\n`;
|
|
161
|
+
out += '\n';
|
|
162
|
+
let num = 0;
|
|
163
|
+
for (const [i, r] of rows.entries()) {
|
|
164
|
+
const tag = r.hotkey ?? String(++num);
|
|
165
|
+
const mark = i === sel ? HILITE : ' ';
|
|
166
|
+
const det = r.detail !== '' ? ' ' + dim(r.detail) : '';
|
|
167
|
+
const def = r.isDefault ? ' ' + accent('\u00b7 ' + (r.defaultText ?? 'default')) : '';
|
|
168
|
+
out += ` ${mark} ${key(tag)} ${r.label}${det}${def}\n`;
|
|
169
|
+
}
|
|
170
|
+
if (hint !== undefined)
|
|
171
|
+
out += `\n ${dim(hint)}\n`;
|
|
172
|
+
out += `\n ${dim('\u2191\u2193/jk move \u21b5 select esc default ^C quit')}\n`;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/** Drive a highlighted keypress menu to a resolution. Returns the selected row
|
|
176
|
+
* index, plus the action letter when one of `actionKeys` was pressed on the
|
|
177
|
+
* highlighted row. */
|
|
178
|
+
function runMenu(title, subtitle, rows, opts = {}) {
|
|
179
|
+
const numbered = [];
|
|
180
|
+
rows.forEach((r, i) => {
|
|
181
|
+
if (r.hotkey === undefined)
|
|
182
|
+
numbered.push(i);
|
|
183
|
+
});
|
|
184
|
+
let sel = opts.initialIndex ?? 0;
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
let prevLines = 0;
|
|
187
|
+
const draw = () => {
|
|
188
|
+
const block = renderMenu(title, subtitle, rows, sel, opts.hint);
|
|
189
|
+
process.stdout.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
|
|
190
|
+
prevLines = blockRows(block);
|
|
191
|
+
};
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
process.stdin.off('keypress', onKey);
|
|
194
|
+
if (process.stdin.isTTY)
|
|
195
|
+
process.stdin.setRawMode(false);
|
|
196
|
+
process.stdin.pause();
|
|
197
|
+
};
|
|
198
|
+
const onKey = (str, k) => {
|
|
199
|
+
if (k.ctrl === true && k.name === 'c') {
|
|
200
|
+
cleanup();
|
|
201
|
+
process.stdout.write('\n');
|
|
202
|
+
process.exit(130);
|
|
203
|
+
}
|
|
204
|
+
if (k.name === 'up' || k.name === 'k' || str === 'k') {
|
|
205
|
+
sel = (sel - 1 + rows.length) % rows.length;
|
|
206
|
+
draw();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (k.name === 'down' || k.name === 'j' || str === 'j') {
|
|
210
|
+
sel = (sel + 1) % rows.length;
|
|
211
|
+
draw();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (k.name === 'return' || k.name === 'enter') {
|
|
215
|
+
cleanup();
|
|
216
|
+
resolve({ index: sel });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Esc cancels navigation and accepts the default row (the MRU/pinned
|
|
220
|
+
// profile, or the root row on the no-coverage menu) — a safe exit, never
|
|
221
|
+
// a dead key. Ctrl-C still aborts crtr entirely.
|
|
222
|
+
if (k.name === 'escape') {
|
|
223
|
+
cleanup();
|
|
224
|
+
resolve({ index: opts.initialIndex ?? 0 });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (str !== undefined && /^[0-9]$/.test(str)) {
|
|
228
|
+
const d = Number.parseInt(str, 10);
|
|
229
|
+
if (d >= 1 && d <= numbered.length) {
|
|
230
|
+
sel = numbered[d - 1];
|
|
231
|
+
draw();
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const hk = rows.findIndex((r) => r.hotkey !== undefined && r.hotkey === str);
|
|
236
|
+
if (hk >= 0) {
|
|
237
|
+
cleanup();
|
|
238
|
+
resolve({ index: hk });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (str !== undefined && opts.actionKeys?.includes(str) === true) {
|
|
242
|
+
cleanup();
|
|
243
|
+
resolve({ index: sel, action: str });
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
emitKeypressEvents(process.stdin);
|
|
247
|
+
if (process.stdin.isTTY)
|
|
248
|
+
process.stdin.setRawMode(true);
|
|
249
|
+
process.stdin.resume();
|
|
250
|
+
process.stdin.on('keypress', onKey);
|
|
251
|
+
draw();
|
|
252
|
+
});
|
|
130
253
|
}
|
|
131
254
|
/** The trailing readline prompt: ` › <dim hint> `. */
|
|
132
255
|
function caret(hint) {
|
|
@@ -156,17 +279,131 @@ async function promptAddDirToProfile(entry, cwd) {
|
|
|
156
279
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
157
280
|
try {
|
|
158
281
|
writeHeader(`Profile "${entry.manifest.name}" doesn't cover this directory`);
|
|
159
|
-
|
|
160
|
-
.trim()
|
|
161
|
-
.toLowerCase();
|
|
162
|
-
if (choice === 'n' || choice === 'no')
|
|
163
|
-
return;
|
|
164
|
-
addProfileProject(entry.profileId, cwd);
|
|
282
|
+
await offerAddDirToProfile(rl, entry, cwd);
|
|
165
283
|
}
|
|
166
284
|
finally {
|
|
167
285
|
rl.close();
|
|
168
286
|
}
|
|
169
287
|
}
|
|
288
|
+
/** Ask (default yes) whether to widen `entry`'s purview to include `cwd`, on an
|
|
289
|
+
* ALREADY-OPEN readline. Shared by the explicit-`--profile` path
|
|
290
|
+
* (`promptAddDirToProfile`) and the select-existing path in
|
|
291
|
+
* `promptCreateOrRoot`, so a profile chosen for a directory it doesn't cover
|
|
292
|
+
* can adopt that directory without a separate `crtr profile project add`. */
|
|
293
|
+
async function offerAddDirToProfile(rl, entry, cwd) {
|
|
294
|
+
const choice = (await rl.question(caret(`add this directory to "${entry.manifest.name}"? [Y/n]`)))
|
|
295
|
+
.trim()
|
|
296
|
+
.toLowerCase();
|
|
297
|
+
if (choice === 'n' || choice === 'no')
|
|
298
|
+
return;
|
|
299
|
+
addProfileProject(entry.profileId, cwd);
|
|
300
|
+
process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${entry.manifest.name}"`)}\n`);
|
|
301
|
+
}
|
|
302
|
+
/** Search-and-pick among EVERY existing profile (root excluded — it is the
|
|
303
|
+
* separate `[r]` option). Reached from the no-coverage prompt so the user can
|
|
304
|
+
* run under a profile they can't fully name when they've started crtr in a
|
|
305
|
+
* directory none of their profiles cover. A live search box: type to filter
|
|
306
|
+
* (matched against name AND project paths — you may recall the directory, not
|
|
307
|
+
* the name), ↑/↓ (or Ctrl-N/P) move the highlight, Enter picks it, Esc backs
|
|
308
|
+
* out (returns null → caller falls back to root). Because typing filters,
|
|
309
|
+
* navigation here is arrows-only — j/k are literal filter characters (fzf
|
|
310
|
+
* convention), unlike the non-search menus where j/k move. */
|
|
311
|
+
async function promptSelectExistingProfile() {
|
|
312
|
+
const all = [...listProfiles()]
|
|
313
|
+
.filter((e) => e.profileId !== ROOT_PROFILE_ID)
|
|
314
|
+
.sort((a, b) => (isMoreRecent(a.manifest.last_used_at, b.manifest.last_used_at) ? -1 : 1));
|
|
315
|
+
if (all.length === 0) {
|
|
316
|
+
process.stdout.write(` ${dim('no existing profiles to select')}\n`);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
let filter = '';
|
|
320
|
+
let sel = 0;
|
|
321
|
+
const matchesOf = () => filter === ''
|
|
322
|
+
? all
|
|
323
|
+
: all.filter((e) => e.manifest.name.toLowerCase().includes(filter) ||
|
|
324
|
+
e.manifest.projects.some((p) => tildify(p).toLowerCase().includes(filter)));
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
let prevLines = 0;
|
|
327
|
+
const draw = () => {
|
|
328
|
+
const matches = matchesOf();
|
|
329
|
+
if (sel >= matches.length)
|
|
330
|
+
sel = Math.max(0, matches.length - 1);
|
|
331
|
+
let block = `\n ${bold('Select an existing profile')}\n`;
|
|
332
|
+
block += ` ${dim('across every profile, not just this directory')}\n\n`;
|
|
333
|
+
if (matches.length === 0) {
|
|
334
|
+
block += ` ${dim(`no match for "${filter}"`)}\n`;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const nameWidth = Math.max(...matches.map((e) => e.manifest.name.length));
|
|
338
|
+
matches.forEach((e, i) => {
|
|
339
|
+
const mark = i === sel ? HILITE : ' ';
|
|
340
|
+
block += ` ${mark} ${bold(e.manifest.name.padEnd(nameWidth))} ${dim(purviewSummary(e))}\n`;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
block += `\n ${accent('\u203a')} ${filter}${dim('\u2588')}\n`;
|
|
344
|
+
block += ` ${dim('type to filter \u2191\u2193 move \u21b5 pick esc back')}\n`;
|
|
345
|
+
process.stdout.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
|
|
346
|
+
prevLines = blockRows(block);
|
|
347
|
+
};
|
|
348
|
+
const cleanup = () => {
|
|
349
|
+
process.stdin.off('keypress', onKey);
|
|
350
|
+
if (process.stdin.isTTY)
|
|
351
|
+
process.stdin.setRawMode(false);
|
|
352
|
+
process.stdin.pause();
|
|
353
|
+
};
|
|
354
|
+
const onKey = (str, k) => {
|
|
355
|
+
if (k.ctrl === true && k.name === 'c') {
|
|
356
|
+
cleanup();
|
|
357
|
+
process.stdout.write('\n');
|
|
358
|
+
process.exit(130);
|
|
359
|
+
}
|
|
360
|
+
if (k.name === 'escape') {
|
|
361
|
+
cleanup();
|
|
362
|
+
resolve(null);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const matches = matchesOf();
|
|
366
|
+
if (k.name === 'return' || k.name === 'enter') {
|
|
367
|
+
if (matches.length > 0) {
|
|
368
|
+
cleanup();
|
|
369
|
+
resolve(matches[sel].profileId);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (k.name === 'up' || (k.ctrl === true && k.name === 'p')) {
|
|
374
|
+
if (matches.length > 0) {
|
|
375
|
+
sel = (sel - 1 + matches.length) % matches.length;
|
|
376
|
+
draw();
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (k.name === 'down' || (k.ctrl === true && k.name === 'n')) {
|
|
381
|
+
if (matches.length > 0) {
|
|
382
|
+
sel = (sel + 1) % matches.length;
|
|
383
|
+
draw();
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (k.name === 'backspace') {
|
|
388
|
+
filter = filter.slice(0, -1);
|
|
389
|
+
sel = 0;
|
|
390
|
+
draw();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (str !== undefined && str.length === 1 && str >= ' ' && k.ctrl !== true) {
|
|
394
|
+
filter += str.toLowerCase();
|
|
395
|
+
sel = 0;
|
|
396
|
+
draw();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
emitKeypressEvents(process.stdin);
|
|
400
|
+
if (process.stdin.isTTY)
|
|
401
|
+
process.stdin.setRawMode(true);
|
|
402
|
+
process.stdin.resume();
|
|
403
|
+
process.stdin.on('keypress', onKey);
|
|
404
|
+
draw();
|
|
405
|
+
});
|
|
406
|
+
}
|
|
170
407
|
/** Ask for a name (defaulting to cwd's basename) and create a profile pointing
|
|
171
408
|
* at cwd on the SAME open readline — shared by both interactive create paths
|
|
172
409
|
* so the name prompt reads identically whether reached via the covering menu
|
|
@@ -180,19 +417,47 @@ async function createProfileHere(rl, cwd) {
|
|
|
180
417
|
return profileId;
|
|
181
418
|
}
|
|
182
419
|
async function promptCreateOrRoot(cwd) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
420
|
+
// Root is the highlighted default so a bare Enter (or Esc) still lands on the
|
|
421
|
+
// safe no-op, exactly as the old `[c/s/r]` prompt defaulted. Looped so Esc
|
|
422
|
+
// out of the search sub-list returns HERE rather than silently dropping to
|
|
423
|
+
// root — the create/select/root choice is always the fallback, never skipped.
|
|
424
|
+
const rows = [
|
|
425
|
+
{ label: 'create a profile here', detail: '', hotkey: 'c' },
|
|
426
|
+
{ label: 'select an existing profile', detail: '', hotkey: 's' },
|
|
427
|
+
{ label: dim('use the root profile'), detail: '', hotkey: 'r', isDefault: true },
|
|
428
|
+
];
|
|
429
|
+
for (;;) {
|
|
430
|
+
const res = await runMenu('No profile covers this directory', undefined, rows, {
|
|
431
|
+
initialIndex: 2,
|
|
432
|
+
});
|
|
433
|
+
if (res.index === 0) {
|
|
434
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
435
|
+
try {
|
|
436
|
+
return await createProfileHere(rl, cwd);
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
rl.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (res.index === 1) {
|
|
443
|
+
const selected = await promptSelectExistingProfile();
|
|
444
|
+
// Esc/back out of the search list (or no profiles to pick) → re-show this
|
|
445
|
+
// create/select/root menu, not a silent fall-through to root.
|
|
446
|
+
if (selected === null)
|
|
447
|
+
continue;
|
|
448
|
+
const entry = loadProfileManifest(selected);
|
|
449
|
+
// The picked profile does not cover cwd (nothing does here) — offer to
|
|
450
|
+
// widen its purview so the next node started here is covered too.
|
|
451
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
452
|
+
try {
|
|
453
|
+
await offerAddDirToProfile(rl, entry, cwd);
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
rl.close();
|
|
457
|
+
}
|
|
458
|
+
return entry.profileId;
|
|
459
|
+
}
|
|
460
|
+
return ensureRootProfile().profileId;
|
|
196
461
|
}
|
|
197
462
|
}
|
|
198
463
|
/** Choose among a set of candidate profiles for `cwd`, or create a new one.
|
|
@@ -215,63 +480,70 @@ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId) {
|
|
|
215
480
|
const ordered = pinned !== null ? [pinned, ...mru.filter((e) => e !== pinned)] : mru;
|
|
216
481
|
// Pad names to a shared column so the dim detail lines up down the menu.
|
|
217
482
|
const nameWidth = Math.max(...ordered.map((e) => e.manifest.name.length));
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
483
|
+
const rows = ordered.map((entry, i) => {
|
|
484
|
+
const n = entry.manifest.projects.length;
|
|
485
|
+
// For exact ties the matched path is cwd itself, so surface last-used to
|
|
486
|
+
// tell them apart; for parent/workspace coverage, WHICH of the profile's
|
|
487
|
+
// dirs matches cwd is the differentiator. Either way append the total dir
|
|
488
|
+
// count so "covers X" is never mistaken for the profile's full purview.
|
|
489
|
+
const detail = exact
|
|
490
|
+
? `${relativeUsed(entry.manifest.last_used_at)}${n > 1 ? ` \u00b7 ${n} dirs total` : ''}`
|
|
491
|
+
: (() => {
|
|
492
|
+
const md = entry.manifest.projects.find((p) => projectCovers(cwd, p)) ??
|
|
493
|
+
entry.manifest.projects[0];
|
|
494
|
+
return n > 1 ? `matches ${tildify(md)} \u00b7 ${n} dirs total` : `matches ${tildify(md)}`;
|
|
495
|
+
})();
|
|
496
|
+
const defaultText = pinned !== null && ordered[0] === pinned ? 'default (pinned)' : 'default';
|
|
497
|
+
return {
|
|
498
|
+
label: bold(entry.manifest.name.padEnd(nameWidth)),
|
|
499
|
+
detail,
|
|
500
|
+
isDefault: i === 0,
|
|
501
|
+
defaultText,
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
rows.push({ label: dim('create a new profile here'), detail: '', hotkey: 'c' });
|
|
505
|
+
// Action letters resolve on the highlighted row: `d` pins it as this
|
|
506
|
+
// directory's default; `a` widens its purview to include cwd (meaningless
|
|
507
|
+
// when the candidates already claim cwd exactly, so hidden there); `u` clears
|
|
508
|
+
// an existing pin.
|
|
509
|
+
const actionKeys = ['d'];
|
|
510
|
+
if (!exact)
|
|
511
|
+
actionKeys.push('a');
|
|
512
|
+
if (pinned !== null)
|
|
513
|
+
actionKeys.push('u');
|
|
514
|
+
const hintParts = ['d=set default'];
|
|
515
|
+
if (!exact)
|
|
516
|
+
hintParts.push('a=add dir here');
|
|
517
|
+
if (pinned !== null)
|
|
518
|
+
hintParts.push('u=unpin');
|
|
519
|
+
const res = await runMenu(exact ? 'Choose a profile' : 'Select a profile', exact
|
|
520
|
+
? 'Several profiles claim this directory as their project'
|
|
521
|
+
: 'This directory falls under existing profile(s)', rows, { actionKeys, hint: hintParts.join(' ') });
|
|
522
|
+
// The create row is last; selecting it (Enter on it, or `c`) creates here.
|
|
523
|
+
if (res.index >= ordered.length) {
|
|
524
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
525
|
+
try {
|
|
248
526
|
return await createProfileHere(rl, cwd);
|
|
249
|
-
if (answer === 'u' || answer === 'unpin') {
|
|
250
|
-
clearDefaultProfile(cwd);
|
|
251
|
-
process.stdout.write(` ${accent('\u2713')} ${dim('cleared the default for this directory')}\n`);
|
|
252
|
-
return ordered[0].profileId;
|
|
253
|
-
}
|
|
254
|
-
const m = /^(\d+)?([da])?$/.exec(answer);
|
|
255
|
-
// Unrecognized input → the safe default (choice 1); never loop, since this
|
|
256
|
-
// prompt is gating pi's boot on the shared TTY.
|
|
257
|
-
if (m === null)
|
|
258
|
-
return ordered[0].profileId;
|
|
259
|
-
const [, numStr, action] = m;
|
|
260
|
-
const idx = numStr !== undefined ? Number.parseInt(numStr, 10) - 1 : 0;
|
|
261
|
-
const picked = idx >= 0 && idx < ordered.length ? ordered[idx] : ordered[0];
|
|
262
|
-
if (action === 'd') {
|
|
263
|
-
setDefaultProfileId(cwd, picked.profileId);
|
|
264
|
-
process.stdout.write(` ${accent('\u2713')} ${dim(`"${picked.manifest.name}" is now the default here`)}\n`);
|
|
265
527
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${picked.manifest.name}"`)}\n`);
|
|
528
|
+
finally {
|
|
529
|
+
rl.close();
|
|
269
530
|
}
|
|
531
|
+
}
|
|
532
|
+
const picked = ordered[res.index];
|
|
533
|
+
if (res.action === 'u') {
|
|
534
|
+
clearDefaultProfile(cwd);
|
|
535
|
+
process.stdout.write(` ${accent('\u2713')} ${dim('cleared the default for this directory')}\n`);
|
|
270
536
|
return picked.profileId;
|
|
271
537
|
}
|
|
272
|
-
|
|
273
|
-
|
|
538
|
+
if (res.action === 'd') {
|
|
539
|
+
setDefaultProfileId(cwd, picked.profileId);
|
|
540
|
+
process.stdout.write(` ${accent('\u2713')} ${dim(`"${picked.manifest.name}" is now the default here`)}\n`);
|
|
541
|
+
}
|
|
542
|
+
else if (res.action === 'a') {
|
|
543
|
+
addProfileProject(picked.profileId, cwd);
|
|
544
|
+
process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${picked.manifest.name}"`)}\n`);
|
|
274
545
|
}
|
|
546
|
+
return picked.profileId;
|
|
275
547
|
}
|
|
276
548
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
277
549
|
*
|
|
@@ -296,7 +568,10 @@ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId) {
|
|
|
296
568
|
* is covered). Default is the MRU covering profile.
|
|
297
569
|
* - Non-interactive: auto-pick the MRU covering profile, no prompt.
|
|
298
570
|
* 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
|
|
299
|
-
* create a profile here
|
|
571
|
+
* create a profile here, select an existing profile (a searchable list of
|
|
572
|
+
* every profile, for when you started somewhere none of them cover and
|
|
573
|
+
* can't recall the exact name — the pick then offers to adopt cwd), or use
|
|
574
|
+
* the root profile. Non-interactive (no TTY):
|
|
300
575
|
* default to the root profile and print the recovery instruction to STDERR —
|
|
301
576
|
* never stdout, which the caller may be piping. */
|
|
302
577
|
export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = false) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|