@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/tunnel.js CHANGED
@@ -2,8 +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
- import { resolveRatholeBinaryForCli } from "./runtime.js";
7
+ import { findProjectManifest, resolveRatholeBinaryForCli } from "./runtime.js";
8
+ import { resolveConfiguredControllerUrl, resolveUserAccessToken } from "./config.js";
9
+ import { 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");
7
14
  function cleanUrl(raw) {
8
15
  return raw.replace(/\/+$/, "");
9
16
  }
@@ -14,31 +21,62 @@ function readEnv(key) {
14
21
  return undefined;
15
22
  }
16
23
  function resolveProject(opts) {
17
- return (opts.project?.trim() ||
24
+ const explicit = opts.project?.trim() ||
18
25
  readEnv("PROJECT_ID") ||
19
26
  readEnv("CONTROLLER_PROJECT_ID") ||
20
- (() => {
21
- throw new Error("Project id is required (--project or PROJECT_ID)");
22
- })());
27
+ null;
28
+ if (explicit) {
29
+ return explicit;
30
+ }
31
+ const manifest = findProjectManifest(process.cwd()).manifest;
32
+ if (manifest?.projectId) {
33
+ return manifest.projectId;
34
+ }
35
+ throw new Error("No project configured for this folder.\n\nNext:\n- instafy project:init\n\nOr pass --project <uuid> / set PROJECT_ID.");
23
36
  }
24
37
  function resolveControllerUrl(opts) {
25
- return (opts.controllerUrl?.trim() ||
26
- readEnv("INSTAFY_SERVER_URL") ||
38
+ const explicit = opts.controllerUrl?.trim();
39
+ if (explicit) {
40
+ return explicit;
41
+ }
42
+ const envUrl = readEnv("INSTAFY_SERVER_URL") ||
27
43
  readEnv("INSTAFY_URL") ||
28
44
  readEnv("CONTROLLER_URL") ||
29
45
  readEnv("CONTROLLER_BASE_URL") ||
30
- "http://127.0.0.1:8788");
46
+ null;
47
+ if (envUrl) {
48
+ return envUrl;
49
+ }
50
+ const manifestControllerUrl = findProjectManifest(process.cwd()).manifest?.controllerUrl?.trim() ?? null;
51
+ if (manifestControllerUrl) {
52
+ return manifestControllerUrl;
53
+ }
54
+ return resolveConfiguredControllerUrl() ?? "http://127.0.0.1:8788";
31
55
  }
32
56
  function resolveControllerToken(opts) {
33
- return (opts.controllerToken?.trim() ||
34
- readEnv("INSTAFY_SERVICE_TOKEN") ||
57
+ const explicit = opts.controllerToken?.trim();
58
+ if (explicit) {
59
+ return explicit;
60
+ }
61
+ const accessToken = resolveUserAccessToken();
62
+ if (accessToken) {
63
+ return accessToken;
64
+ }
65
+ const serviceToken = readEnv("INSTAFY_SERVICE_TOKEN") ||
35
66
  readEnv("CONTROLLER_BEARER") ||
67
+ readEnv("CONTROLLER_TOKEN") ||
36
68
  readEnv("SERVICE_ROLE_KEY") ||
69
+ readEnv("CONTROLLER_SERVICE_ROLE_KEY") ||
37
70
  readEnv("SUPABASE_SERVICE_ROLE_KEY") ||
38
71
  readEnv("CONTROLLER_INTERNAL_TOKEN") ||
39
- (() => {
40
- throw new Error("Service token is required (--service-token, INSTAFY_SERVICE_TOKEN, or SERVICE_ROLE_KEY).");
41
- })());
72
+ null;
73
+ if (serviceToken) {
74
+ return serviceToken;
75
+ }
76
+ throw formatAuthRequiredError({
77
+ retryCommand: "instafy tunnel",
78
+ advancedHint: "pass --access-token / --service-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN / INSTAFY_SERVICE_TOKEN",
79
+ });
42
80
  }
43
81
  function resolvePort(opts) {
44
82
  const fromEnv = readEnv("WEBHOOK_LOCAL_PORT") ||
@@ -47,6 +85,77 @@ function resolvePort(opts) {
47
85
  const parsedEnv = fromEnv ? Number(fromEnv) : NaN;
48
86
  return opts.port ?? (Number.isFinite(parsedEnv) ? parsedEnv : 3000);
49
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
+ }
50
159
  async function requestTunnel(controllerUrl, token, projectId, metadata) {
51
160
  const target = `${cleanUrl(controllerUrl)}/projects/${encodeURIComponent(projectId)}/tunnels/request`;
52
161
  const response = await fetch(target, {
@@ -181,3 +290,181 @@ export async function runTunnelCommand(opts, options) {
181
290
  process.once("SIGTERM", handle);
182
291
  });
183
292
  }
293
+ export async function startTunnelDetached(opts) {
294
+ const projectId = resolveProject(opts);
295
+ const controllerUrl = resolveControllerUrl(opts);
296
+ const controllerToken = resolveControllerToken(opts);
297
+ const port = resolvePort(opts);
298
+ if (opts.ratholeBin) {
299
+ process.env.RATHOLE_BIN = opts.ratholeBin;
300
+ }
301
+ const rathole = await resolveRatholeBinaryForCli({
302
+ env: process.env,
303
+ version: process.env.RATHOLE_VERSION ?? null,
304
+ cacheDir: process.env.RATHOLE_CACHE_DIR ?? null,
305
+ logger: (message) => console.log(kleur.cyan(`[rathole] ${message}`)),
306
+ warn: (message) => console.warn(kleur.yellow(message)),
307
+ });
308
+ if (!rathole) {
309
+ throw new Error("rathole is required to start a tunnel. Set RATHOLE_BIN or ensure it is on PATH.");
310
+ }
311
+ const metadata = { localPort: port, source: "instafy-cli" };
312
+ const grant = await requestTunnel(controllerUrl, controllerToken, projectId, metadata);
313
+ ensureDir(TUNNEL_WORKDIR_DIR);
314
+ ensureDir(TUNNEL_LOG_DIR);
315
+ const tunnelIdSafe = safePathSegment(grant.tunnelId);
316
+ const workdir = path.join(TUNNEL_WORKDIR_DIR, tunnelIdSafe);
317
+ ensureDir(workdir);
318
+ const creds = grant.credentials && typeof grant.credentials === "object"
319
+ ? grant.credentials
320
+ : {};
321
+ const configBody = buildRatholeConfig(creds, port);
322
+ const configPath = path.join(workdir, "rathole.toml");
323
+ fs.writeFileSync(configPath, configBody, { encoding: "utf8", mode: 0o600 });
324
+ const logFile = opts.logFile?.trim()
325
+ ? path.resolve(opts.logFile.trim())
326
+ : path.join(TUNNEL_LOG_DIR, `tunnel-${tunnelIdSafe}.log`);
327
+ const logFd = fs.openSync(logFile, "a");
328
+ const child = spawn(rathole, ["-c", configPath], {
329
+ stdio: ["ignore", logFd, logFd],
330
+ cwd: workdir,
331
+ detached: true,
332
+ });
333
+ let exitedEarly = false;
334
+ let exitMessage = "";
335
+ child.on("exit", (code, signal) => {
336
+ exitedEarly = true;
337
+ exitMessage = code !== null ? `code ${code}` : `signal ${signal}`;
338
+ });
339
+ // Give rathole a moment to fail fast, so `instafy tunnel` can report errors.
340
+ await new Promise((resolve) => setTimeout(resolve, 250));
341
+ if (exitedEarly || !isProcessAlive(child.pid ?? -1)) {
342
+ const logTail = (() => {
343
+ try {
344
+ const text = fs.readFileSync(logFile, "utf8");
345
+ const lines = text.split(/\r?\n/).filter(Boolean);
346
+ return lines.slice(-20).join("\n");
347
+ }
348
+ catch {
349
+ return "";
350
+ }
351
+ })();
352
+ const suffix = logTail ? `\n\nLast logs:\n${logTail}` : "";
353
+ throw new Error(`Tunnel process exited (${exitMessage || "unknown"}).${suffix}`);
354
+ }
355
+ child.unref();
356
+ const entry = {
357
+ tunnelId: grant.tunnelId,
358
+ projectId,
359
+ hostname: grant.hostname,
360
+ url: grant.url ?? `https://${grant.hostname}`,
361
+ localPort: port,
362
+ controllerUrl: cleanUrl(controllerUrl),
363
+ pid: child.pid ?? -1,
364
+ logFile,
365
+ workdir,
366
+ startedAt: new Date().toISOString(),
367
+ };
368
+ upsertTunnelState(entry);
369
+ return entry;
370
+ }
371
+ export function listTunnelSessions(options) {
372
+ const state = readStateFile();
373
+ if (options?.all) {
374
+ return state.tunnels;
375
+ }
376
+ return state.tunnels.filter((entry) => isProcessAlive(entry.pid));
377
+ }
378
+ export async function stopTunnelSession(opts) {
379
+ const tunnelId = opts.tunnelId?.trim() ?? "";
380
+ if (!tunnelId) {
381
+ const active = listTunnelSessions({ all: false });
382
+ if (active.length === 1) {
383
+ return stopTunnelSession({ ...opts, tunnelId: active[0]?.tunnelId });
384
+ }
385
+ throw new Error("Tunnel id is required. Use `instafy tunnel:list` to find it.");
386
+ }
387
+ const entry = removeTunnelState(tunnelId);
388
+ if (!entry) {
389
+ throw new Error(`Tunnel not found in local state: ${tunnelId}`);
390
+ }
391
+ if (isProcessAlive(entry.pid)) {
392
+ try {
393
+ process.kill(entry.pid, "SIGTERM");
394
+ }
395
+ catch {
396
+ // ignore
397
+ }
398
+ const stopped = await waitForProcessExit(entry.pid, 4000);
399
+ if (!stopped) {
400
+ try {
401
+ process.kill(entry.pid, "SIGKILL");
402
+ }
403
+ catch {
404
+ // ignore
405
+ }
406
+ }
407
+ }
408
+ const controllerToken = resolveControllerToken(opts);
409
+ await revokeTunnel(entry.controllerUrl, controllerToken, entry.projectId, entry.tunnelId);
410
+ try {
411
+ fs.rmSync(entry.workdir, { recursive: true, force: true });
412
+ }
413
+ catch {
414
+ // ignore
415
+ }
416
+ return { ok: true, tunnelId: entry.tunnelId };
417
+ }
418
+ export function resolveTunnelLogFile(tunnelId) {
419
+ const chosen = tunnelId?.trim() ?? "";
420
+ const all = readStateFile().tunnels;
421
+ if (chosen) {
422
+ const entry = all.find((tunnel) => tunnel.tunnelId === chosen);
423
+ if (!entry) {
424
+ throw new Error(`Tunnel not found in local state: ${chosen}`);
425
+ }
426
+ return entry;
427
+ }
428
+ const active = all.filter((entry) => isProcessAlive(entry.pid));
429
+ if (active.length === 1) {
430
+ return active[0];
431
+ }
432
+ throw new Error("Tunnel id is required. Use `instafy tunnel:list` to find it.");
433
+ }
434
+ export async function tailTunnelLogs(options) {
435
+ const entry = resolveTunnelLogFile(options.tunnelId);
436
+ const logFile = entry.logFile;
437
+ const lines = Number.isFinite(options.lines) ? options.lines : NaN;
438
+ const lineCount = Number.isFinite(lines) && lines > 0 ? Math.floor(lines) : 200;
439
+ const follow = Boolean(options.follow);
440
+ if (options.json) {
441
+ console.log(JSON.stringify({ tunnelId: entry.tunnelId, logFile, follow, lines: lineCount }, null, 2));
442
+ return;
443
+ }
444
+ if (!follow) {
445
+ const raw = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
446
+ const rows = raw.split(/\r?\n/);
447
+ const tail = rows.slice(Math.max(0, rows.length - lineCount));
448
+ console.log(tail.join("\n"));
449
+ return;
450
+ }
451
+ // Follow mode: prefer system tail tools.
452
+ const child = process.platform === "win32"
453
+ ? spawn("powershell.exe", [
454
+ "-NoProfile",
455
+ "-Command",
456
+ `Get-Content -LiteralPath '${logFile.replace(/'/g, "''")}' -Tail ${lineCount} -Wait`,
457
+ ], { stdio: "inherit" })
458
+ : spawn("tail", ["-n", String(lineCount), "-f", logFile], { stdio: "inherit" });
459
+ const handleExit = () => {
460
+ try {
461
+ child.kill("SIGTERM");
462
+ }
463
+ catch {
464
+ // ignore
465
+ }
466
+ };
467
+ process.once("SIGINT", handleExit);
468
+ process.once("SIGTERM", handleExit);
469
+ await new Promise((resolve) => child.on("exit", () => resolve()));
470
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instafy/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8-staging.348",
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,6 +17,7 @@
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",