@instafy/cli 0.1.7 → 0.1.8-staging.348

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/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import kleur from "kleur";
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import os from "node:os";
8
8
  import { ensureRatholeBinary } from "./rathole.js";
9
+ import { resolveConfiguredAccessToken } from "./config.js";
9
10
  const INSTAFY_DIR = path.join(os.homedir(), ".instafy");
10
11
  const STATE_FILE = path.join(INSTAFY_DIR, "cli-runtime-state.json");
11
12
  const LOG_DIR = path.join(INSTAFY_DIR, "cli-runtime-logs");
@@ -27,7 +28,82 @@ function resolveRuntimeBinary() {
27
28
  return candidate;
28
29
  }
29
30
  }
30
- throw new Error("runtime-agent binary not found. Build it with `cargo build -p runtime-agent`.");
31
+ throw new Error("runtime-agent binary not found. If you're in the instafy repo, run `cargo build -p runtime-agent`. Otherwise install Docker and rerun `instafy runtime:start`.");
32
+ }
33
+ function tryResolveRuntimeBinary() {
34
+ try {
35
+ return resolveRuntimeBinary();
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function isDockerAvailable() {
42
+ try {
43
+ const result = spawnSync("docker", ["version"], { stdio: "ignore" });
44
+ return result.status === 0;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ function normalizeControllerUrlForDocker(controllerUrl) {
51
+ const trimmed = controllerUrl.trim().replace(/\/$/, "");
52
+ if (!trimmed) {
53
+ return trimmed;
54
+ }
55
+ try {
56
+ const url = new URL(trimmed);
57
+ const host = url.hostname.toLowerCase();
58
+ if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "0.0.0.0") {
59
+ url.hostname = "host.docker.internal";
60
+ }
61
+ return url.toString().replace(/\/$/, "");
62
+ }
63
+ catch {
64
+ return trimmed
65
+ .replace("127.0.0.1", "host.docker.internal")
66
+ .replace("localhost", "host.docker.internal")
67
+ .replace("0.0.0.0", "host.docker.internal")
68
+ .replace(/\/$/, "");
69
+ }
70
+ }
71
+ function resolveRuntimeAgentImage() {
72
+ const fromEnv = normalizeToken(process.env["INSTAFY_RUNTIME_AGENT_IMAGE"]) ??
73
+ normalizeToken(process.env["RUNTIME_AGENT_IMAGE"]) ??
74
+ null;
75
+ return fromEnv ?? "ghcr.io/instafy-dev/instafy-runtime-agent:latest";
76
+ }
77
+ function dockerContainerRunning(containerId) {
78
+ const id = containerId.trim();
79
+ if (!id) {
80
+ return false;
81
+ }
82
+ try {
83
+ const result = spawnSync("docker", ["inspect", "-f", "{{.State.Running}}", id], {
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "ignore"],
86
+ });
87
+ if (result.status !== 0) {
88
+ return false;
89
+ }
90
+ return String(result.stdout ?? "").trim() === "true";
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function stopDockerContainer(containerId) {
97
+ const id = containerId.trim();
98
+ if (!id) {
99
+ return;
100
+ }
101
+ try {
102
+ spawnSync("docker", ["rm", "-f", id], { stdio: "ignore" });
103
+ }
104
+ catch {
105
+ // ignore
106
+ }
31
107
  }
32
108
  function printStatus(label, value) {
33
109
  console.log(`${kleur.cyan(label)} ${value}`);
@@ -131,6 +207,14 @@ function findRatholeOnPath() {
131
207
  }
132
208
  return null;
133
209
  }
210
+ function resolveControllerAccessTokenForCli(options, env, supabaseAccessToken) {
211
+ return (normalizeToken(options.controllerAccessToken) ??
212
+ normalizeToken(env["INSTAFY_ACCESS_TOKEN"]) ??
213
+ normalizeToken(env["CONTROLLER_ACCESS_TOKEN"]) ??
214
+ supabaseAccessToken ??
215
+ normalizeToken(env["SUPABASE_ACCESS_TOKEN"]) ??
216
+ resolveConfiguredAccessToken());
217
+ }
134
218
  export async function resolveRatholeBinaryForCli(options) {
135
219
  const warn = options.warn ??
136
220
  ((message) => {
@@ -212,33 +296,34 @@ function isProcessAlive(pid) {
212
296
  }
213
297
  }
214
298
  export async function runtimeStart(options) {
215
- const bin = resolveRuntimeBinary();
216
299
  const env = { ...process.env };
217
300
  const existing = readState();
218
- if (existing && isProcessAlive(existing.pid)) {
219
- throw new Error(`Runtime already running (pid ${existing.pid}) for project ${existing.projectId}. Stop it first.`);
301
+ if (existing) {
302
+ if (existing.runner === "docker" && existing.containerId && dockerContainerRunning(existing.containerId)) {
303
+ throw new Error(`Runtime already running (docker container ${existing.containerId}) for project ${existing.projectId}. Stop it first.`);
304
+ }
305
+ if ((!existing.runner || existing.runner === "process") && isProcessAlive(existing.pid)) {
306
+ throw new Error(`Runtime already running (pid ${existing.pid}) for project ${existing.projectId}. Stop it first.`);
307
+ }
220
308
  }
221
- const manifestInfo = findProjectManifest(options.workspace ?? process.cwd());
309
+ const manifestInfo = findProjectManifest(process.cwd());
222
310
  const projectId = options.project ?? env["PROJECT_ID"] ?? manifestInfo.manifest?.projectId ?? null;
223
311
  if (!projectId) {
224
- throw new Error("Project ID is required (--project or PROJECT_ID)");
312
+ throw new Error("No project configured. Run `instafy project:init` in this folder (recommended) or pass --project.");
225
313
  }
226
314
  env["PROJECT_ID"] = projectId;
227
315
  const supabaseAccessToken = resolveSupabaseAccessToken(options, env);
228
316
  if (supabaseAccessToken) {
229
317
  env["SUPABASE_ACCESS_TOKEN"] = supabaseAccessToken;
230
318
  }
231
- let controllerAccessToken = normalizeToken(options.controllerAccessToken) ??
232
- normalizeToken(env["INSTAFY_ACCESS_TOKEN"]) ??
233
- normalizeToken(env["CONTROLLER_ACCESS_TOKEN"]) ??
234
- supabaseAccessToken;
319
+ let controllerAccessToken = resolveControllerAccessTokenForCli(options, env, supabaseAccessToken);
235
320
  let runtimeAccessToken = normalizeToken(options.runtimeToken) ?? normalizeToken(env["RUNTIME_ACCESS_TOKEN"]);
236
321
  const agentKey = options.controllerToken ??
237
322
  env["INSTAFY_SERVICE_TOKEN"] ??
238
323
  env["AGENT_LOGIN_KEY"] ??
239
324
  env["AGENT_KEY"];
240
325
  if (!agentKey && !controllerAccessToken && !runtimeAccessToken) {
241
- throw new Error("Provide either --runtime-token/RUNTIME_ACCESS_TOKEN, --access-token/INSTAFY_ACCESS_TOKEN, --supabase-access-token/SUPABASE_ACCESS_TOKEN, or --service-token/INSTAFY_SERVICE_TOKEN (advanced).");
326
+ throw new Error("Login required. Run `instafy login`, or pass --access-token / --supabase-access-token, or provide --runtime-token.");
242
327
  }
243
328
  if (agentKey) {
244
329
  env["AGENT_LOGIN_KEY"] = agentKey;
@@ -250,6 +335,7 @@ export async function runtimeStart(options) {
250
335
  options.controllerUrl ??
251
336
  env["INSTAFY_SERVER_URL"] ??
252
337
  env["CONTROLLER_BASE_URL"] ??
338
+ manifestInfo.manifest?.controllerUrl ??
253
339
  "http://127.0.0.1:8788";
254
340
  if (!runtimeAccessToken && controllerAccessToken) {
255
341
  runtimeAccessToken = await mintRuntimeAccessToken({
@@ -320,47 +406,123 @@ export async function runtimeStart(options) {
320
406
  if (options.runtimeLeaseId && options.runtimeLeaseId.trim()) {
321
407
  env["RUNTIME_LEASE_ID"] = options.runtimeLeaseId.trim();
322
408
  }
323
- if (!env["ORIGIN_ENDPOINT"]) {
324
- const ratholeResolved = await resolveRatholeBinaryForCli({
325
- env,
326
- version: process.env["RATHOLE_VERSION"] ?? null,
327
- cacheDir: process.env["RATHOLE_CACHE_DIR"] ?? null,
328
- logger: (message) => console.log(kleur.cyan(`[rathole] ${message}`)),
329
- warn: (message) => console.warn(kleur.yellow(message)),
330
- });
331
- if (!ratholeResolved) {
332
- throw new Error("Tunnel is required but rathole is unavailable. Set RATHOLE_BIN (recommended) or pass --origin-endpoint to use a reachable URL.");
409
+ const runtimeMode = (options.runtimeMode ?? "auto").toLowerCase();
410
+ const runtimeBin = runtimeMode === "docker" ? null : tryResolveRuntimeBinary();
411
+ const useDocker = runtimeMode === "docker" ||
412
+ (runtimeMode === "auto" && !runtimeBin);
413
+ if (!useDocker) {
414
+ if (!env["ORIGIN_ENDPOINT"]) {
415
+ const ratholeResolved = await resolveRatholeBinaryForCli({
416
+ env,
417
+ version: process.env["RATHOLE_VERSION"] ?? null,
418
+ cacheDir: process.env["RATHOLE_CACHE_DIR"] ?? null,
419
+ logger: (message) => console.log(kleur.cyan(`[rathole] ${message}`)),
420
+ warn: (message) => console.warn(kleur.yellow(message)),
421
+ });
422
+ if (!ratholeResolved) {
423
+ throw new Error("Tunnel is required but rathole is unavailable. Set RATHOLE_BIN (recommended) or pass --origin-endpoint to use a reachable URL.");
424
+ }
425
+ }
426
+ const bin = runtimeBin ?? resolveRuntimeBinary();
427
+ printStatus("Starting runtime-agent:", bin);
428
+ printStatus("Project:", projectId);
429
+ printStatus("Workspace:", workspace);
430
+ printStatus("Origin:", originId);
431
+ const detached = Boolean(options.detach);
432
+ const logFile = options.logFile?.trim()
433
+ ? path.resolve(options.logFile)
434
+ : detached
435
+ ? path.join(LOG_DIR, `runtime-${Date.now()}.log`)
436
+ : undefined;
437
+ if (logFile) {
438
+ ensureLogDir();
439
+ }
440
+ let stdio;
441
+ if (!logFile && !detached) {
442
+ stdio = "inherit";
333
443
  }
444
+ else {
445
+ const out = logFile ? openSync(logFile, "a") : "ignore";
446
+ stdio = ["ignore", out, out];
447
+ }
448
+ const spawnOptions = { env, stdio, detached };
449
+ const child = spawn(bin, spawnOptions);
450
+ if (detached) {
451
+ child.unref();
452
+ }
453
+ const startedAt = new Date().toISOString();
454
+ writeState({
455
+ runner: "process",
456
+ pid: child.pid ?? -1,
457
+ projectId,
458
+ workspace,
459
+ controllerUrl: env["CONTROLLER_BASE_URL"],
460
+ originId,
461
+ runtimeId: env["RUNTIME_ID"] ?? null,
462
+ displayName: env["RUNTIME_DISPLAY_NAME"],
463
+ startedAt,
464
+ logFile,
465
+ detached,
466
+ });
467
+ child.on("exit", (code, signal) => {
468
+ const msg = code !== null ? `code ${code}` : `signal ${signal}`;
469
+ console.log(kleur.yellow(`runtime-agent exited (${msg})`));
470
+ const current = readState();
471
+ if (current && current.pid === child.pid) {
472
+ clearState();
473
+ }
474
+ });
475
+ return;
334
476
  }
335
- printStatus("Starting runtime-agent:", bin);
477
+ if (!isDockerAvailable()) {
478
+ throw new Error("runtime-agent is not available (no local binary and docker is not installed). Install Docker Desktop, or build runtime-agent from source.");
479
+ }
480
+ const image = resolveRuntimeAgentImage();
481
+ const containerName = `instafy-runtime-${originId}`;
482
+ const hostPort = String(options.bindPort ?? env["ORIGIN_BIND_PORT"] ?? 54332);
483
+ const hostBindHost = options.bindHost ?? "127.0.0.1";
484
+ const dockerEnv = { ...env };
485
+ dockerEnv["WORKSPACE_DIR"] = "/workspace";
486
+ dockerEnv["CODEX_HOME"] = "/workspace/.codex";
487
+ dockerEnv["ORIGIN_BIND_HOST"] = "0.0.0.0";
488
+ dockerEnv["CONTROLLER_BASE_URL"] = normalizeControllerUrlForDocker(env["CONTROLLER_BASE_URL"] ?? "");
489
+ const envArgs = [];
490
+ for (const [key, value] of Object.entries(dockerEnv)) {
491
+ if (typeof value !== "string" || value.length === 0)
492
+ continue;
493
+ envArgs.push("-e", `${key}=${value}`);
494
+ }
495
+ const runArgs = [
496
+ "run",
497
+ "--detach",
498
+ "--name",
499
+ containerName,
500
+ "--add-host",
501
+ "host.docker.internal:host-gateway",
502
+ "-p",
503
+ `${hostBindHost}:${hostPort}:${hostPort}`,
504
+ "-v",
505
+ `${workspace}:/workspace`,
506
+ ...envArgs,
507
+ image,
508
+ ];
509
+ printStatus("Starting runtime-agent (docker):", image);
336
510
  printStatus("Project:", projectId);
337
511
  printStatus("Workspace:", workspace);
338
512
  printStatus("Origin:", originId);
339
- const detached = Boolean(options.detach);
340
- const logFile = options.logFile?.trim()
341
- ? path.resolve(options.logFile)
342
- : detached
343
- ? path.join(LOG_DIR, `runtime-${Date.now()}.log`)
344
- : undefined;
345
- if (logFile) {
346
- ensureLogDir();
347
- }
348
- let stdio;
349
- if (!logFile && !detached) {
350
- stdio = "inherit";
513
+ const started = spawnSync("docker", runArgs, { encoding: "utf8" });
514
+ if (started.status !== 0) {
515
+ const stderr = String(started.stderr ?? "").trim();
516
+ throw new Error(`docker run failed: ${stderr || "unknown error"}`);
351
517
  }
352
- else {
353
- const out = logFile ? openSync(logFile, "a") : "ignore";
354
- stdio = ["ignore", out, out];
355
- }
356
- const spawnOptions = { env, stdio, detached };
357
- const child = spawn(bin, spawnOptions);
358
- if (detached) {
359
- child.unref();
518
+ const containerId = String(started.stdout ?? "").trim();
519
+ if (!containerId) {
520
+ throw new Error("docker run did not return a container id");
360
521
  }
361
522
  const startedAt = new Date().toISOString();
362
523
  writeState({
363
- pid: child.pid ?? -1,
524
+ runner: "docker",
525
+ pid: -1,
364
526
  projectId,
365
527
  workspace,
366
528
  controllerUrl: env["CONTROLLER_BASE_URL"],
@@ -368,22 +530,40 @@ export async function runtimeStart(options) {
368
530
  runtimeId: env["RUNTIME_ID"] ?? null,
369
531
  displayName: env["RUNTIME_DISPLAY_NAME"],
370
532
  startedAt,
371
- logFile,
372
- detached,
533
+ detached: Boolean(options.detach),
534
+ containerId,
535
+ containerName,
373
536
  });
374
- child.on("exit", (code, signal) => {
375
- const msg = code !== null ? `code ${code}` : `signal ${signal}`;
376
- console.log(kleur.yellow(`runtime-agent exited (${msg})`));
537
+ if (options.detach) {
538
+ printStatus("Container:", containerId);
539
+ console.log(kleur.gray(`Use: docker logs -f ${containerId}`));
540
+ return;
541
+ }
542
+ const logs = spawn("docker", ["logs", "-f", containerId], { stdio: "inherit" });
543
+ const handleExit = async () => {
544
+ stopDockerContainer(containerId);
377
545
  const current = readState();
378
- if (current && current.pid === child.pid) {
546
+ if (current?.containerId === containerId) {
379
547
  clearState();
380
548
  }
549
+ };
550
+ process.once("SIGINT", () => {
551
+ void handleExit();
552
+ });
553
+ process.once("SIGTERM", () => {
554
+ void handleExit();
555
+ });
556
+ logs.on("exit", () => {
557
+ void handleExit();
381
558
  });
382
559
  }
383
560
  function formatStatus(state, running) {
384
561
  return {
385
562
  running,
563
+ runner: state.runner ?? "process",
386
564
  pid: state.pid,
565
+ containerId: state.containerId ?? null,
566
+ containerName: state.containerName ?? null,
387
567
  projectId: state.projectId,
388
568
  workspace: state.workspace,
389
569
  controllerUrl: state.controllerUrl ?? null,
@@ -404,7 +584,8 @@ async function sendOfflineBeat(state) {
404
584
  let bearer = directToken;
405
585
  if (!bearer) {
406
586
  const controllerAccessToken = normalizeToken(process.env["CONTROLLER_ACCESS_TOKEN"]) ??
407
- normalizeToken(process.env["SUPABASE_ACCESS_TOKEN"]);
587
+ normalizeToken(process.env["SUPABASE_ACCESS_TOKEN"]) ??
588
+ resolveConfiguredAccessToken();
408
589
  if (controllerAccessToken) {
409
590
  try {
410
591
  bearer = await mintRuntimeAccessToken({
@@ -465,14 +646,25 @@ export async function runtimeStatus(options) {
465
646
  }
466
647
  return;
467
648
  }
468
- const alive = isProcessAlive(state.pid);
649
+ const alive = state.runner === "docker"
650
+ ? Boolean(state.containerId && dockerContainerRunning(state.containerId))
651
+ : isProcessAlive(state.pid);
469
652
  const payload = formatStatus(state, alive);
470
653
  if (options?.json) {
471
654
  console.log(JSON.stringify(payload));
472
655
  return;
473
656
  }
474
657
  console.log(alive ? kleur.green("Runtime is running") : kleur.red("Runtime is not running"));
475
- printStatus("PID:", String(state.pid));
658
+ printStatus("Runner:", state.runner ?? "process");
659
+ if (state.runner === "docker") {
660
+ if (state.containerId)
661
+ printStatus("Container:", state.containerId);
662
+ if (state.containerName)
663
+ printStatus("Name:", state.containerName);
664
+ }
665
+ else {
666
+ printStatus("PID:", String(state.pid));
667
+ }
476
668
  printStatus("Project:", state.projectId);
477
669
  printStatus("Workspace:", state.workspace);
478
670
  if (state.controllerUrl)
@@ -482,13 +674,13 @@ export async function runtimeStatus(options) {
482
674
  if (state.runtimeId)
483
675
  printStatus("Runtime:", state.runtimeId);
484
676
  if (state.displayName)
485
- printStatus("Name:", state.displayName);
677
+ printStatus("Display:", state.displayName);
486
678
  printStatus("Started:", state.startedAt);
487
679
  if (state.logFile)
488
680
  printStatus("Log:", state.logFile);
489
681
  printStatus("Detached:", state.detached ? "yes" : "no");
490
682
  if (!alive) {
491
- console.log(kleur.yellow("State file exists but process is not alive."));
683
+ console.log(kleur.yellow(`State file exists but ${state.runner === "docker" ? "container" : "process"} is not alive.`));
492
684
  }
493
685
  }
494
686
  export async function runtimeStop(options) {
@@ -502,6 +694,22 @@ export async function runtimeStop(options) {
502
694
  }
503
695
  return;
504
696
  }
697
+ if (state.runner === "docker") {
698
+ const running = Boolean(state.containerId && dockerContainerRunning(state.containerId));
699
+ if (state.containerId) {
700
+ stopDockerContainer(state.containerId);
701
+ }
702
+ clearState();
703
+ await sendOfflineBeat(state);
704
+ const result = { stopped: running, containerId: state.containerId ?? null };
705
+ if (options?.json) {
706
+ console.log(JSON.stringify(result));
707
+ }
708
+ else {
709
+ console.log(running ? kleur.green("Runtime stopped.") : kleur.yellow("Runtime not running."));
710
+ }
711
+ return;
712
+ }
505
713
  if (!isProcessAlive(state.pid)) {
506
714
  clearState();
507
715
  if (options?.json) {
@@ -552,9 +760,10 @@ export async function runtimeToken(options) {
552
760
  const token = options.controllerAccessToken ??
553
761
  process.env["INSTAFY_ACCESS_TOKEN"] ??
554
762
  process.env["CONTROLLER_ACCESS_TOKEN"] ??
555
- process.env["SUPABASE_ACCESS_TOKEN"];
763
+ process.env["SUPABASE_ACCESS_TOKEN"] ??
764
+ resolveConfiguredAccessToken();
556
765
  if (!token) {
557
- throw new Error("Access token is required (--access-token, INSTAFY_ACCESS_TOKEN, or SUPABASE_ACCESS_TOKEN).");
766
+ throw new Error("Login required. Run `instafy login` or pass --access-token / set SUPABASE_ACCESS_TOKEN.");
558
767
  }
559
768
  const minted = await mintRuntimeAccessToken({
560
769
  controllerUrl,