cawdev-cli 0.9.0 → 1.0.0-beta

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.
@@ -0,0 +1,412 @@
1
+ // `cawdev config` — R283, and the granular half of R93's setup walk.
2
+ //
3
+ // `--setup` answers every question a machine has at once: which projects,
4
+ // which checkouts, which agents. That is right for a fresh laptop and wrong
5
+ // for the ordinary case afterwards — "this machine should also serve one more
6
+ // project" does not need to re-ask about the other three. So this file is the
7
+ // same questions, one at a time, on a config that already exists.
8
+ //
9
+ // **Every change here writes the FILE and nothing else.** A daemon already
10
+ // running keeps the config it booted with — `cawdev --setup` already says so,
11
+ // and this repeats it rather than inventing a second story. The one exception
12
+ // is `reloadIfRunning`: `agentCommands` and `acceptsRulesFromConsole` are read
13
+ // fresh off the live `config` object on every use in runner.mjs (a spawn, or
14
+ // the next heartbeat), so a SIGHUP that re-reads the file is enough to apply
15
+ // them without a restart — see runner.mjs's own handler for why that is a
16
+ // signal and not a command sent over R52's read-only socket.
17
+ //
18
+ // R288: the same functions have a SECOND door — the attached terminal's `c`
19
+ // key — and that is why every question here comes in through a parameter.
20
+ // `ask.line` and `pick` are the two shapes a question takes (a line, or one
21
+ // row of a Select); the shell subcommand hands in readline and `askOne`, the
22
+ // UI hands in its own raw-mode line editor and its own picker, and the
23
+ // function between them cannot tell which. One implementation of each change,
24
+ // so the two doors cannot disagree about what a change does.
25
+ //
26
+ // Zero dependencies, like everything in tools/.
27
+
28
+ import { connect as connectTo } from 'node:net';
29
+ import { mkdir, readFile } from 'node:fs/promises';
30
+ import { dirname } from 'node:path';
31
+ import { painter } from '../lib/ansi.mjs';
32
+ import { probeSocket, socketPathFor } from './control.mjs';
33
+ import {
34
+ askOne, cloneInto, confirm, defaultRunnerName,
35
+ findAgents, mintRunnerToken, servable, writeRunnerConfig,
36
+ } from './bootstrap.mjs';
37
+ import {
38
+ TYPE_A_PATH, absolute, describe, inspectPath, suggestProjectPaths, suggestWorkspacePaths, usable,
39
+ } from './paths.mjs';
40
+ import { Select } from './select.mjs';
41
+ import { storedSession } from './sign-in.mjs';
42
+ import { loadTokenRecord, saveToken } from './token-store.mjs';
43
+
44
+ /**
45
+ * The config as an object, or null if it will not parse.
46
+ *
47
+ * Null rather than a throw: an unreadable config is the daemon's complaint to
48
+ * make — it names the file and the parse error — and swallowing it here to
49
+ * offer a setup walk would replace a precise message with a wrong guess about
50
+ * what somebody wants. Here rather than in cawdev.mjs since R288, because the
51
+ * attached UI reads the file too and cawdev.mjs imports attach.mjs.
52
+ */
53
+ export async function readConfigFile(path) {
54
+ try {
55
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
56
+ return parsed && typeof parsed === 'object' ? parsed : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** Every slug a config file already serves, whatever shape each entry is. */
63
+ export function slugsOf(file) {
64
+ return Object.keys(file.projects ?? {});
65
+ }
66
+
67
+ /** The agents a config file says this machine spawns — runner.mjs's own reading. */
68
+ export function agentsOf(file) {
69
+ return file.agentCommands ?? (file.agentCommand ? [file.agentCommand] : ['claude']);
70
+ }
71
+
72
+ /** The checkout path(s) one project entry names, whatever shape it is. */
73
+ export function pathsOf(entry) {
74
+ if (typeof entry === 'string') return [entry];
75
+ if (Array.isArray(entry?.workspaces)) return entry.workspaces;
76
+ if (entry?.path) return [entry.path];
77
+ return [];
78
+ }
79
+
80
+ /**
81
+ * Turn one project entry into the `{workspaces: [...]}` shape — R47's third
82
+ * form — adding a path without disturbing whatever else the entry already
83
+ * carried (`allowedTools`, `browser`, …).
84
+ */
85
+ function withWorkspace(entry, path) {
86
+ if (entry == null) {
87
+ return { workspaces: [path] };
88
+ }
89
+ if (typeof entry === 'string') {
90
+ return { workspaces: [entry, path] };
91
+ }
92
+ if (Array.isArray(entry.workspaces)) {
93
+ return { ...entry, workspaces: [...entry.workspaces, path] };
94
+ }
95
+ if (entry.path) {
96
+ const { path: only, ...rest } = entry;
97
+ return { ...rest, workspaces: [only, path] };
98
+ }
99
+ return { ...entry, workspaces: [path] };
100
+ }
101
+
102
+ /**
103
+ * This token's public id, so its grants can be WIDENED in place — R173's own
104
+ * mechanism, `PUT /api/tokens/{id}/grants` — instead of minting a second token
105
+ * and leaving the first live on the Tokens page for somebody to notice.
106
+ *
107
+ * Three places to look, in the order a machine would actually have it: the
108
+ * config file itself (`tokenId`, set by `--setup` since R283), the token
109
+ * store (same field, for a top-up token minted by `mintForThisMachine`), and,
110
+ * failing both — every token minted before this card — a lookup by the label
111
+ * `mintRunnerToken` always uses. Null when none of the three finds it, which
112
+ * is not an error: the caller falls back to minting a fresh one.
113
+ */
114
+ export async function resolveTokenId(session, file, url) {
115
+ if (file.tokenId) return file.tokenId;
116
+ const stored = await loadTokenRecord(url);
117
+ if (stored?.id) return stored.id;
118
+ const label = `${file.name ?? defaultRunnerName()} (runner)`;
119
+ const tokens = await session.request('/api/tokens');
120
+ return (Array.isArray(tokens) ? tokens : [])
121
+ .find((each) => each.label === label && !each.revoked)?.id ?? null;
122
+ }
123
+
124
+ /**
125
+ * Where should a checkout go — R289, and the step that used to be an empty
126
+ * line asking for an absolute path.
127
+ *
128
+ * The suggestions come first, each with what is THERE right now (`describe`
129
+ * over `inspectPath`), and the last row is a line to type on. This is the
130
+ * plain form the shell subcommand uses; attach.mjs supplies its own through
131
+ * the same `askPath` parameter, with the line pre-filled and tab-completed.
132
+ * Both return an absolute path with `~` expanded, and the caller inspects it
133
+ * AGAIN before acting — the preview is a courtesy, the check is the rule.
134
+ */
135
+ export async function plainAskPath({ ask, say, pick = askOne, question, suggestions, slug, gitUrl, inspect = inspectPath }) {
136
+ const rows = [];
137
+ for (const each of suggestions) {
138
+ const seen = await inspect(each.path, { gitUrl });
139
+ rows.push({ id: each.path, label: each.path, hint: `${each.why} · ${describe(seen, slug)}` });
140
+ }
141
+ rows.push({ id: TYPE_A_PATH, label: 'Type a path', hint: '~ works' });
142
+ const picked = await pick(ask, say, new Select({ title: question, rows }));
143
+ if (picked.row.id !== TYPE_A_PATH) {
144
+ return picked.row.id;
145
+ }
146
+ const typed = String(await ask.line(` path [${suggestions[0]?.path ?? ''}] `)).trim();
147
+ return absolute(typed || suggestions[0]?.path || '');
148
+ }
149
+
150
+ /**
151
+ * The path a checkout goes in, checked, and cloned into if there is nothing
152
+ * there — the half of the two add-commands that is the same. A checkout of this
153
+ * repository already at the path is taken as it is; anything else that exists
154
+ * is refused in words rather than cloned over or beside.
155
+ */
156
+ async function settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspect }) {
157
+ const seen = await inspect(path, { gitUrl });
158
+ if (!usable(seen)) {
159
+ throw new Error(`${path} is ${describe(seen, slug)}. Nothing changed.`);
160
+ }
161
+ if (seen.kind === 'checkout') {
162
+ if (seen.matches === null && !await confirm(ask, `Use ${path} as a checkout of ${slug}`)) {
163
+ throw new Error('Nothing changed.');
164
+ }
165
+ say(` ${ink.success('✓')} ${ink.muted('Using the checkout already at')} ${ink.text(path)}`);
166
+ return;
167
+ }
168
+ if (!gitUrl) {
169
+ throw new Error(`${path} is ${describe(seen, slug)}, and there is no git URL to clone from — `
170
+ + 'sign in first, or point at a checkout you have already made.');
171
+ }
172
+ if (!await confirm(ask, `Clone ${gitUrl} into ${path}`)) {
173
+ throw new Error('Nothing changed.');
174
+ }
175
+ say(` ${ink.muted('Cloning')} ${ink.accent(gitUrl)} ${ink.muted('into')} ${ink.text(path)}`);
176
+ await mkdir(dirname(path), { recursive: true });
177
+ await clone(gitUrl, path);
178
+ }
179
+
180
+ /**
181
+ * Add one project this session can serve to an already-configured machine.
182
+ *
183
+ * Clones it, then widens the existing token's grants to cover it — via
184
+ * {@link resolveTokenId}, or by minting a fresh token when that comes back
185
+ * empty (an older machine's first run through this command, which is also
186
+ * what teaches it the id for next time).
187
+ */
188
+ export async function addProject({
189
+ configPath, file, url, session: given = null,
190
+ ask, say, ink = painter(3),
191
+ pick = askOne,
192
+ askPath = plainAskPath,
193
+ inspect = inspectPath,
194
+ clone = cloneInto,
195
+ write = writeRunnerConfig,
196
+ } = {}) {
197
+ const session = given ?? (await storedSession(url));
198
+ if (!session.signedIn) {
199
+ throw new Error('Not signed in on this machine. Run `cawdev --setup` first.');
200
+ }
201
+
202
+ const already = new Set(slugsOf(file));
203
+ const projects = servable(await session.request('/api/projects'))
204
+ .filter((project) => !already.has(project.slug));
205
+ if (!projects.length) {
206
+ throw new Error('Every project you can serve is already configured on this machine.');
207
+ }
208
+
209
+ let project = projects[0];
210
+ if (projects.length > 1) {
211
+ const picked = await pick(ask, say, new Select({
212
+ title: 'Which project should this machine also serve?',
213
+ rows: projects.map((candidate) => ({
214
+ id: candidate.slug, label: candidate.name, hint: candidate.slug,
215
+ })),
216
+ }));
217
+ project = projects.find((candidate) => candidate.slug === picked.row.id);
218
+ }
219
+
220
+ const path = await askPath({
221
+ ask, say, pick, inspect,
222
+ question: `Where should ${project.slug} live on this machine?`,
223
+ suggestions: suggestProjectPaths(file, project.slug),
224
+ slug: project.slug,
225
+ gitUrl: project.gitUrl ?? null,
226
+ });
227
+ await settleCheckout({ path, slug: project.slug, gitUrl: project.gitUrl ?? null, ask, say, ink, clone, inspect });
228
+
229
+ const grants = {};
230
+ for (const slug of [...already, project.slug]) {
231
+ grants[slug] = ['runner:operate'];
232
+ }
233
+ const tokenId = await resolveTokenId(session, file, url);
234
+ const next = { ...file, projects: { ...file.projects, [project.slug]: path } };
235
+ if (tokenId) {
236
+ await session.request(`/api/tokens/${tokenId}/grants`, { method: 'PUT', body: { grants } });
237
+ next.tokenId = tokenId;
238
+ say(` ${ink.success('✓')} ${ink.muted('Widened this machine\'s token to also cover')} ${ink.text(project.slug)}`);
239
+ } else {
240
+ const minted = await mintRunnerToken(session, Object.keys(grants), file.name ?? defaultRunnerName());
241
+ next.token = minted.secret;
242
+ next.tokenId = minted.id;
243
+ say(` ${ink.warn('!')} ${ink.muted('Could not find the existing token to widen — minted a new one.')}`);
244
+ say(` ${ink.muted('Revoke the old one on the Tokens page if you like; nothing here needs it any more.')}`);
245
+ }
246
+
247
+ await write(configPath, next);
248
+ say(` ${ink.success('✓')} ${ink.muted('Now serving')} ${ink.text(project.slug)} ${ink.muted('at')} ${ink.accent(path)}`);
249
+ return next;
250
+ }
251
+
252
+ /** Add another checkout for a project this machine already serves — R47. */
253
+ export async function addWorkspace({
254
+ configPath, file,
255
+ ask, say, ink = painter(3),
256
+ pick = askOne,
257
+ askPath = plainAskPath,
258
+ inspect = inspectPath,
259
+ clone = cloneInto,
260
+ write = writeRunnerConfig,
261
+ session: given = null,
262
+ url,
263
+ } = {}) {
264
+ const slugs = slugsOf(file);
265
+ if (!slugs.length) {
266
+ throw new Error('This machine serves no projects yet — add one first.');
267
+ }
268
+
269
+ const slug = slugs.length === 1 ? slugs[0]
270
+ : (await pick(ask, say, new Select({
271
+ title: 'Add a workspace to which project?',
272
+ rows: slugs.map((each) => ({ id: each, label: each })),
273
+ }))).row.id;
274
+
275
+ // The git URL first, because the suggestions are described against it —
276
+ // "already a checkout of cawdev" is only sayable when the remote is known.
277
+ let gitUrl = null;
278
+ const session = given ?? (await storedSession(url));
279
+ if (session.signedIn) {
280
+ const projects = await session.request('/api/projects');
281
+ gitUrl = (Array.isArray(projects) ? projects : []).find((p) => p.slug === slug)?.gitUrl ?? null;
282
+ }
283
+
284
+ const existing = pathsOf(file.projects[slug]).map((each) => absolute(each));
285
+ const path = await askPath({
286
+ ask, say, pick, inspect,
287
+ question: `Where should the second checkout of ${slug} live?`,
288
+ suggestions: suggestWorkspacePaths(file, slug),
289
+ slug,
290
+ gitUrl,
291
+ });
292
+ if (existing.includes(path)) {
293
+ throw new Error(`${path} is already a workspace of ${slug}.`);
294
+ }
295
+ await settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspect });
296
+
297
+ const next = { ...file, projects: { ...file.projects, [slug]: withWorkspace(file.projects[slug], path) } };
298
+ await write(configPath, next);
299
+ const count = pathsOf(next.projects[slug]).length;
300
+ say(` ${ink.success('✓')} ${ink.muted(`${slug} now has ${count} checkout${count === 1 ? '' : 's'}, `
301
+ + `so ${count} coding run${count === 1 ? '' : 's'} at once`)}`);
302
+ return next;
303
+ }
304
+
305
+ /** Turn one agent this machine spawns on or off. */
306
+ export async function setAgentEnabled({
307
+ file, configPath, enabled, command,
308
+ ask, say, ink = painter(3),
309
+ write = writeRunnerConfig,
310
+ agent = findAgents,
311
+ } = {}) {
312
+ const current = agentsOf(file);
313
+ let next;
314
+ if (enabled) {
315
+ if (current.includes(command)) {
316
+ say(` ${ink.muted(command)} ${ink.muted('is already enabled on this machine.')}`);
317
+ return file;
318
+ }
319
+ const [found] = await agent([command]);
320
+ if (!found) {
321
+ throw new Error(`${command} is not on this machine's PATH. Install it before enabling it here.`);
322
+ }
323
+ const label = command === 'agy' ? 'Antigravity CLI' : 'Claude Code';
324
+ if (!await confirm(ask, `Is ${label} signed in on this machine?`)) {
325
+ throw new Error(`Sign in first — run \`${command}\` once in a terminal and follow it.`);
326
+ }
327
+ next = { ...file, agentCommands: [...current, command] };
328
+ } else {
329
+ if (!current.includes(command)) {
330
+ say(` ${ink.muted(command)} ${ink.muted('was already not enabled here.')}`);
331
+ return file;
332
+ }
333
+ const remaining = current.filter((each) => each !== command);
334
+ if (!remaining.length) {
335
+ throw new Error('This machine must spawn at least one agent — enable another before disabling this one.');
336
+ }
337
+ next = { ...file, agentCommands: remaining };
338
+ }
339
+ await write(configPath, next);
340
+ say(` ${ink.success('✓')} ${ink.muted('This machine spawns:')} ${ink.text(next.agentCommands.join(', '))}`);
341
+ return next;
342
+ }
343
+
344
+ /** Turn `acceptsRulesFromConsole` on or off — R126. */
345
+ export async function setAcceptsRulesFromConsole({
346
+ file, configPath, enabled,
347
+ ask, say, ink = painter(3),
348
+ write = writeRunnerConfig,
349
+ } = {}) {
350
+ if (enabled && !await confirm(ask,
351
+ 'Turning this on lets anyone able to write to your cawdev widen what an unattended '
352
+ + 'agent may run in your checkouts. Are you sure')) {
353
+ throw new Error('Nothing changed.');
354
+ }
355
+ const next = { ...file, acceptsRulesFromConsole: enabled };
356
+ await write(configPath, next);
357
+ say(` ${ink.success('✓')} ${ink.muted('acceptsRulesFromConsole is now')} ${ink.text(String(enabled))}`);
358
+ return next;
359
+ }
360
+
361
+ /**
362
+ * The pid of a daemon running on this exact config, or null.
363
+ *
364
+ * The socket is keyed by NAME (R52), not by config path, so a machine
365
+ * renamed between `--setup` and now would miss its own daemon — an edge case
366
+ * left alone deliberately: naming this wrong points the reload at nobody's
367
+ * process rather than somebody else's.
368
+ */
369
+ async function livePidFor(name) {
370
+ const path = socketPathFor(name);
371
+ if (!(await probeSocket(path))) return null;
372
+ return new Promise((done) => {
373
+ const client = connectTo(path);
374
+ let buffer = '';
375
+ const finish = (pid) => {
376
+ clearTimeout(timer);
377
+ try { client.destroy(); } catch { /* already gone */ }
378
+ done(pid);
379
+ };
380
+ const timer = setTimeout(() => finish(null), 1000);
381
+ client.setEncoding('utf8');
382
+ client.on('data', (chunk) => {
383
+ buffer += chunk;
384
+ const newline = buffer.indexOf('\n');
385
+ if (newline === -1) return;
386
+ try {
387
+ finish(JSON.parse(buffer.slice(0, newline))?.runner?.pid ?? null);
388
+ } catch {
389
+ finish(null);
390
+ }
391
+ });
392
+ client.on('error', () => finish(null));
393
+ });
394
+ }
395
+
396
+ /**
397
+ * SIGHUP a daemon already running on this config, so `agentCommands` and
398
+ * `acceptsRulesFromConsole` take effect without a restart. Never a socket
399
+ * command — see this file's header and runner.mjs's handler for why.
400
+ */
401
+ export async function reloadIfRunning(name, say, ink = painter(3), { pidOf = livePidFor, kill = process.kill } = {}) {
402
+ const pid = await pidOf(name);
403
+ if (!pid) return false;
404
+ try {
405
+ kill(pid, 'SIGHUP');
406
+ say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${pid}) to re-read this — no restart needed.`)}`);
407
+ return true;
408
+ } catch (failure) {
409
+ say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up.`)}`);
410
+ return false;
411
+ }
412
+ }
@@ -0,0 +1,254 @@
1
+ // Where a checkout goes, and what is there already — R289.
2
+ //
3
+ // R288 put the config screen inside the terminal and the first use of it found
4
+ // the path step unusable: an empty line, an absolute path to retype, no
5
+ // completion, `~` silently wrong, and a clone into whatever was typed. This file
6
+ // is the part of the fix that can be READ without a terminal: what to suggest,
7
+ // how to complete, and what a path IS right now — the same answers whether they
8
+ // are drawn as a picker in attach.mjs or printed as a numbered list by the shell
9
+ // subcommand.
10
+ //
11
+ // Pure where it can be (`expandHome`, `completePath`, `sameRepository`,
12
+ // `describe`) and injectable where it cannot (`inspectPath` reads the disk and
13
+ // asks git), because a path check that can only be tested against the real
14
+ // filesystem is one that gets tested once.
15
+ //
16
+ // Zero dependencies, like everything in tools/.
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { readdir, stat } from 'node:fs/promises';
20
+ import { homedir } from 'node:os';
21
+ import { basename, dirname, join, resolve } from 'node:path';
22
+
23
+ /** The row a picker ends with: not one of the suggestions, a line to type on. */
24
+ export const TYPE_A_PATH = 'type-a-path';
25
+
26
+ /**
27
+ * `~` and `~/…` as the shell reads them.
28
+ *
29
+ * `path.resolve` does not know about `~`: it treated `~/code/x` as a relative
30
+ * path and produced `<cwd>/~/code/x`, which is the bug that started this card.
31
+ * Only the leading tilde, and only the current user's — `~bob` is a shell
32
+ * feature nobody types into a config screen, and guessing at it is how a path
33
+ * lands in somebody else's home.
34
+ */
35
+ export function expandHome(path, home = homedir()) {
36
+ const text = String(path ?? '').trim();
37
+ if (text === '~') return home;
38
+ if (text.startsWith('~/')) return join(home, text.slice(2));
39
+ return text;
40
+ }
41
+
42
+ /** Absolute, with `~` expanded first — the one spelling every path here ends in. */
43
+ export function absolute(path, home = homedir()) {
44
+ return resolve(expandHome(path, home));
45
+ }
46
+
47
+ /**
48
+ * The checkout paths a config file names, whatever shape each entry is.
49
+ * Mirrors configure.mjs's `pathsOf` on purpose rather than importing it —
50
+ * that module imports this one.
51
+ */
52
+ function pathsIn(file) {
53
+ return Object.values(file?.projects ?? {}).flatMap((entry) => {
54
+ if (typeof entry === 'string') return [entry];
55
+ if (Array.isArray(entry?.workspaces)) return entry.workspaces;
56
+ if (entry?.path) return [entry.path];
57
+ return [];
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Where a NEW project's checkout would plausibly go on this machine.
63
+ *
64
+ * Beside the checkouts it already serves first — somebody with everything
65
+ * under `~/Documents/Dev/agents-workspace/test/` wants the next one there too,
66
+ * not under a default they never chose — then the default root. Deduplicated
67
+ * and in that order, so the first row is the best guess and enter takes it.
68
+ */
69
+ export function suggestProjectPaths(file, slug, { home = homedir() } = {}) {
70
+ const seen = new Set();
71
+ const out = [];
72
+ const add = (path, why) => {
73
+ const full = absolute(path, home);
74
+ if (seen.has(full)) return;
75
+ seen.add(full);
76
+ out.push({ path: full, why });
77
+ };
78
+ for (const existing of pathsIn(file)) {
79
+ add(join(dirname(absolute(existing, home)), slug), `beside ${basename(existing)}`);
80
+ }
81
+ add(join(home, 'cawdev', slug), 'the default');
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Where ANOTHER checkout of a project this machine already serves would go —
87
+ * R47's second workspace. `<first>-2`, then `-3`, skipping the numbers the
88
+ * config already uses; then the default root's version of the same.
89
+ */
90
+ export function suggestWorkspacePaths(file, slug, { home = homedir() } = {}) {
91
+ const entry = file?.projects?.[slug];
92
+ const existing = pathsIn({ projects: { [slug]: entry } }).map((path) => absolute(path, home));
93
+ const taken = new Set(existing);
94
+ const out = [];
95
+ const seen = new Set();
96
+ const add = (path, why) => {
97
+ if (seen.has(path) || taken.has(path)) return;
98
+ seen.add(path);
99
+ out.push({ path, why });
100
+ };
101
+ const base = existing[0] ?? join(home, 'cawdev', slug);
102
+ for (let n = 2; n < 100 && out.length < 1; n++) {
103
+ add(`${base}-${n}`, `next to ${basename(base)}`);
104
+ }
105
+ add(`${join(home, 'cawdev', slug)}-${existing.length + 1}`, 'under the default root');
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Tab, on a path — pure over the names in the directory being typed in.
111
+ *
112
+ * `text` is what is on the line; `entries` are the DIRECTORY names in the
113
+ * folder that text points into (its own folder if it ends in `/`, its parent
114
+ * otherwise). Returns the directories that match what has been typed so far,
115
+ * as full paths, and what the line should become: one match completes whole
116
+ * with a trailing `/`, several complete as far as they agree, none leave the
117
+ * line alone. What every shell does, so nobody has to be taught it.
118
+ */
119
+ export function completePath(text, entries, home = homedir()) {
120
+ const raw = String(text ?? '');
121
+ const expanded = expandHome(raw, home);
122
+ const endsWithSlash = expanded.endsWith('/');
123
+ const dir = endsWithSlash ? expanded : dirname(expanded);
124
+ const prefix = endsWithSlash ? '' : basename(expanded);
125
+ const matches = (entries ?? [])
126
+ .filter((name) => name.startsWith(prefix) && (prefix.startsWith('.') || !name.startsWith('.')))
127
+ .sort()
128
+ .map((name) => join(dir, name));
129
+ if (!matches.length) {
130
+ return { dir, matches, completed: raw };
131
+ }
132
+ if (matches.length === 1) {
133
+ return { dir, matches, completed: `${matches[0]}/` };
134
+ }
135
+ const names = matches.map((path) => basename(path));
136
+ let common = names[0];
137
+ for (const name of names.slice(1)) {
138
+ let i = 0;
139
+ while (i < common.length && common[i] === name[i]) i += 1;
140
+ common = common.slice(0, i);
141
+ }
142
+ const completed = common.length > prefix.length ? join(dir, common) : expanded;
143
+ return { dir, matches, completed };
144
+ }
145
+
146
+ /** The directory names in `dir`, or none when it cannot be read. */
147
+ export async function listDirectories(dir) {
148
+ try {
149
+ const entries = await readdir(dir, { withFileTypes: true });
150
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Two remotes naming the same repository — `git@github.com:o/r.git` and
158
+ * `https://github.com/o/r` are one repository, and a checkout cloned over ssh
159
+ * must not be refused because the platform stored the https form.
160
+ */
161
+ export function sameRepository(a, b) {
162
+ const key = (url) => String(url ?? '')
163
+ .trim()
164
+ .toLowerCase()
165
+ .replace(/^[a-z+]+:\/\//, '')
166
+ .replace(/^[^@/]+@/, '')
167
+ .replace(/^([^:/]+):(?!\/\/)/, '$1/')
168
+ .replace(/\.git$/, '')
169
+ .replace(/\/+$/, '');
170
+ return Boolean(a && b) && key(a) === key(b);
171
+ }
172
+
173
+ /** `git -C path remote get-url origin`, or null when there is none. */
174
+ export function originOf(path) {
175
+ return new Promise((done) => {
176
+ let out = '';
177
+ const child = spawn('git', ['-C', path, 'remote', 'get-url', 'origin'], {
178
+ stdio: ['ignore', 'pipe', 'ignore'],
179
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
180
+ });
181
+ child.stdout.on('data', (chunk) => { out += chunk; });
182
+ child.on('error', () => done(null));
183
+ child.on('close', (code) => done(code === 0 ? out.trim() || null : null));
184
+ });
185
+ }
186
+
187
+ /**
188
+ * What a path IS, right now — the preview under the line and the check before
189
+ * anything is cloned, one function so they cannot disagree.
190
+ *
191
+ * missing nothing there; a clone would create it
192
+ * empty a directory with nothing in it; a clone would fill it
193
+ * checkout a git checkout whose origin is this project's repository
194
+ * other-repo a git checkout of something else
195
+ * not-empty a directory holding things that are not this project
196
+ * file not a directory at all
197
+ *
198
+ * `gitUrl` may be null (nobody signed in to ask the platform); then any
199
+ * checkout is `checkout` with `matches: null`, and the caller says so.
200
+ */
201
+ export async function inspectPath(path, { gitUrl = null } = {}, {
202
+ statOf = stat, list = readdir, remoteOf = originOf,
203
+ } = {}) {
204
+ let info;
205
+ try {
206
+ info = await statOf(path);
207
+ } catch {
208
+ return { kind: 'missing' };
209
+ }
210
+ if (!info.isDirectory()) {
211
+ return { kind: 'file' };
212
+ }
213
+ let names;
214
+ try {
215
+ names = await list(path);
216
+ } catch (failure) {
217
+ return { kind: 'unreadable', error: failure.message };
218
+ }
219
+ if (!names.length) {
220
+ return { kind: 'empty' };
221
+ }
222
+ if (names.includes('.git')) {
223
+ const remote = await remoteOf(path);
224
+ if (!gitUrl) {
225
+ return { kind: 'checkout', remote, matches: null };
226
+ }
227
+ return sameRepository(remote, gitUrl)
228
+ ? { kind: 'checkout', remote, matches: true }
229
+ : { kind: 'other-repo', remote };
230
+ }
231
+ return { kind: 'not-empty' };
232
+ }
233
+
234
+ /** One line for a person, about an inspection — the preview and the refusal. */
235
+ export function describe(inspection, slug = 'this project') {
236
+ switch (inspection?.kind) {
237
+ case 'missing': return 'new folder — will clone here';
238
+ case 'empty': return 'empty folder — will clone here';
239
+ case 'checkout':
240
+ return inspection.matches === null
241
+ ? `a checkout (${inspection.remote ?? 'no origin'}) — cannot check it is ${slug} without signing in`
242
+ : `already a checkout of ${slug} — used as it is`;
243
+ case 'other-repo': return `a checkout of ${inspection.remote ?? 'something else'}, not ${slug}`;
244
+ case 'not-empty': return 'exists and is not empty';
245
+ case 'file': return 'a file, not a folder';
246
+ case 'unreadable': return `cannot be read (${inspection.error})`;
247
+ default: return '';
248
+ }
249
+ }
250
+
251
+ /** Whether a path in this state may be used — cloned into, or taken as is. */
252
+ export function usable(inspection) {
253
+ return ['missing', 'empty', 'checkout'].includes(inspection?.kind);
254
+ }