flowviant 0.72.0 → 0.73.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/bin/cli.mjs +68 -15
- package/bin/lib/credentials.mjs +34 -0
- package/bin/lib/tty.mjs +123 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -244,7 +244,7 @@ if (process.argv[2] === 'env') {
|
|
|
244
244
|
// two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
|
|
245
245
|
// kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
|
|
246
246
|
// stopped process runs no timers). See tty.mjs.
|
|
247
|
-
const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
|
|
247
|
+
const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
|
|
248
248
|
const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
|
|
249
249
|
|
|
250
250
|
/** How long the one-time binding confirm waits before serving unbound. A person
|
|
@@ -285,6 +285,8 @@ if (!FLEET_TOKEN) {
|
|
|
285
285
|
}
|
|
286
286
|
if (CREDENTIAL.choices?.length && interactive) {
|
|
287
287
|
const creds = await import('./lib/credentials.mjs');
|
|
288
|
+
const { originSlug } = await import('./lib/git.mjs');
|
|
289
|
+
const { basename } = await import('node:path');
|
|
288
290
|
const { choices, repoRoot } = CREDENTIAL;
|
|
289
291
|
console.log(
|
|
290
292
|
CREDENTIAL.reason === 'outside-repo'
|
|
@@ -293,27 +295,78 @@ if (!FLEET_TOKEN) {
|
|
|
293
295
|
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
294
296
|
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
295
297
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
|
|
299
|
+
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
|
|
300
|
+
// WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
|
|
301
|
+
// refuses to serve a project the repo PATH did not name (the skadooble law);
|
|
302
|
+
// this only decides which row the cursor starts on, using the repo's folder
|
|
303
|
+
// name and its github repo-name against the stored project names. A unique
|
|
304
|
+
// match becomes ONE keypress; a wrong guess costs nothing, because the human
|
|
305
|
+
// still confirms. `multiple-bound` gets no hint — every choice already names
|
|
306
|
+
// this repo, so nothing distinguishes them.
|
|
307
|
+
const slug = repoRoot ? originSlug(repoRoot) : null;
|
|
308
|
+
const likely =
|
|
309
|
+
CREDENTIAL.reason === 'multiple-bound'
|
|
310
|
+
? -1
|
|
311
|
+
: creds.likelyChoiceIndex(choices, {
|
|
312
|
+
repoBasename: repoRoot ? basename(repoRoot) : null,
|
|
313
|
+
repoSlugName: slug ? slug.split('/')[1] : null,
|
|
314
|
+
});
|
|
315
|
+
|
|
298
316
|
// Bounded like the confirm below, and for the same reason — but silence
|
|
299
317
|
// means something DIFFERENT here and the difference is load-bearing. There
|
|
300
318
|
// is a real ambiguity to resolve; serving a guess is the skadooble bug.
|
|
301
319
|
// So no answer REFUSES, which is exactly what this branch already does
|
|
302
320
|
// headless, and the message says how to answer without being present.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
321
|
+
let chosen = null; // 0-based into [...choices, login]
|
|
322
|
+
if (menuSupported()) {
|
|
323
|
+
const rowLabel = (e) =>
|
|
324
|
+
creds.projectLabel(e) + (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
|
|
325
|
+
const options = [...choices.map(rowLabel), loginLabel];
|
|
326
|
+
// Say WHY the cursor starts where it does — "intuitive" made visible.
|
|
327
|
+
if (likely >= 0) options[likely] += ' ← looks like this repo';
|
|
328
|
+
const res = await selectMenu({
|
|
329
|
+
options,
|
|
330
|
+
defaultIndex: likely >= 0 ? likely : 0,
|
|
331
|
+
timeoutMs: PICK_TIMEOUT_MS,
|
|
332
|
+
});
|
|
333
|
+
if (res.timedOut) {
|
|
334
|
+
console.error(
|
|
335
|
+
`\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
|
|
336
|
+
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
337
|
+
);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
if (res.cancelled) {
|
|
341
|
+
console.error('nothing chosen — nothing started.');
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
if (!res.unsupported) chosen = res.index;
|
|
345
|
+
}
|
|
346
|
+
if (chosen === null) {
|
|
347
|
+
// Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
|
|
348
|
+
// takes the likely default when there is one, so it is one keystroke here
|
|
349
|
+
// too.
|
|
350
|
+
console.log(listLines(choices, creds));
|
|
351
|
+
console.log(` ${choices.length + 1}. ${loginLabel}`);
|
|
352
|
+
const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
|
|
353
|
+
const raw = await askWithTimeout(
|
|
354
|
+
`Which project should this daemon serve? [1-${choices.length + 1}]${hint} `,
|
|
355
|
+
PICK_TIMEOUT_MS
|
|
311
356
|
);
|
|
312
|
-
|
|
357
|
+
if (raw === null) {
|
|
358
|
+
console.error(
|
|
359
|
+
`\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
|
|
360
|
+
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
361
|
+
);
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
|
|
365
|
+
chosen = Number.isInteger(n) ? n - 1 : -1;
|
|
313
366
|
}
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
const picked =
|
|
367
|
+
|
|
368
|
+
if (chosen === choices.length) await reexecAfterLogin();
|
|
369
|
+
const picked = chosen >= 0 ? choices[chosen] : undefined;
|
|
317
370
|
if (!picked) {
|
|
318
371
|
console.error('nothing chosen — nothing started.');
|
|
319
372
|
process.exit(1);
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -136,6 +136,40 @@ export function projectLabel(e) {
|
|
|
136
136
|
return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** Collapse a name or slug for loose comparison: "My Project", "my-project"
|
|
140
|
+
* and "myproject" all become "myproject". */
|
|
141
|
+
function normalizeName(s) {
|
|
142
|
+
return String(s ?? '')
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^a-z0-9]/g, '');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* WHICH stored project most likely goes with this repo — a HINT for the
|
|
149
|
+
* picker's default, and DELIBERATELY not a licence to serve it. `resolveStored
|
|
150
|
+
* Credential` matches by repo PATH and refuses to guess past that, because
|
|
151
|
+
* serving a project the path did not name is the "it said skadooble in my
|
|
152
|
+
* calendar repo" surprise this whole file exists to prevent. A name that
|
|
153
|
+
* matches the repo's folder or its github repo-name is softer evidence — good
|
|
154
|
+
* enough to put the cursor on that row so the answer is one keypress, never
|
|
155
|
+
* good enough to skip the human. Two names can collide (a project "api" and a
|
|
156
|
+
* repo "api"), which is exactly why this only pre-selects.
|
|
157
|
+
*
|
|
158
|
+
* Returns the index of a UNIQUE match, or -1 when nothing matches or more than
|
|
159
|
+
* one does — an ambiguous hint is not a hint, and a default that is as likely
|
|
160
|
+
* wrong as right is worse than starting at the top.
|
|
161
|
+
*/
|
|
162
|
+
export function likelyChoiceIndex(choices, { repoBasename, repoSlugName } = {}) {
|
|
163
|
+
const wants = new Set([normalizeName(repoBasename), normalizeName(repoSlugName)].filter(Boolean));
|
|
164
|
+
if (wants.size === 0) return -1;
|
|
165
|
+
const hits = [];
|
|
166
|
+
choices.forEach((e, i) => {
|
|
167
|
+
const n = normalizeName(e?.name);
|
|
168
|
+
if (n && wants.has(n)) hits.push(i);
|
|
169
|
+
});
|
|
170
|
+
return hits.length === 1 ? hits[0] : -1;
|
|
171
|
+
}
|
|
172
|
+
|
|
139
173
|
function mutate(fn) {
|
|
140
174
|
const f = readFile() ?? {};
|
|
141
175
|
if (!f.projects || typeof f.projects !== 'object') f.projects = {};
|
package/bin/lib/tty.mjs
CHANGED
|
@@ -109,3 +109,126 @@ export async function askWithTimeout(query, timeoutMs) {
|
|
|
109
109
|
process.off('SIGTTOU', noop);
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Can we draw an arrow-navigable menu? Raw mode is what turns ↑/↓ into
|
|
115
|
+
* keystrokes we receive one at a time; without `setRawMode` (a pipe, a
|
|
116
|
+
* terminal that refuses raw input) there is nothing to drive, and the caller
|
|
117
|
+
* falls back to the numeric prompt rather than drawing a menu nobody can move.
|
|
118
|
+
* `canPrompt()` still gates whether we ask AT ALL — this only decides HOW.
|
|
119
|
+
*/
|
|
120
|
+
export function menuSupported() {
|
|
121
|
+
return Boolean(
|
|
122
|
+
process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The menu's key handling, PURE so it can be tested without a terminal. Given a
|
|
128
|
+
* keypress and the current cursor, returns exactly one intent:
|
|
129
|
+
* { index } move the highlight (arrows wrap; k/j vim-style; g/G ends)
|
|
130
|
+
* { choose } take a row (Enter takes the highlight; 1–9 jump-and-take)
|
|
131
|
+
* { cancel } Esc, q, or Ctrl-C — nothing chosen
|
|
132
|
+
* null a key we ignore
|
|
133
|
+
* A number past the end is ignored, not clamped: pressing 9 in a 3-row list
|
|
134
|
+
* must not silently select row 3.
|
|
135
|
+
*/
|
|
136
|
+
export function menuKey(key, { index, count }) {
|
|
137
|
+
if (key === '\x03' || key === '\x1b' || key === 'q' || key === 'Q') return { cancel: true };
|
|
138
|
+
if (key === '\r' || key === '\n') return { choose: index };
|
|
139
|
+
if (key === '\x1b[A' || key === 'k') return { index: (index - 1 + count) % count };
|
|
140
|
+
if (key === '\x1b[B' || key === 'j') return { index: (index + 1) % count };
|
|
141
|
+
if (key === '\x1b[H' || key === 'g') return { index: 0 };
|
|
142
|
+
if (key === '\x1b[F' || key === 'G') return { index: count - 1 };
|
|
143
|
+
if (/^[1-9]$/.test(key)) {
|
|
144
|
+
const n = Number(key) - 1;
|
|
145
|
+
return n < count ? { choose: n } : null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* An arrow-navigable picker for the start path. Resolves one of:
|
|
154
|
+
* { index } the row taken
|
|
155
|
+
* { cancelled } Esc/q/Ctrl-C
|
|
156
|
+
* { timedOut } nobody drove it within timeoutMs
|
|
157
|
+
* { unsupported } no raw mode — the caller uses the numeric prompt instead
|
|
158
|
+
*
|
|
159
|
+
* Held to this file's one law — no start-path prompt may hang the daemon — the
|
|
160
|
+
* same way `askWithTimeout` is: it keeps the timeout, installs the
|
|
161
|
+
* SIGTTIN/SIGTTOU no-ops that keep that timer alive, restores the terminal in
|
|
162
|
+
* every exit, and refuses (returns `unsupported`) rather than half-drawing when
|
|
163
|
+
* raw mode is not really there. The caller still gates on `canPrompt()` before
|
|
164
|
+
* ever reaching here.
|
|
165
|
+
*/
|
|
166
|
+
export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
|
|
167
|
+
if (!menuSupported()) return { unsupported: true };
|
|
168
|
+
const stdin = process.stdin;
|
|
169
|
+
const out = process.stdout;
|
|
170
|
+
const count = options.length;
|
|
171
|
+
if (count === 0) return { unsupported: true };
|
|
172
|
+
let index = Math.min(Math.max(defaultIndex | 0, 0), count - 1);
|
|
173
|
+
|
|
174
|
+
const width = Math.max(24, (out.columns || 80) - 2);
|
|
175
|
+
const clip = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
|
|
176
|
+
const frame = () =>
|
|
177
|
+
options
|
|
178
|
+
.map((o, i) => (i === index ? `\x1b[7m> ${clip(o)}\x1b[0m` : ` ${clip(o)}`))
|
|
179
|
+
.join('\n') +
|
|
180
|
+
'\n' +
|
|
181
|
+
`\x1b[2m ${MENU_FOOTER}\x1b[0m`;
|
|
182
|
+
const lineCount = count + 1; // rows + footer
|
|
183
|
+
|
|
184
|
+
let rendered = false;
|
|
185
|
+
const draw = () => {
|
|
186
|
+
if (rendered) out.write(`\x1b[${lineCount}A`); // back to the top of our block
|
|
187
|
+
out.write('\r\x1b[0J'); // clear from here to the end of the screen
|
|
188
|
+
out.write(frame() + '\n');
|
|
189
|
+
rendered = true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const noop = () => {};
|
|
193
|
+
return await new Promise((resolve) => {
|
|
194
|
+
let done = false;
|
|
195
|
+
const finish = (result) => {
|
|
196
|
+
if (done) return;
|
|
197
|
+
done = true;
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
stdin.removeListener('data', onData);
|
|
200
|
+
try {
|
|
201
|
+
stdin.setRawMode(false);
|
|
202
|
+
} catch {
|
|
203
|
+
/* already restored / gone */
|
|
204
|
+
}
|
|
205
|
+
stdin.pause();
|
|
206
|
+
process.off('SIGTTIN', noop);
|
|
207
|
+
process.off('SIGTTOU', noop);
|
|
208
|
+
out.write('\x1b[?25h'); // show the cursor again
|
|
209
|
+
resolve(result);
|
|
210
|
+
};
|
|
211
|
+
const onData = (buf) => {
|
|
212
|
+
const action = menuKey(buf.toString('utf8'), { index, count });
|
|
213
|
+
if (!action) return;
|
|
214
|
+
if (action.cancel) return finish({ cancelled: true });
|
|
215
|
+
if (action.choose !== undefined) return finish({ index: action.choose });
|
|
216
|
+
if (action.index !== undefined) {
|
|
217
|
+
index = action.index;
|
|
218
|
+
draw();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
process.on('SIGTTIN', noop);
|
|
222
|
+
process.on('SIGTTOU', noop);
|
|
223
|
+
const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs);
|
|
224
|
+
try {
|
|
225
|
+
stdin.setRawMode(true);
|
|
226
|
+
} catch {
|
|
227
|
+
return finish({ unsupported: true });
|
|
228
|
+
}
|
|
229
|
+
stdin.resume();
|
|
230
|
+
out.write('\x1b[?25l'); // hide the cursor while we own the block
|
|
231
|
+
draw();
|
|
232
|
+
stdin.on('data', onData);
|
|
233
|
+
});
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|