@instafy/cli 0.1.8 → 0.1.9

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/tunnel.js CHANGED
@@ -2,9 +2,15 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
5
6
  import kleur from "kleur";
6
7
  import { findProjectManifest, resolveRatholeBinaryForCli } from "./runtime.js";
7
- import { resolveConfiguredControllerUrl, resolveUserAccessToken } from "./config.js";
8
+ import { resolveConfiguredControllerUrl, resolveControllerUrl as resolveDefaultControllerUrl, resolveUserAccessToken, } from "./config.js";
9
+ import { formatAuthRejectedError, formatAuthRequiredError } from "./errors.js";
10
+ const INSTAFY_DIR = path.join(os.homedir(), ".instafy");
11
+ const TUNNEL_STATE_FILE = path.join(INSTAFY_DIR, "cli-tunnel-state.json");
12
+ const TUNNEL_LOG_DIR = path.join(INSTAFY_DIR, "cli-tunnel-logs");
13
+ const TUNNEL_WORKDIR_DIR = path.join(INSTAFY_DIR, "cli-tunnel-workdirs");
8
14
  function cleanUrl(raw) {
9
15
  return raw.replace(/\/+$/, "");
10
16
  }
@@ -26,9 +32,9 @@ function resolveProject(opts) {
26
32
  if (manifest?.projectId) {
27
33
  return manifest.projectId;
28
34
  }
29
- throw new Error("No project configured. Run `instafy project:init` or pass --project.");
35
+ throw new Error("No project configured for this folder.\n\nNext:\n- instafy project init\n\nOr pass --project <uuid> / set PROJECT_ID.");
30
36
  }
31
- function resolveControllerUrl(opts) {
37
+ function resolveTunnelControllerUrl(opts) {
32
38
  const explicit = opts.controllerUrl?.trim();
33
39
  if (explicit) {
34
40
  return explicit;
@@ -45,9 +51,9 @@ function resolveControllerUrl(opts) {
45
51
  if (manifestControllerUrl) {
46
52
  return manifestControllerUrl;
47
53
  }
48
- return resolveConfiguredControllerUrl() ?? "http://127.0.0.1:8788";
54
+ return resolveConfiguredControllerUrl() ?? resolveDefaultControllerUrl();
49
55
  }
50
- function resolveControllerToken(opts) {
56
+ function resolveControllerToken(opts, retryCommand = "instafy tunnel start") {
51
57
  const explicit = opts.controllerToken?.trim();
52
58
  if (explicit) {
53
59
  return explicit;
@@ -67,7 +73,10 @@ function resolveControllerToken(opts) {
67
73
  if (serviceToken) {
68
74
  return serviceToken;
69
75
  }
70
- throw new Error("Login required. Run `instafy login`, pass --access-token, or provide --service-token / INSTAFY_SERVICE_TOKEN.");
76
+ throw formatAuthRequiredError({
77
+ retryCommand,
78
+ advancedHint: "pass --access-token / --service-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN / INSTAFY_SERVICE_TOKEN",
79
+ });
71
80
  }
72
81
  function resolvePort(opts) {
73
82
  const fromEnv = readEnv("WEBHOOK_LOCAL_PORT") ||
@@ -76,6 +85,77 @@ function resolvePort(opts) {
76
85
  const parsedEnv = fromEnv ? Number(fromEnv) : NaN;
77
86
  return opts.port ?? (Number.isFinite(parsedEnv) ? parsedEnv : 3000);
78
87
  }
88
+ function ensureDir(dir) {
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ }
91
+ function safePathSegment(value) {
92
+ const trimmed = value.trim();
93
+ if (!trimmed)
94
+ return randomUUID();
95
+ return trimmed.replace(/[^a-zA-Z0-9._-]/g, "_");
96
+ }
97
+ function writeStateFile(state) {
98
+ ensureDir(INSTAFY_DIR);
99
+ fs.writeFileSync(TUNNEL_STATE_FILE, JSON.stringify(state, null, 2), "utf8");
100
+ try {
101
+ fs.chmodSync(TUNNEL_STATE_FILE, 0o600);
102
+ }
103
+ catch {
104
+ // ignore chmod failures (windows / unusual fs)
105
+ }
106
+ }
107
+ function readStateFile() {
108
+ try {
109
+ const raw = fs.readFileSync(TUNNEL_STATE_FILE, "utf8");
110
+ const parsed = JSON.parse(raw);
111
+ if (!parsed || typeof parsed !== "object")
112
+ return { version: 1, tunnels: [] };
113
+ if (!Array.isArray(parsed.tunnels))
114
+ return { version: 1, tunnels: [] };
115
+ return {
116
+ version: 1,
117
+ tunnels: parsed.tunnels.filter(Boolean),
118
+ };
119
+ }
120
+ catch {
121
+ return { version: 1, tunnels: [] };
122
+ }
123
+ }
124
+ function isProcessAlive(pid) {
125
+ if (!Number.isFinite(pid) || pid <= 0)
126
+ return false;
127
+ try {
128
+ process.kill(pid, 0);
129
+ return true;
130
+ }
131
+ catch (error) {
132
+ const code = error instanceof Error ? error.code : null;
133
+ // EPERM means the process exists but we don't have permission to signal it.
134
+ return code === "EPERM";
135
+ }
136
+ }
137
+ async function waitForProcessExit(pid, timeoutMs) {
138
+ const deadline = Date.now() + timeoutMs;
139
+ while (Date.now() < deadline) {
140
+ if (!isProcessAlive(pid))
141
+ return true;
142
+ await new Promise((resolve) => setTimeout(resolve, 100));
143
+ }
144
+ return !isProcessAlive(pid);
145
+ }
146
+ function upsertTunnelState(entry) {
147
+ const state = readStateFile();
148
+ const next = state.tunnels.filter((tunnel) => tunnel.tunnelId !== entry.tunnelId);
149
+ next.unshift(entry);
150
+ writeStateFile({ version: 1, tunnels: next });
151
+ }
152
+ function removeTunnelState(tunnelId) {
153
+ const state = readStateFile();
154
+ const existing = state.tunnels.find((tunnel) => tunnel.tunnelId === tunnelId) ?? null;
155
+ const next = state.tunnels.filter((tunnel) => tunnel.tunnelId !== tunnelId);
156
+ writeStateFile({ version: 1, tunnels: next });
157
+ return existing;
158
+ }
79
159
  async function requestTunnel(controllerUrl, token, projectId, metadata) {
80
160
  const target = `${cleanUrl(controllerUrl)}/projects/${encodeURIComponent(projectId)}/tunnels/request`;
81
161
  const response = await fetch(target, {
@@ -89,6 +169,13 @@ async function requestTunnel(controllerUrl, token, projectId, metadata) {
89
169
  });
90
170
  if (!response.ok) {
91
171
  const text = await response.text().catch(() => "");
172
+ if (response.status === 401 || response.status === 403) {
173
+ throw formatAuthRejectedError({
174
+ status: response.status,
175
+ responseBody: text,
176
+ retryCommand: "instafy tunnel start",
177
+ });
178
+ }
92
179
  throw new Error(`Tunnel request failed (${response.status} ${response.statusText}): ${text}`);
93
180
  }
94
181
  const body = (await response.json());
@@ -140,7 +227,7 @@ local_addr = "127.0.0.1:${port}"
140
227
  }
141
228
  export async function startTunnelSession(opts) {
142
229
  const projectId = resolveProject(opts);
143
- const controllerUrl = resolveControllerUrl(opts);
230
+ const controllerUrl = resolveTunnelControllerUrl(opts);
144
231
  const controllerToken = resolveControllerToken(opts);
145
232
  const port = resolvePort(opts);
146
233
  let cleanedUp = false;
@@ -210,3 +297,181 @@ export async function runTunnelCommand(opts, options) {
210
297
  process.once("SIGTERM", handle);
211
298
  });
212
299
  }
300
+ export async function startTunnelDetached(opts) {
301
+ const projectId = resolveProject(opts);
302
+ const controllerUrl = resolveTunnelControllerUrl(opts);
303
+ const controllerToken = resolveControllerToken(opts);
304
+ const port = resolvePort(opts);
305
+ if (opts.ratholeBin) {
306
+ process.env.RATHOLE_BIN = opts.ratholeBin;
307
+ }
308
+ const rathole = await resolveRatholeBinaryForCli({
309
+ env: process.env,
310
+ version: process.env.RATHOLE_VERSION ?? null,
311
+ cacheDir: process.env.RATHOLE_CACHE_DIR ?? null,
312
+ logger: (message) => console.log(kleur.cyan(`[rathole] ${message}`)),
313
+ warn: (message) => console.warn(kleur.yellow(message)),
314
+ });
315
+ if (!rathole) {
316
+ throw new Error("rathole is required to start a tunnel. Set RATHOLE_BIN or ensure it is on PATH.");
317
+ }
318
+ const metadata = { localPort: port, source: "instafy-cli" };
319
+ const grant = await requestTunnel(controllerUrl, controllerToken, projectId, metadata);
320
+ ensureDir(TUNNEL_WORKDIR_DIR);
321
+ ensureDir(TUNNEL_LOG_DIR);
322
+ const tunnelIdSafe = safePathSegment(grant.tunnelId);
323
+ const workdir = path.join(TUNNEL_WORKDIR_DIR, tunnelIdSafe);
324
+ ensureDir(workdir);
325
+ const creds = grant.credentials && typeof grant.credentials === "object"
326
+ ? grant.credentials
327
+ : {};
328
+ const configBody = buildRatholeConfig(creds, port);
329
+ const configPath = path.join(workdir, "rathole.toml");
330
+ fs.writeFileSync(configPath, configBody, { encoding: "utf8", mode: 0o600 });
331
+ const logFile = opts.logFile?.trim()
332
+ ? path.resolve(opts.logFile.trim())
333
+ : path.join(TUNNEL_LOG_DIR, `tunnel-${tunnelIdSafe}.log`);
334
+ const logFd = fs.openSync(logFile, "a");
335
+ const child = spawn(rathole, ["-c", configPath], {
336
+ stdio: ["ignore", logFd, logFd],
337
+ cwd: workdir,
338
+ detached: true,
339
+ });
340
+ let exitedEarly = false;
341
+ let exitMessage = "";
342
+ child.on("exit", (code, signal) => {
343
+ exitedEarly = true;
344
+ exitMessage = code !== null ? `code ${code}` : `signal ${signal}`;
345
+ });
346
+ // Give rathole a moment to fail fast, so `instafy tunnel start` can report errors.
347
+ await new Promise((resolve) => setTimeout(resolve, 250));
348
+ if (exitedEarly || !isProcessAlive(child.pid ?? -1)) {
349
+ const logTail = (() => {
350
+ try {
351
+ const text = fs.readFileSync(logFile, "utf8");
352
+ const lines = text.split(/\r?\n/).filter(Boolean);
353
+ return lines.slice(-20).join("\n");
354
+ }
355
+ catch {
356
+ return "";
357
+ }
358
+ })();
359
+ const suffix = logTail ? `\n\nLast logs:\n${logTail}` : "";
360
+ throw new Error(`Tunnel process exited (${exitMessage || "unknown"}).${suffix}`);
361
+ }
362
+ child.unref();
363
+ const entry = {
364
+ tunnelId: grant.tunnelId,
365
+ projectId,
366
+ hostname: grant.hostname,
367
+ url: grant.url ?? `https://${grant.hostname}`,
368
+ localPort: port,
369
+ controllerUrl: cleanUrl(controllerUrl),
370
+ pid: child.pid ?? -1,
371
+ logFile,
372
+ workdir,
373
+ startedAt: new Date().toISOString(),
374
+ };
375
+ upsertTunnelState(entry);
376
+ return entry;
377
+ }
378
+ export function listTunnelSessions(options) {
379
+ const state = readStateFile();
380
+ if (options?.all) {
381
+ return state.tunnels;
382
+ }
383
+ return state.tunnels.filter((entry) => isProcessAlive(entry.pid));
384
+ }
385
+ export async function stopTunnelSession(opts) {
386
+ const tunnelId = opts.tunnelId?.trim() ?? "";
387
+ if (!tunnelId) {
388
+ const active = listTunnelSessions({ all: false });
389
+ if (active.length === 1) {
390
+ return stopTunnelSession({ ...opts, tunnelId: active[0]?.tunnelId });
391
+ }
392
+ throw new Error("Tunnel id is required. Use `instafy tunnel list` to find it.");
393
+ }
394
+ const entry = removeTunnelState(tunnelId);
395
+ if (!entry) {
396
+ throw new Error(`Tunnel not found in local state: ${tunnelId}`);
397
+ }
398
+ if (isProcessAlive(entry.pid)) {
399
+ try {
400
+ process.kill(entry.pid, "SIGTERM");
401
+ }
402
+ catch {
403
+ // ignore
404
+ }
405
+ const stopped = await waitForProcessExit(entry.pid, 4000);
406
+ if (!stopped) {
407
+ try {
408
+ process.kill(entry.pid, "SIGKILL");
409
+ }
410
+ catch {
411
+ // ignore
412
+ }
413
+ }
414
+ }
415
+ const controllerToken = resolveControllerToken(opts, `instafy tunnel stop ${tunnelId}`);
416
+ await revokeTunnel(entry.controllerUrl, controllerToken, entry.projectId, entry.tunnelId);
417
+ try {
418
+ fs.rmSync(entry.workdir, { recursive: true, force: true });
419
+ }
420
+ catch {
421
+ // ignore
422
+ }
423
+ return { ok: true, tunnelId: entry.tunnelId };
424
+ }
425
+ export function resolveTunnelLogFile(tunnelId) {
426
+ const chosen = tunnelId?.trim() ?? "";
427
+ const all = readStateFile().tunnels;
428
+ if (chosen) {
429
+ const entry = all.find((tunnel) => tunnel.tunnelId === chosen);
430
+ if (!entry) {
431
+ throw new Error(`Tunnel not found in local state: ${chosen}`);
432
+ }
433
+ return entry;
434
+ }
435
+ const active = all.filter((entry) => isProcessAlive(entry.pid));
436
+ if (active.length === 1) {
437
+ return active[0];
438
+ }
439
+ throw new Error("Tunnel id is required. Use `instafy tunnel list` to find it.");
440
+ }
441
+ export async function tailTunnelLogs(options) {
442
+ const entry = resolveTunnelLogFile(options.tunnelId);
443
+ const logFile = entry.logFile;
444
+ const lines = Number.isFinite(options.lines) ? options.lines : NaN;
445
+ const lineCount = Number.isFinite(lines) && lines > 0 ? Math.floor(lines) : 200;
446
+ const follow = Boolean(options.follow);
447
+ if (options.json) {
448
+ console.log(JSON.stringify({ tunnelId: entry.tunnelId, logFile, follow, lines: lineCount }, null, 2));
449
+ return;
450
+ }
451
+ if (!follow) {
452
+ const raw = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
453
+ const rows = raw.split(/\r?\n/);
454
+ const tail = rows.slice(Math.max(0, rows.length - lineCount));
455
+ console.log(tail.join("\n"));
456
+ return;
457
+ }
458
+ // Follow mode: prefer system tail tools.
459
+ const child = process.platform === "win32"
460
+ ? spawn("powershell.exe", [
461
+ "-NoProfile",
462
+ "-Command",
463
+ `Get-Content -LiteralPath '${logFile.replace(/'/g, "''")}' -Tail ${lineCount} -Wait`,
464
+ ], { stdio: "inherit" })
465
+ : spawn("tail", ["-n", String(lineCount), "-f", logFile], { stdio: "inherit" });
466
+ const handleExit = () => {
467
+ try {
468
+ child.kill("SIGTERM");
469
+ }
470
+ catch {
471
+ // ignore
472
+ }
473
+ };
474
+ process.once("SIGINT", handleExit);
475
+ process.once("SIGTERM", handleExit);
476
+ await new Promise((resolve) => child.on("exit", () => resolve()));
477
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instafy/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Run Instafy projects locally, link folders to Studio, and share previews/webhooks via tunnels.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -17,12 +17,14 @@
17
17
  "type": "module",
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
+ "prepack": "tsc -p tsconfig.json",
20
21
  "dev": "ts-node src/index.ts",
21
22
  "test": "pnpm test:unit",
22
23
  "test:unit": "pnpm build && vitest run",
23
24
  "test:live": "pnpm build && TUNNEL_E2E_LIVE=1 vitest run test/tunnel-live.e2e.spec.ts"
24
25
  },
25
26
  "dependencies": {
27
+ "@clack/prompts": "^0.11.0",
26
28
  "commander": "^12.1.0",
27
29
  "kleur": "^4.1.5"
28
30
  },