aicomputer 0.1.22 → 0.2.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.
@@ -1,544 +0,0 @@
1
- import {
2
- createHandleSession,
3
- ensureMutagenSshEnvironment,
4
- getHandleSessionName,
5
- isAbortError,
6
- listOwnedSessions,
7
- selectPreferredSession,
8
- terminateSession
9
- } from "./chunk-TPFE3CC6.js";
10
- import {
11
- readMountHandleMeta,
12
- readMountStatusSnapshot,
13
- removeMountHandleState,
14
- writeMountHandleMeta,
15
- writeMountStatusSnapshot
16
- } from "./chunk-KXLTHWW3.js";
17
- import {
18
- getConnectionInfo,
19
- listComputers
20
- } from "./chunk-E7QD4MHI.js";
21
-
22
- // src/lib/mount-reconcile.ts
23
- import { readdir, mkdir, rm } from "fs/promises";
24
- import { join } from "path";
25
-
26
- // src/lib/computer-picker.ts
27
- import { select } from "@inquirer/prompts";
28
- import chalk2 from "chalk";
29
-
30
- // src/lib/format.ts
31
- import chalk from "chalk";
32
- function padEnd(str, len) {
33
- const visible = str.replace(/\u001b\[[0-9;]*m/g, "");
34
- return str + " ".repeat(Math.max(0, len - visible.length));
35
- }
36
- function timeAgo(dateStr) {
37
- const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1e3);
38
- if (seconds < 60) return `${seconds}s ago`;
39
- const minutes = Math.floor(seconds / 60);
40
- if (minutes < 60) return `${minutes}m ago`;
41
- const hours = Math.floor(minutes / 60);
42
- if (hours < 24) return `${hours}h ago`;
43
- const days = Math.floor(hours / 24);
44
- return `${days}d ago`;
45
- }
46
- function formatStatus(status) {
47
- switch (status) {
48
- case "running":
49
- return chalk.green(status);
50
- case "pending":
51
- case "provisioning":
52
- case "starting":
53
- return chalk.yellow(status);
54
- case "stopping":
55
- case "stopped":
56
- case "deleted":
57
- return chalk.gray(status);
58
- case "error":
59
- return chalk.red(status);
60
- default:
61
- return status;
62
- }
63
- }
64
-
65
- // src/lib/computer-picker.ts
66
- async function promptForSSHComputer(computers, message) {
67
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
68
- throw new Error("computer id or handle is required when not running interactively");
69
- }
70
- const available = computers.filter(isSSHSelectable);
71
- if (available.length === 0) {
72
- if (computers.length === 0) {
73
- throw new Error("no computers found");
74
- }
75
- throw new Error("no running computers with SSH enabled");
76
- }
77
- const handleWidth = Math.max(6, ...available.map((computer) => computer.handle.length));
78
- const selectedID = await select({
79
- message,
80
- pageSize: Math.min(available.length, 10),
81
- choices: available.map((computer) => ({
82
- name: `${padEnd(chalk2.white(computer.handle), handleWidth + 2)}${padEnd(formatStatus(computer.status), 12)}${chalk2.dim(describeSSHChoice(computer))}`,
83
- value: computer.id
84
- }))
85
- });
86
- return available.find((computer) => computer.id === selectedID) ?? available[0];
87
- }
88
- function isSSHSelectable(computer) {
89
- return computer.ssh_enabled && computer.status === "running";
90
- }
91
- function describeSSHChoice(computer) {
92
- const displayName = computer.display_name.trim();
93
- if (displayName && displayName !== computer.handle) {
94
- return `${displayName} ${timeAgo(computer.updated_at)}`;
95
- }
96
- return `${computer.runtime_family} ${timeAgo(computer.updated_at)}`;
97
- }
98
-
99
- // src/lib/mount-reconcile.ts
100
- function computeMountPlan(input) {
101
- const desiredHandles = new Set(input.desired.map((entry) => entry.handle));
102
- const sessionGroups = /* @__PURE__ */ new Map();
103
- for (const session of input.ownedSessions) {
104
- const group = sessionGroups.get(session.handle);
105
- if (group) {
106
- group.push(session);
107
- } else {
108
- sessionGroups.set(session.handle, [session]);
109
- }
110
- }
111
- const toCreate = [];
112
- const toInspect = [];
113
- const toReset = /* @__PURE__ */ new Set();
114
- for (const entry of input.desired) {
115
- const sessions = sessionGroups.get(entry.handle) ?? [];
116
- const reusable = sessions.length === 1 && !sessions[0].legacy;
117
- if (reusable) {
118
- toInspect.push(entry);
119
- continue;
120
- }
121
- if (sessions.length > 0) {
122
- toReset.add(entry.handle);
123
- }
124
- toCreate.push(entry);
125
- }
126
- const toStop = /* @__PURE__ */ new Set();
127
- for (const session of input.ownedSessions) {
128
- if (!desiredHandles.has(session.handle)) {
129
- toStop.add(session.handle);
130
- }
131
- }
132
- for (const entry of input.rootEntries) {
133
- if (!desiredHandles.has(entry)) {
134
- toStop.add(entry);
135
- }
136
- }
137
- for (const handle of toReset) {
138
- toStop.delete(handle);
139
- }
140
- return {
141
- toCreate,
142
- toInspect,
143
- toReset: Array.from(toReset).sort(),
144
- toStop: Array.from(toStop).sort(),
145
- pending: input.pending
146
- };
147
- }
148
- async function planMountReconcile(config, paths, signal) {
149
- const computers = await listComputers(signal);
150
- const desired = [];
151
- const pending = [];
152
- for (const computer of computers.filter(isSSHSelectable)) {
153
- const mountPath = join(paths.rootPath, computer.handle);
154
- try {
155
- const info = await getConnectionInfo(computer.id, signal);
156
- if (!info.connection.ssh_available) {
157
- pending.push({
158
- handle: computer.handle,
159
- mountPath,
160
- ready: false,
161
- message: "SSH is not ready yet"
162
- });
163
- continue;
164
- }
165
- } catch (error) {
166
- pending.push({
167
- handle: computer.handle,
168
- mountPath,
169
- ready: false,
170
- message: error instanceof Error ? error.message : "Failed to confirm SSH readiness"
171
- });
172
- continue;
173
- }
174
- desired.push({
175
- handle: computer.handle,
176
- mountPath,
177
- ready: true
178
- });
179
- }
180
- throwIfAborted(signal);
181
- const ownedSessions = await listOwnedSessions(config, paths, signal);
182
- const rootEntries = await listRootHandleDirectories(paths.rootPath);
183
- return {
184
- plan: computeMountPlan({
185
- desired,
186
- pending,
187
- ownedSessions,
188
- rootEntries
189
- }),
190
- ownedSessions
191
- };
192
- }
193
- async function reconcileMounts(config, paths, controllerPid = process.pid, signal) {
194
- ensureMutagenSshEnvironment(config, paths);
195
- throwIfAborted(signal);
196
- const { plan, ownedSessions } = await planMountReconcile(config, paths, signal);
197
- const mounts = [];
198
- const issues = [];
199
- const ownedByHandle = groupSessionsByHandle(ownedSessions);
200
- for (const handle of [...plan.toStop, ...plan.toReset]) {
201
- throwIfAborted(signal);
202
- const sessions = ownedByHandle.get(handle) ?? [];
203
- try {
204
- await terminateOwnedSessions(sessions, config, paths, signal);
205
- await removeHandleFromRoot(handle, paths);
206
- removeMountHandleState(handle, config.rootPath);
207
- } catch (error) {
208
- if (isAbortError(error)) {
209
- throw error;
210
- }
211
- issues.push(
212
- error instanceof Error ? `${handle}: ${error.message}` : `${handle}: teardown failed`
213
- );
214
- }
215
- }
216
- for (const entry of plan.toCreate) {
217
- throwIfAborted(signal);
218
- try {
219
- await ensureMountedHandle(entry, config, paths, signal);
220
- const session = selectPreferredSession(
221
- entry.handle,
222
- (await listOwnedSessions(config, paths, signal)).filter(
223
- (candidate) => candidate.handle === entry.handle
224
- )
225
- );
226
- const inspected = inspectSnapshotEntry(session, entry.mountPath, config.rootPath);
227
- mounts.push(inspected.snapshot);
228
- if (inspected.issue) {
229
- issues.push(inspected.issue);
230
- }
231
- } catch (error) {
232
- if (isAbortError(error)) {
233
- throw error;
234
- }
235
- const message = error instanceof Error ? error.message : "sync start failed";
236
- mounts.push(snapshotEntry(entry.handle, entry.mountPath, "error", message));
237
- issues.push(`${entry.handle}: ${message}`);
238
- }
239
- }
240
- throwIfAborted(signal);
241
- const postCreateSessions = groupSessionsByHandle(
242
- await listOwnedSessions(config, paths, signal)
243
- );
244
- for (const entry of plan.toInspect) {
245
- throwIfAborted(signal);
246
- try {
247
- const sessions = postCreateSessions.get(entry.handle) ?? [];
248
- if (sessions.length === 0) {
249
- await ensureMountedHandle(entry, config, paths, signal);
250
- const createdSession = selectPreferredSession(
251
- entry.handle,
252
- (await listOwnedSessions(config, paths, signal)).filter(
253
- (candidate) => candidate.handle === entry.handle
254
- )
255
- );
256
- const inspected2 = inspectSnapshotEntry(
257
- createdSession,
258
- entry.mountPath,
259
- config.rootPath
260
- );
261
- mounts.push(inspected2.snapshot);
262
- if (inspected2.issue) {
263
- issues.push(inspected2.issue);
264
- }
265
- continue;
266
- }
267
- const session = selectPreferredSession(entry.handle, sessions);
268
- const inspected = inspectSnapshotEntry(session, entry.mountPath, config.rootPath);
269
- mounts.push(inspected.snapshot);
270
- if (inspected.issue) {
271
- issues.push(inspected.issue);
272
- }
273
- } catch (error) {
274
- if (isAbortError(error)) {
275
- throw error;
276
- }
277
- const message = error instanceof Error ? error.message : "sync inspection failed";
278
- mounts.push(snapshotEntry(entry.handle, entry.mountPath, "error", message));
279
- issues.push(`${entry.handle}: ${message}`);
280
- }
281
- }
282
- for (const entry of plan.pending) {
283
- mounts.push(snapshotEntry(entry.handle, entry.mountPath, "pending", entry.message));
284
- }
285
- const now = (/* @__PURE__ */ new Date()).toISOString();
286
- const previousSnapshot = readMountStatusSnapshot(config.rootPath);
287
- const healthy = mounts.every((mount) => mount.state === "mounted" || mount.state === "pending");
288
- const startupInProgress = previousSnapshot?.startupPhase !== "ready" && mounts.some((mount) => mount.state === "syncing" || mount.state === "reconnecting");
289
- const snapshot = {
290
- updatedAt: now,
291
- controllerPid,
292
- running: true,
293
- startupPhase: startupInProgress ? "syncing" : "ready",
294
- startupMessage: startupInProgress ? createInitialSyncMessage(mounts) : void 0,
295
- lastHealthySyncAt: healthy ? now : previousSnapshot?.lastHealthySyncAt,
296
- lastSuccessfulSyncAt: healthy ? now : previousSnapshot?.lastSuccessfulSyncAt,
297
- lastIssueAt: issues.length > 0 ? now : previousSnapshot?.lastIssueAt,
298
- lastIssue: issues[0],
299
- lastError: mounts.some((mount) => mount.state === "error") ? issues[0] : void 0,
300
- mounts: sortSnapshots(mounts)
301
- };
302
- writeMountStatusSnapshot(snapshot, config.rootPath);
303
- return snapshot;
304
- }
305
- async function teardownManagedSessions(config, paths, signal) {
306
- throwIfAborted(signal);
307
- const ownedSessions = await listOwnedSessions(config, paths, signal);
308
- await terminateOwnedSessions(ownedSessions, config, paths, signal);
309
- const handles = new Set(ownedSessions.map((session) => session.handle));
310
- for (const handle of await listKnownHandleStates(paths)) {
311
- handles.add(handle);
312
- }
313
- for (const handle of await listRootHandleDirectories(paths.rootPath)) {
314
- handles.add(handle);
315
- }
316
- for (const handle of handles) {
317
- removeMountHandleState(handle, config.rootPath);
318
- }
319
- await rm(paths.rootPath, { recursive: true, force: true });
320
- }
321
- async function ensureMountedHandle(entry, config, paths, signal) {
322
- await mkdir(entry.mountPath, { recursive: true });
323
- await createHandleSession(entry.handle, config, paths, signal);
324
- writeMountHandleMeta(
325
- entry.handle,
326
- {
327
- handle: entry.handle,
328
- sessionName: getHandleSessionName(entry.handle),
329
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
330
- lastStartedAt: (/* @__PURE__ */ new Date()).toISOString()
331
- },
332
- config.rootPath
333
- );
334
- }
335
- function inspectSnapshotEntry(session, mountPath, rootPath) {
336
- const status = (session.status ?? "").toLowerCase();
337
- const progressDetails = buildSyncProgressDetails(session, rootPath);
338
- const problemParts = [];
339
- if (session.conflictCount > 0) {
340
- problemParts.push(
341
- `${session.conflictCount} conflict${session.conflictCount === 1 ? "" : "s"}`
342
- );
343
- }
344
- if (session.scanProblemCount > 0) {
345
- problemParts.push(
346
- `${session.scanProblemCount} scan problem${session.scanProblemCount === 1 ? "" : "s"}`
347
- );
348
- }
349
- if (problemParts.length > 0) {
350
- const message = problemParts.join(", ");
351
- return {
352
- snapshot: snapshotEntry(session.handle, mountPath, "degraded", message),
353
- issue: `${session.handle}: ${message}`
354
- };
355
- }
356
- if (!session.alphaConnected) {
357
- const message = session.lastError ?? "local sync endpoint disconnected";
358
- return {
359
- snapshot: snapshotEntry(session.handle, mountPath, "error", message, {
360
- status: session.status,
361
- progress: session.stagingProgress,
362
- currentFile: session.currentFile
363
- }),
364
- issue: `${session.handle}: ${message}`
365
- };
366
- }
367
- if (!session.betaConnected || status.includes("connecting") || status.includes("reconnect")) {
368
- const message = session.lastError ?? progressDetails.message ?? "connecting to remote machine";
369
- return {
370
- snapshot: snapshotEntry(session.handle, mountPath, "reconnecting", message, {
371
- status: session.status,
372
- progress: session.stagingProgress,
373
- currentFile: session.currentFile,
374
- etaSeconds: progressDetails.etaSeconds
375
- })
376
- };
377
- }
378
- if (isMutagenSyncInProgress(status)) {
379
- return {
380
- snapshot: snapshotEntry(
381
- session.handle,
382
- mountPath,
383
- "syncing",
384
- progressDetails.message ?? session.status ?? "initial sync in progress",
385
- {
386
- status: session.status,
387
- progress: session.stagingProgress,
388
- currentFile: session.currentFile,
389
- etaSeconds: progressDetails.etaSeconds
390
- }
391
- )
392
- };
393
- }
394
- if (session.lastError && !status.includes("watching")) {
395
- return {
396
- snapshot: snapshotEntry(session.handle, mountPath, "degraded", session.lastError, {
397
- status: session.status,
398
- progress: session.stagingProgress,
399
- currentFile: session.currentFile
400
- }),
401
- issue: `${session.handle}: ${session.lastError}`
402
- };
403
- }
404
- return {
405
- snapshot: snapshotEntry(session.handle, mountPath, "mounted", void 0, {
406
- status: session.status
407
- })
408
- };
409
- }
410
- function snapshotEntry(handle, mountPath, state, message, extra = {}) {
411
- return {
412
- handle,
413
- mountPath,
414
- state,
415
- message,
416
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
417
- ...extra
418
- };
419
- }
420
- function isMutagenSyncInProgress(status) {
421
- return [
422
- "staging",
423
- "scanning",
424
- "reconciling",
425
- "applying",
426
- "saving",
427
- "waiting"
428
- ].some((phase) => status.includes(phase));
429
- }
430
- function buildSyncProgressDetails(session, rootPath) {
431
- const details = [];
432
- if (session.status) {
433
- details.push(session.status);
434
- }
435
- if (session.stagingProgress) {
436
- details.push(session.stagingProgress);
437
- }
438
- if (session.currentFile) {
439
- details.push(session.currentFile);
440
- }
441
- const startedAt = readMountHandleMeta(session.handle, rootPath)?.lastStartedAt;
442
- const etaSeconds = estimateEtaSeconds(startedAt, session.stagingProgress);
443
- if (etaSeconds !== void 0) {
444
- details.push(`eta ~${formatSeconds(etaSeconds)}`);
445
- }
446
- return {
447
- message: details.length > 0 ? details.join("; ") : void 0,
448
- etaSeconds
449
- };
450
- }
451
- function estimateEtaSeconds(startedAt, stagingProgress) {
452
- if (!startedAt || !stagingProgress) {
453
- return void 0;
454
- }
455
- const match = stagingProgress.match(/(\d+)%/);
456
- if (!match) {
457
- return void 0;
458
- }
459
- const percent = Number.parseInt(match[1] ?? "", 10);
460
- if (!Number.isFinite(percent) || percent <= 0 || percent >= 100) {
461
- return void 0;
462
- }
463
- const elapsedSeconds = Math.max(
464
- 0,
465
- (Date.now() - new Date(startedAt).getTime()) / 1e3
466
- );
467
- if (!Number.isFinite(elapsedSeconds) || elapsedSeconds < 1) {
468
- return void 0;
469
- }
470
- return Math.max(1, Math.round(elapsedSeconds * (100 - percent) / percent));
471
- }
472
- function formatSeconds(seconds) {
473
- if (seconds < 60) {
474
- return `${seconds}s`;
475
- }
476
- const minutes = Math.floor(seconds / 60);
477
- const remainder = seconds % 60;
478
- return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
479
- }
480
- function createInitialSyncMessage(mounts) {
481
- const actionable = mounts.filter((mount) => mount.state !== "pending");
482
- const readyCount = actionable.filter((mount) => mount.state === "mounted").length;
483
- const active = actionable.find((mount) => mount.state === "syncing") ?? actionable.find((mount) => mount.state === "reconnecting");
484
- if (!active) {
485
- return "Waiting for initial sync...";
486
- }
487
- const progressPrefix = actionable.length > 0 ? `${readyCount}/${actionable.length} ready; ` : "";
488
- return `Waiting for initial sync... ${progressPrefix}${active.handle}: ${active.message ?? active.state}`;
489
- }
490
- function sortSnapshots(mounts) {
491
- return mounts.slice().sort((left, right) => left.handle.localeCompare(right.handle));
492
- }
493
- function groupSessionsByHandle(sessions) {
494
- const grouped = /* @__PURE__ */ new Map();
495
- for (const session of sessions) {
496
- const group = grouped.get(session.handle);
497
- if (group) {
498
- group.push(session);
499
- } else {
500
- grouped.set(session.handle, [session]);
501
- }
502
- }
503
- return grouped;
504
- }
505
- function terminateOwnedSessions(sessions, config, paths, signal) {
506
- return Promise.all(
507
- sessions.map((session) => terminateSession(session, config, paths, signal))
508
- ).then(() => void 0);
509
- }
510
- async function removeHandleFromRoot(handle, paths) {
511
- await rm(join(paths.rootPath, handle), { recursive: true, force: true });
512
- }
513
- async function listRootHandleDirectories(rootPath) {
514
- try {
515
- return (await readdir(rootPath, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
516
- } catch {
517
- return [];
518
- }
519
- }
520
- async function listKnownHandleStates(paths) {
521
- try {
522
- return (await readdir(paths.handlesDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
523
- } catch {
524
- return [];
525
- }
526
- }
527
- function throwIfAborted(signal) {
528
- if (signal?.aborted) {
529
- const error = new Error("mount operation cancelled");
530
- error.name = "AbortError";
531
- throw error;
532
- }
533
- }
534
-
535
- export {
536
- padEnd,
537
- timeAgo,
538
- formatStatus,
539
- promptForSSHComputer,
540
- computeMountPlan,
541
- planMountReconcile,
542
- reconcileMounts,
543
- teardownManagedSessions
544
- };
@@ -1,62 +0,0 @@
1
- // src/lib/mount-host.ts
2
- import { spawnSync } from "child_process";
3
- function getMountHostValidationIssues() {
4
- return evaluateMountHostValidation({
5
- platform: process.platform,
6
- hasSsh: hasCommand("ssh"),
7
- hasScp: hasCommand("scp")
8
- });
9
- }
10
- function formatMountHostInstallGuidance(issues) {
11
- const lines = [];
12
- for (const issue of issues) {
13
- switch (issue.code) {
14
- case "unsupported-platform":
15
- lines.push(
16
- "Supported today: macOS and Linux terminals with bundled Mutagen plus OpenSSH client tools available."
17
- );
18
- break;
19
- case "missing-ssh":
20
- case "missing-scp":
21
- lines.push("Install OpenSSH client tools so both `ssh` and `scp` are available on PATH.");
22
- break;
23
- default:
24
- lines.push(issue.message);
25
- break;
26
- }
27
- }
28
- return lines;
29
- }
30
- function evaluateMountHostValidation(input) {
31
- const issues = [];
32
- if (!["darwin", "linux"].includes(input.platform)) {
33
- issues.push({
34
- code: "unsupported-platform",
35
- message: "computer mount currently supports macOS and Linux terminals only"
36
- });
37
- return issues;
38
- }
39
- if (!input.hasSsh) {
40
- issues.push({
41
- code: "missing-ssh",
42
- message: "`ssh` is not installed or not available on PATH."
43
- });
44
- }
45
- if (!input.hasScp) {
46
- issues.push({
47
- code: "missing-scp",
48
- message: "`scp` is not installed or not available on PATH."
49
- });
50
- }
51
- return issues;
52
- }
53
- function hasCommand(command) {
54
- const result = spawnSync("which", [command], { stdio: "ignore" });
55
- return result.status === 0;
56
- }
57
-
58
- export {
59
- getMountHostValidationIssues,
60
- formatMountHostInstallGuidance,
61
- evaluateMountHostValidation
62
- };