instavm 0.15.0 → 0.16.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.
package/dist/cli.js CHANGED
@@ -35,8 +35,9 @@ __export(cli_exports, {
35
35
  runCli: () => runCli
36
36
  });
37
37
  module.exports = __toCommonJS(cli_exports);
38
- var import_fs3 = __toESM(require("fs"));
39
- var import_path12 = __toESM(require("path"));
38
+ var import_fs5 = __toESM(require("fs"));
39
+ var import_crypto2 = __toESM(require("crypto"));
40
+ var import_path14 = __toESM(require("path"));
40
41
  var import_child_process = require("child_process");
41
42
  var import_commander = require("commander");
42
43
 
@@ -179,7 +180,7 @@ var HTTPClient = class {
179
180
  timeout: config.timeout,
180
181
  headers: {
181
182
  "Content-Type": "application/json",
182
- "User-Agent": "instavm-js-sdk/0.15.0"
183
+ "User-Agent": "instavm-js-sdk/0.16.0"
183
184
  }
184
185
  });
185
186
  this.setupInterceptors();
@@ -958,8 +959,8 @@ function encodePathSegment(value) {
958
959
  }
959
960
  return encodeURIComponent(segment);
960
961
  }
961
- function encodePathSegments(path4) {
962
- const segments = path4.replace(/^\/+/, "").split("/").filter((segment) => segment.length > 0 && segment !== ".");
962
+ function encodePathSegments(path6) {
963
+ const segments = path6.replace(/^\/+/, "").split("/").filter((segment) => segment.length > 0 && segment !== ".");
963
964
  if (segments.some((segment) => segment === "..")) {
964
965
  throw new Error("Path traversal not allowed in proxy path");
965
966
  }
@@ -1262,10 +1263,10 @@ var ComputerUseManager = class {
1262
1263
  { "X-API-Key": this.httpClient.apiKey }
1263
1264
  );
1264
1265
  }
1265
- async proxy(sessionId, path4, method = "GET", options = {}) {
1266
+ async proxy(sessionId, path6, method = "GET", options = {}) {
1266
1267
  this.ensureCloud("Computer-use proxy");
1267
1268
  const safeSessionId = encodePathSegment(sessionId);
1268
- const cleanPath = encodePathSegments(path4);
1269
+ const cleanPath = encodePathSegments(path6);
1269
1270
  return this.httpClient.request({
1270
1271
  method,
1271
1272
  url: `/v1/computeruse/${safeSessionId}/${cleanPath}`,
@@ -1274,26 +1275,26 @@ var ComputerUseManager = class {
1274
1275
  headers: { "X-API-Key": this.httpClient.apiKey }
1275
1276
  });
1276
1277
  }
1277
- async get(sessionId, path4, params) {
1278
- return this.proxy(sessionId, path4, "GET", { params });
1278
+ async get(sessionId, path6, params) {
1279
+ return this.proxy(sessionId, path6, "GET", { params });
1279
1280
  }
1280
- async post(sessionId, path4, body) {
1281
- return this.proxy(sessionId, path4, "POST", { body });
1281
+ async post(sessionId, path6, body) {
1282
+ return this.proxy(sessionId, path6, "POST", { body });
1282
1283
  }
1283
- async put(sessionId, path4, body) {
1284
- return this.proxy(sessionId, path4, "PUT", { body });
1284
+ async put(sessionId, path6, body) {
1285
+ return this.proxy(sessionId, path6, "PUT", { body });
1285
1286
  }
1286
- async patch(sessionId, path4, body) {
1287
- return this.proxy(sessionId, path4, "PATCH", { body });
1287
+ async patch(sessionId, path6, body) {
1288
+ return this.proxy(sessionId, path6, "PATCH", { body });
1288
1289
  }
1289
- async delete(sessionId, path4, params) {
1290
- return this.proxy(sessionId, path4, "DELETE", { params });
1290
+ async delete(sessionId, path6, params) {
1291
+ return this.proxy(sessionId, path6, "DELETE", { params });
1291
1292
  }
1292
- async options(sessionId, path4, params) {
1293
- return this.proxy(sessionId, path4, "OPTIONS", { params });
1293
+ async options(sessionId, path6, params) {
1294
+ return this.proxy(sessionId, path6, "OPTIONS", { params });
1294
1295
  }
1295
- async head(sessionId, path4, params) {
1296
- return this.proxy(sessionId, path4, "HEAD", { params });
1296
+ async head(sessionId, path6, params) {
1297
+ return this.proxy(sessionId, path6, "HEAD", { params });
1297
1298
  }
1298
1299
  /**
1299
1300
  * Get the VNC WebSocket URL for a computer-use session.
@@ -1624,12 +1625,12 @@ var VolumesManager = class {
1624
1625
  formData
1625
1626
  );
1626
1627
  }
1627
- async downloadFile(volumeId, path4) {
1628
+ async downloadFile(volumeId, path6) {
1628
1629
  this.ensureCloud("Volume file download");
1629
1630
  const safeVolumeId = encodePathSegment(volumeId);
1630
1631
  const response = await this.httpClient.post(
1631
1632
  `/v1/volumes/${safeVolumeId}/files/download`,
1632
- { path: path4 },
1633
+ { path: path6 },
1633
1634
  { "X-API-Key": this.httpClient.apiKey }
1634
1635
  );
1635
1636
  return {
@@ -2050,7 +2051,7 @@ var InstaVM = class {
2050
2051
  async getCurrentUser() {
2051
2052
  this.ensureNotLocal("User profile lookup");
2052
2053
  return this.httpClient.get(
2053
- "/v1/me",
2054
+ "/v1/users/me",
2054
2055
  void 0,
2055
2056
  { "X-API-Key": this.httpClient.apiKey }
2056
2057
  );
@@ -2291,6 +2292,14 @@ var InstaVM = class {
2291
2292
  }
2292
2293
  };
2293
2294
 
2295
+ // src/cli/cookbook.ts
2296
+ var import_fs3 = __toESM(require("fs"));
2297
+ var import_os2 = __toESM(require("os"));
2298
+ var import_path12 = __toESM(require("path"));
2299
+ var import_crypto = __toESM(require("crypto"));
2300
+ var import_readline = __toESM(require("readline"));
2301
+ var import_yaml = require("yaml");
2302
+
2294
2303
  // src/cli/config.ts
2295
2304
  var import_fs2 = __toESM(require("fs"));
2296
2305
  var import_os = __toESM(require("os"));
@@ -2527,225 +2536,2295 @@ function updateProfileSettings(options) {
2527
2536
  return saveConfig(config, configPath);
2528
2537
  }
2529
2538
 
2530
- // src/cli.ts
2531
- var defaultDeps = {
2532
- stdout: process.stdout,
2533
- stderr: process.stderr,
2534
- spawnSync: import_child_process.spawnSync,
2535
- resolveRuntimeConfig,
2536
- updateProfileSettings,
2537
- clientFactory: (apiKey, options) => new InstaVM(apiKey, options),
2538
- promptSecret,
2539
- readStdin,
2540
- writeFile: (outputPath, content) => {
2541
- import_fs3.default.writeFileSync(outputPath, content);
2539
+ // src/cli/cookbook.ts
2540
+ var CookbookError = class extends Error {
2541
+ };
2542
+ var CookbookDeployError = class extends CookbookError {
2543
+ constructor(message, payload) {
2544
+ super(message);
2545
+ this.payload = payload;
2542
2546
  }
2543
2547
  };
2544
- function printJson(deps, payload) {
2545
- deps.stdout.write(`${JSON.stringify(payload)}
2546
- `);
2548
+ var DEFAULT_COOKBOOK_REPO_URL = "https://github.com/instavm/cookbooks.git";
2549
+ var DEFAULT_COOKBOOK_REF = "main";
2550
+ var DEFAULT_REMOTE_ROOT = ".instavm-cookbooks";
2551
+ var DEFAULT_SOURCE_EXCLUDES = [
2552
+ ".git",
2553
+ "__pycache__",
2554
+ ".pytest_cache",
2555
+ ".venv",
2556
+ "venv",
2557
+ "node_modules",
2558
+ ".next",
2559
+ "dist",
2560
+ "build",
2561
+ ".DS_Store"
2562
+ ];
2563
+ function getCookbookCacheDir(homeDir = import_os2.default.homedir()) {
2564
+ return import_path12.default.join(getConfigDir(homeDir), "cookbooks");
2547
2565
  }
2548
- function printLines(deps, lines) {
2549
- for (const line of lines) {
2550
- deps.stdout.write(`${line}
2551
- `);
2552
- }
2566
+ function getCookbookRepoDir(homeDir = import_os2.default.homedir()) {
2567
+ return import_path12.default.join(getCookbookCacheDir(homeDir), "repo");
2553
2568
  }
2554
- function emitOutput(deps, options, payload, textLines) {
2555
- if (options.json) {
2556
- printJson(deps, payload);
2557
- } else {
2558
- printLines(deps, textLines);
2569
+ function resolveCookbookRoot(deps, progress, homeDir = import_os2.default.homedir()) {
2570
+ const override = process.env.INSTAVM_COOKBOOKS_DIR;
2571
+ if (override) {
2572
+ const resolved = import_path12.default.resolve(override);
2573
+ if (!import_fs3.default.existsSync(resolved)) {
2574
+ throw new CookbookError(`Cookbook directory override does not exist: ${resolved}`);
2575
+ }
2576
+ return resolved;
2559
2577
  }
2560
- return 0;
2578
+ return syncCookbookRepo(deps, progress, homeDir);
2561
2579
  }
2562
- function addRuntimeOptions(command, options = {}) {
2563
- command.option("--api-key <apiKey>", "Override the InstaVM API key for this command");
2564
- command.option("--base-url <baseUrl>", "Override the InstaVM API base URL for this command");
2565
- if (options.includeSshHost) {
2566
- command.option("--ssh-host <sshHost>", "Override the SSH gateway host for this command");
2580
+ function syncCookbookRepo(deps, progress, homeDir = import_os2.default.homedir()) {
2581
+ const repoUrl = process.env.INSTAVM_COOKBOOKS_REPO || DEFAULT_COOKBOOK_REPO_URL;
2582
+ const repoRef = process.env.INSTAVM_COOKBOOKS_REF || DEFAULT_COOKBOOK_REF;
2583
+ const repoDir = getCookbookRepoDir(homeDir);
2584
+ import_fs3.default.mkdirSync(import_path12.default.dirname(repoDir), { recursive: true });
2585
+ if (progress) {
2586
+ progress(`Syncing cookbook catalog from ${repoUrl}`);
2587
+ }
2588
+ if (!import_fs3.default.existsSync(import_path12.default.join(repoDir, ".git"))) {
2589
+ runCommand(
2590
+ deps,
2591
+ "git",
2592
+ ["clone", "--depth", "1", "--branch", repoRef, repoUrl, repoDir],
2593
+ "Failed to clone the cookbook catalog."
2594
+ );
2595
+ return repoDir;
2567
2596
  }
2568
- if (options.includeJson !== false) {
2569
- command.option("-j, --json", "Emit JSON output");
2597
+ runCommand(deps, "git", ["-C", repoDir, "fetch", "--depth", "1", "origin", repoRef], "Failed to update the cookbook catalog.");
2598
+ runCommand(deps, "git", ["-C", repoDir, "checkout", repoRef], "Failed to check out the cookbook catalog branch.");
2599
+ runCommand(deps, "git", ["-C", repoDir, "reset", "--hard", "FETCH_HEAD"], "Failed to reset the cookbook catalog to the latest remote state.");
2600
+ return repoDir;
2601
+ }
2602
+ function discoverCookbooks(root) {
2603
+ if (!import_fs3.default.existsSync(root)) {
2604
+ return [];
2570
2605
  }
2571
- return command;
2606
+ return import_fs3.default.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && import_fs3.default.existsSync(import_path12.default.join(root, entry.name, "instavm.yaml"))).sort((a, b) => a.name.localeCompare(b.name)).map((entry) => loadManifest(import_path12.default.join(root, entry.name, "instavm.yaml")));
2572
2607
  }
2573
- function parseSize(value) {
2574
- const raw = value.trim().toLowerCase();
2575
- if (!raw) {
2576
- throw new Error("size is required");
2608
+ function loadManifest(manifestPath) {
2609
+ let raw;
2610
+ try {
2611
+ raw = (0, import_yaml.parse)(import_fs3.default.readFileSync(manifestPath, "utf8"));
2612
+ } catch (error) {
2613
+ throw new CookbookError(`Invalid cookbook manifest: ${manifestPath}`);
2577
2614
  }
2578
- const units = [
2579
- ["tb", 1024 ** 4],
2580
- ["gb", 1024 ** 3],
2581
- ["mb", 1024 ** 2],
2582
- ["kb", 1024],
2583
- ["b", 1]
2584
- ];
2585
- for (const [suffix, multiplier] of units) {
2586
- if (raw.endsWith(suffix) && raw !== suffix) {
2587
- const number = raw.slice(0, -suffix.length).trim();
2588
- return Math.floor(Number.parseFloat(number) * multiplier);
2589
- }
2615
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2616
+ throw new CookbookError(`Manifest root must be an object: ${manifestPath}`);
2590
2617
  }
2591
- return Number.parseInt(raw, 10);
2592
- }
2593
- function humanBytes(value) {
2594
- let amount = Number(value || 0);
2595
- for (const unit of ["B", "KB", "MB", "GB", "TB"]) {
2596
- if (amount < 1024 || unit === "TB") {
2597
- if (unit === "B") {
2598
- return `${Math.trunc(amount)}${unit}`;
2599
- }
2600
- return `${amount.toFixed(1)}${unit}`;
2618
+ const requireObject = (value, field) => {
2619
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2620
+ throw new CookbookError(`Manifest field \`${field}\` must be an object.`);
2601
2621
  }
2602
- amount /= 1024;
2603
- }
2604
- return `${Math.trunc(Number(value || 0))}B`;
2605
- }
2606
- function parseEnvPairs(values) {
2607
- const pairs = {};
2608
- for (const entry of values || []) {
2609
- const separatorIndex = entry.indexOf("=");
2610
- if (separatorIndex < 1) {
2611
- throw new Error(`Expected KEY=VALUE, got: ${entry}`);
2622
+ return value;
2623
+ };
2624
+ const requireString = (value, field) => {
2625
+ if (typeof value !== "string" || !value.trim()) {
2626
+ throw new CookbookError(`Manifest field \`${field}\` must be a non-empty string.`);
2612
2627
  }
2613
- const key = entry.slice(0, separatorIndex).trim();
2614
- const value = entry.slice(separatorIndex + 1);
2615
- if (!key) {
2616
- throw new Error(`Invalid env key: ${entry}`);
2628
+ return value.trim();
2629
+ };
2630
+ const requireInt = (value, field) => {
2631
+ if (!Number.isInteger(value)) {
2632
+ throw new CookbookError(`Manifest field \`${field}\` must be an integer.`);
2617
2633
  }
2618
- pairs[key] = value;
2634
+ return Number(value);
2635
+ };
2636
+ const requiredFields = [
2637
+ "schema_version",
2638
+ "slug",
2639
+ "title",
2640
+ "version",
2641
+ "summary",
2642
+ "category",
2643
+ "runtime",
2644
+ "deploy",
2645
+ "vm",
2646
+ "app",
2647
+ "run",
2648
+ "secrets",
2649
+ "post_deploy_notes"
2650
+ ];
2651
+ const missing = requiredFields.filter((field) => !(field in raw));
2652
+ if (missing.length > 0) {
2653
+ throw new CookbookError(`Manifest is missing required fields: ${missing.join(", ")}`);
2654
+ }
2655
+ const deploy = requireObject(raw.deploy, "deploy");
2656
+ const vm = requireObject(raw.vm, "vm");
2657
+ const app = requireObject(raw.app, "app");
2658
+ const run = requireObject(raw.run, "run");
2659
+ const secrets = Array.isArray(raw.secrets) ? raw.secrets : [];
2660
+ const manifest = {
2661
+ path: import_path12.default.dirname(manifestPath),
2662
+ schema_version: requireInt(raw.schema_version, "schema_version"),
2663
+ slug: requireString(raw.slug, "slug"),
2664
+ title: requireString(raw.title, "title"),
2665
+ version: requireString(raw.version, "version"),
2666
+ summary: requireString(raw.summary, "summary"),
2667
+ category: requireString(raw.category, "category"),
2668
+ runtime: requireString(raw.runtime, "runtime"),
2669
+ deploy: {
2670
+ kind: requireString(deploy.kind, "deploy.kind")
2671
+ },
2672
+ vm: {
2673
+ memory_mb: requireInt(vm.memory_mb, "vm.memory_mb"),
2674
+ vcpu_count: requireInt(vm.vcpu_count, "vm.vcpu_count"),
2675
+ timeout_seconds: Number.isInteger(vm.timeout_seconds) ? Number(vm.timeout_seconds) : void 0,
2676
+ image_variant: typeof vm.image_variant === "string" ? vm.image_variant.trim() || void 0 : void 0
2677
+ },
2678
+ app: {
2679
+ port: requireInt(app.port, "app.port"),
2680
+ healthcheck_path: requireString(app.healthcheck_path, "app.healthcheck_path"),
2681
+ share_public_default: Boolean(app.share_public_default),
2682
+ readiness_timeout_seconds: requireInt(app.readiness_timeout_seconds, "app.readiness_timeout_seconds")
2683
+ },
2684
+ run: {
2685
+ workdir: requireString(run.workdir, "run.workdir"),
2686
+ start_command: requireString(run.start_command, "run.start_command"),
2687
+ logs_hint: typeof run.logs_hint === "string" ? run.logs_hint.trim() || void 0 : void 0
2688
+ },
2689
+ secrets: secrets.map((secret, index) => {
2690
+ const obj = requireObject(secret, `secrets[${index}]`);
2691
+ return {
2692
+ name: requireString(obj.name, `secrets[${index}].name`),
2693
+ required: obj.required !== false,
2694
+ prompt: requireString(obj.prompt, `secrets[${index}].prompt`),
2695
+ env_name: requireString(obj.env_name, `secrets[${index}].env_name`)
2696
+ };
2697
+ }),
2698
+ post_deploy_notes: typeof raw.post_deploy_notes === "string" ? [raw.post_deploy_notes.trim()].filter(Boolean) : Array.isArray(raw.post_deploy_notes) ? raw.post_deploy_notes.map((entry) => requireString(entry, "post_deploy_notes[]")) : [],
2699
+ artifact: raw.artifact ? {
2700
+ oci_image: requireString(raw.artifact.oci_image, "artifact.oci_image"),
2701
+ snapshot_name: requireString(raw.artifact.snapshot_name, "artifact.snapshot_name"),
2702
+ snapshot_visibility: requireString(raw.artifact.snapshot_visibility, "artifact.snapshot_visibility")
2703
+ } : void 0,
2704
+ build: raw.build ? {
2705
+ context: requireString(raw.build.context, "build.context"),
2706
+ dockerfile: requireString(raw.build.dockerfile, "build.dockerfile"),
2707
+ target: typeof raw.build.target === "string" ? raw.build.target.trim() || void 0 : void 0
2708
+ } : void 0,
2709
+ source: raw.source ? {
2710
+ include: Array.isArray(raw.source.include) ? raw.source.include.map((entry) => requireString(entry, "source.include[]")) : [],
2711
+ exclude: Array.isArray(raw.source.exclude) ? raw.source.exclude.map((entry) => requireString(entry, "source.exclude[]")) : [],
2712
+ setup_command: requireString(raw.source.setup_command, "source.setup_command")
2713
+ } : void 0
2714
+ };
2715
+ if (!["published_snapshot", "upload_and_run"].includes(manifest.deploy.kind)) {
2716
+ throw new CookbookError("Manifest field `deploy.kind` must be `published_snapshot` or `upload_and_run`.");
2619
2717
  }
2620
- return pairs;
2621
- }
2622
- function parseVolumeSpec(spec) {
2623
- const parts = spec.split(":");
2624
- if (parts.length < 2) {
2625
- throw new Error("volume mounts must use <volume_id>:<mount_path>[:rw|ro[:checkpoint_id]]");
2718
+ if (manifest.schema_version !== 1) {
2719
+ throw new CookbookError("Unsupported cookbook schema version. Expected `schema_version: 1`.");
2626
2720
  }
2627
- const volumeId = parts[0].trim();
2628
- const mountPath = parts[1].trim();
2629
- let mode = "rw";
2630
- let checkpointId = null;
2631
- if (parts[2]?.trim()) {
2632
- mode = parts[2].trim().toLowerCase();
2721
+ if (!manifest.app.healthcheck_path.startsWith("/")) {
2722
+ throw new CookbookError("Manifest field `app.healthcheck_path` must start with `/`.");
2633
2723
  }
2634
- if (parts[3]?.trim()) {
2635
- checkpointId = parts[3].trim();
2724
+ if (manifest.deploy.kind === "published_snapshot" && !manifest.artifact) {
2725
+ throw new CookbookError("Published snapshot cookbooks require `artifact` config.");
2636
2726
  }
2637
- if (parts.length > 4) {
2638
- throw new Error("volume mounts must use <volume_id>:<mount_path>[:rw|ro[:checkpoint_id]]");
2727
+ if (manifest.deploy.kind === "published_snapshot" && manifest.artifact?.snapshot_visibility !== "public_system") {
2728
+ throw new CookbookError("Manifest field `artifact.snapshot_visibility` must be `public_system`.");
2639
2729
  }
2640
- if (!["rw", "ro"].includes(mode)) {
2641
- throw new Error("volume mount mode must be rw or ro");
2730
+ if (manifest.deploy.kind === "published_snapshot" && manifest.build) {
2731
+ if (!import_fs3.default.existsSync(import_path12.default.join(import_path12.default.dirname(manifestPath), manifest.build.context))) {
2732
+ throw new CookbookError(`Build context does not exist: ${import_path12.default.join(import_path12.default.dirname(manifestPath), manifest.build.context)}`);
2733
+ }
2734
+ if (!import_fs3.default.existsSync(import_path12.default.join(import_path12.default.dirname(manifestPath), manifest.build.dockerfile))) {
2735
+ throw new CookbookError(`Dockerfile does not exist: ${import_path12.default.join(import_path12.default.dirname(manifestPath), manifest.build.dockerfile)}`);
2736
+ }
2642
2737
  }
2643
- if (mode === "rw" && checkpointId) {
2644
- throw new Error("checkpoint_id is only allowed for ro mounts");
2738
+ if (manifest.deploy.kind === "upload_and_run" && !manifest.source) {
2739
+ throw new CookbookError("Upload-and-run cookbooks require `source` config.");
2645
2740
  }
2646
- if (mode === "ro" && !checkpointId) {
2647
- checkpointId = "latest";
2741
+ if (manifest.deploy.kind === "upload_and_run" && manifest.source && manifest.source.include.length === 0) {
2742
+ throw new CookbookError("Manifest field `source.include` must contain at least one path or glob.");
2648
2743
  }
2744
+ return manifest;
2745
+ }
2746
+ function manifestDisplayRecord(manifest) {
2649
2747
  return {
2650
- volume_id: volumeId,
2651
- mount_path: mountPath,
2652
- mode,
2653
- checkpoint_id: checkpointId
2748
+ slug: manifest.slug,
2749
+ title: manifest.title,
2750
+ summary: manifest.summary,
2751
+ runtime: manifest.runtime,
2752
+ category: manifest.category,
2753
+ deploy_kind: manifest.deploy.kind,
2754
+ port: manifest.app.port,
2755
+ requires_secrets: manifest.secrets.length > 0,
2756
+ version: manifest.version
2654
2757
  };
2655
2758
  }
2656
- function looksLikeUuid(value) {
2657
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
2658
- }
2659
- function statusPayload(deps, options) {
2660
- const settings = deps.resolveRuntimeConfig({
2661
- apiKey: options.apiKey,
2662
- baseURL: options.baseUrl,
2663
- sshHost: options.sshHost
2664
- });
2665
- const profile = getActiveProfile(settings.config);
2666
- const storedKey = profile.auth?.api_key ? String(profile.auth.api_key) : void 0;
2667
- return {
2668
- config_path: settings.configPath,
2669
- active_profile: settings.config.active_profile,
2670
- api_key: {
2671
- configured: Boolean(settings.apiKey),
2672
- redacted: redactSecret(settings.apiKey),
2673
- source: settings.apiKeySource,
2674
- stored: Boolean(storedKey)
2675
- },
2676
- base_url: {
2677
- value: settings.baseURL,
2678
- source: settings.baseURLSource
2679
- },
2680
- ssh_host: {
2681
- value: settings.sshHost,
2682
- source: settings.sshHostSource
2759
+ function preflightTools(deps) {
2760
+ const found = {};
2761
+ const missing = [];
2762
+ for (const binary of ["git", "ssh", "scp", "tar"]) {
2763
+ const resolved = findExecutable(deps, binary);
2764
+ if (resolved) {
2765
+ found[binary] = resolved;
2766
+ } else {
2767
+ missing.push(binary);
2683
2768
  }
2684
- };
2769
+ }
2770
+ if (missing.length > 0) {
2771
+ throw new CookbookError(`Missing required local tools: ${missing.join(", ")}`);
2772
+ }
2773
+ return found;
2685
2774
  }
2686
- function statusText(payload) {
2687
- const lines = [
2688
- `Config path: ${payload.config_path}`,
2689
- `Active profile: ${payload.active_profile}`,
2690
- `API key: ${payload.api_key.redacted || "not configured"} (${payload.api_key.source})`,
2691
- `Base URL: ${payload.base_url.value} (${payload.base_url.source})`,
2692
- `SSH host: ${payload.ssh_host.value} (${payload.ssh_host.source})`
2693
- ];
2694
- if (payload.api_key.stored) {
2695
- lines.push("Stored key: present");
2775
+ function scanPublicKeys(homeDir = import_os2.default.homedir()) {
2776
+ const sshDir = import_path12.default.join(homeDir, ".ssh");
2777
+ if (!import_fs3.default.existsSync(sshDir)) {
2778
+ return [];
2696
2779
  }
2697
- return lines;
2780
+ return import_fs3.default.readdirSync(sshDir).filter((entry) => entry.endsWith(".pub")).map((entry) => import_path12.default.join(sshDir, entry)).filter((entry) => import_fs3.default.statSync(entry).isFile()).sort();
2698
2781
  }
2699
- function resolveSettings(deps, options) {
2700
- return deps.resolveRuntimeConfig({
2701
- apiKey: options.apiKey,
2702
- baseURL: options.baseUrl,
2703
- sshHost: options.sshHost
2782
+ function publicKeyFingerprint(publicKeyPath) {
2783
+ let content = "";
2784
+ try {
2785
+ content = import_fs3.default.readFileSync(publicKeyPath, "utf8").trim();
2786
+ } catch (error) {
2787
+ return void 0;
2788
+ }
2789
+ const parts = content.split(/\s+/);
2790
+ if (parts.length < 2) {
2791
+ return void 0;
2792
+ }
2793
+ try {
2794
+ const keyBlob = Buffer.from(parts[1], "base64");
2795
+ const digest = import_crypto.default.createHash("sha256").update(keyBlob).digest("base64").replace(/=+$/g, "");
2796
+ return `SHA256:${digest}`;
2797
+ } catch (error) {
2798
+ return void 0;
2799
+ }
2800
+ }
2801
+ function matchingLocalPublicKey(keys, candidates) {
2802
+ const fingerprints = new Set(
2803
+ keys.map((key) => String(key.fingerprint || "").trim()).filter(Boolean)
2804
+ );
2805
+ if (fingerprints.size === 0) {
2806
+ return void 0;
2807
+ }
2808
+ return candidates.find((candidate) => {
2809
+ const fingerprint = publicKeyFingerprint(candidate);
2810
+ return fingerprint ? fingerprints.has(fingerprint) : false;
2704
2811
  });
2705
2812
  }
2706
- function requireClient(deps, options) {
2707
- const settings = resolveSettings(deps, options);
2708
- if (!settings.apiKey) {
2709
- throw new Error("No InstaVM API key configured. Run `instavm auth set-key` or export INSTAVM_API_KEY.");
2813
+ async function ensureAccountSshKey(client, deps, progress, homeDir = import_os2.default.homedir()) {
2814
+ const keys = await client.listSshKeys();
2815
+ const candidates = scanPublicKeys(homeDir);
2816
+ if (keys.length > 0) {
2817
+ const selectedPath = matchingLocalPublicKey(keys, candidates);
2818
+ if (progress && selectedPath) {
2819
+ progress(`Using local SSH identity from ${selectedPath}`);
2820
+ }
2821
+ return { configured: true, auto_added: false, keys, selected_path: selectedPath };
2710
2822
  }
2711
- return {
2712
- client: deps.clientFactory(settings.apiKey, { baseURL: settings.baseURL }),
2713
- settings
2714
- };
2823
+ if (candidates.length === 0) {
2824
+ throw new CookbookError(
2825
+ "No SSH key is registered on your account, and no local public key was found in ~/.ssh. Generate one with `ssh-keygen -t ed25519` and rerun the command."
2826
+ );
2827
+ }
2828
+ if (progress) {
2829
+ progress("No account SSH key found. Selecting a local public key to register.");
2830
+ }
2831
+ let selected = candidates[0];
2832
+ if (candidates.length > 1 && process.stdin.isTTY) {
2833
+ deps.stdout.write("Select a public key to add:\n");
2834
+ candidates.forEach((candidate, index) => {
2835
+ deps.stdout.write(` ${index + 1}. ${candidate}
2836
+ `);
2837
+ });
2838
+ const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
2839
+ try {
2840
+ const choice = (await question(rl, "Choose a key [1]: ")).trim();
2841
+ if (choice) {
2842
+ const index = Number.parseInt(choice, 10) - 1;
2843
+ if (!Number.isInteger(index) || index < 0 || index >= candidates.length) {
2844
+ throw new CookbookError("Invalid SSH key selection.");
2845
+ }
2846
+ selected = candidates[index];
2847
+ }
2848
+ } finally {
2849
+ rl.close();
2850
+ }
2851
+ }
2852
+ const orderedCandidates = [selected, ...candidates.filter((candidate) => candidate !== selected)];
2853
+ const failures = [];
2854
+ for (const candidate of orderedCandidates) {
2855
+ const publicKey = import_fs3.default.readFileSync(candidate, "utf8").trim();
2856
+ if (!publicKey) {
2857
+ failures.push(`${candidate}: empty file`);
2858
+ continue;
2859
+ }
2860
+ try {
2861
+ const result = await client.addSshKey(publicKey);
2862
+ if (progress) {
2863
+ progress(`Registered SSH key from ${candidate}`);
2864
+ }
2865
+ return { configured: true, auto_added: true, selected_path: candidate, result };
2866
+ } catch (error) {
2867
+ const message = error instanceof Error ? error.message : String(error);
2868
+ failures.push(`${candidate}: ${message}`);
2869
+ }
2870
+ }
2871
+ throw new CookbookError(`Unable to register any local public SSH key. Tried: ${failures.join("; ")}`);
2715
2872
  }
2716
- async function resolveDesktopTarget(client, target) {
2717
- if (looksLikeUuid(target)) {
2718
- const status = await client.getSessionStatus(target);
2719
- return { ...status, session_id: target };
2873
+ async function resolveSecretValues(manifest, deps) {
2874
+ const values = {};
2875
+ const configuredNames = [];
2876
+ for (const secret of manifest.secrets) {
2877
+ let value = (process.env[secret.env_name] || "").trim();
2878
+ if (!value && secret.required) {
2879
+ if (!process.stdin.isTTY) {
2880
+ throw new CookbookError(
2881
+ `Secret \`${secret.env_name}\` is required for \`${manifest.slug}\`. Set it in the environment or rerun interactively.`
2882
+ );
2883
+ }
2884
+ value = (await deps.promptSecret(`${secret.prompt}: `)).trim();
2885
+ }
2886
+ if (value) {
2887
+ values[secret.env_name] = value;
2888
+ configuredNames.push(secret.env_name);
2889
+ } else if (secret.required) {
2890
+ throw new CookbookError(`Secret \`${secret.env_name}\` is required for \`${manifest.slug}\`.`);
2891
+ }
2720
2892
  }
2721
- const vm = await client.vms.get(target);
2722
- const sessionId = vm.session_id ? String(vm.session_id) : null;
2723
- if (sessionId) {
2724
- const status = await client.getSessionStatus(sessionId);
2725
- return {
2726
- ...status,
2727
- session_id: sessionId,
2728
- vm_id: status.vm_id || vm.vm_id
2729
- };
2893
+ return { values, configuredNames };
2894
+ }
2895
+ async function resolveSnapshotId(client, manifest) {
2896
+ if (!manifest.artifact) {
2897
+ throw new CookbookError("Published snapshot deploys require `artifact` config.");
2730
2898
  }
2731
- return {
2732
- session_id: null,
2733
- vm_id: vm.vm_id,
2734
- vm_status: vm.status,
2735
- vm_alive: vm.status === "active"
2899
+ const snapshots = await client.snapshots.list({ type: "system" });
2900
+ const found = snapshots.find(
2901
+ (snapshot) => snapshot.name === manifest.artifact?.snapshot_name && snapshot.status === "ready" && snapshot.is_public
2902
+ );
2903
+ if (!found?.id) {
2904
+ throw new CookbookError(`Public system snapshot \`${manifest.artifact.snapshot_name}\` was not found or is not ready.`);
2905
+ }
2906
+ return String(found.id);
2907
+ }
2908
+ function buildVmPayload(manifest, snapshotId) {
2909
+ const payload = {
2910
+ memory_mb: manifest.vm.memory_mb,
2911
+ vcpu_count: manifest.vm.vcpu_count,
2912
+ vm_lifetime_seconds: manifest.vm.timeout_seconds
2736
2913
  };
2914
+ if (manifest.vm.image_variant) {
2915
+ payload.image_variant = manifest.vm.image_variant;
2916
+ }
2917
+ if (snapshotId) {
2918
+ payload.snapshot_id = snapshotId;
2919
+ }
2920
+ return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== void 0 && value !== null));
2737
2921
  }
2738
- async function desktopPayload(client, target) {
2739
- const payload = await resolveDesktopTarget(client, target);
2740
- if (payload.vm_alive && payload.session_id) {
2922
+ function isResourceTierError(error) {
2923
+ const message = error instanceof Error ? error.message : String(error);
2924
+ const normalized = message.toLowerCase();
2925
+ return normalized.includes("free tier") && (normalized.includes("configurable cpu") || normalized.includes("configurable memory"));
2926
+ }
2927
+ function planLifetimeLimitSeconds(error) {
2928
+ const message = error instanceof Error ? error.message : String(error);
2929
+ const match = message.match(/vm lifetime cannot exceed\s+(\d+)s/i);
2930
+ if (!match) {
2931
+ return void 0;
2932
+ }
2933
+ return Number.parseInt(match[1], 10);
2934
+ }
2935
+ async function createVm(client, manifest, snapshotId, progress) {
2936
+ let payload = buildVmPayload(manifest, snapshotId);
2937
+ let adjustedForTier = false;
2938
+ let adjustedForLifetime = false;
2939
+ while (true) {
2741
2940
  try {
2742
- Object.assign(payload, await client.computerUse.viewerUrl(String(payload.session_id)));
2941
+ return await client.vms.create(payload, true);
2743
2942
  } catch (error) {
2943
+ let nextPayload = { ...payload };
2944
+ if (isResourceTierError(error) && ("memory_mb" in nextPayload || "vcpu_count" in nextPayload)) {
2945
+ nextPayload = Object.fromEntries(
2946
+ Object.entries(nextPayload).filter(([key]) => key !== "memory_mb" && key !== "vcpu_count")
2947
+ );
2948
+ if (progress && !adjustedForTier) {
2949
+ progress("Requested CPU or memory exceeds the current account tier.");
2950
+ progress("Retrying VM creation with platform defaults");
2951
+ }
2952
+ adjustedForTier = true;
2953
+ }
2954
+ const lifetimeLimit = planLifetimeLimitSeconds(error);
2955
+ const currentLifetime = typeof nextPayload.vm_lifetime_seconds === "number" ? nextPayload.vm_lifetime_seconds : void 0;
2956
+ if (lifetimeLimit && currentLifetime && currentLifetime > lifetimeLimit) {
2957
+ nextPayload.vm_lifetime_seconds = lifetimeLimit;
2958
+ if (progress && !adjustedForLifetime) {
2959
+ progress("Requested VM lifetime exceeds the current account tier.");
2960
+ progress(`Retrying VM creation with a ${lifetimeLimit}s lifetime`);
2961
+ }
2962
+ adjustedForLifetime = true;
2963
+ }
2964
+ if (JSON.stringify(nextPayload) === JSON.stringify(payload)) {
2965
+ throw error;
2966
+ }
2967
+ payload = nextPayload;
2744
2968
  }
2745
2969
  }
2746
- return payload;
2747
2970
  }
2748
- function createProgram(deps = defaultDeps) {
2971
+ async function runDeploy(manifest, client, settings, deps, progress, snapshotIdOverride) {
2972
+ preflightTools(deps);
2973
+ const sshKeyInfo = await ensureAccountSshKey(client, deps, progress);
2974
+ const { values: secretValues, configuredNames } = await resolveSecretValues(manifest, deps);
2975
+ let snapshotId = snapshotIdOverride;
2976
+ if (!snapshotId && manifest.deploy.kind === "published_snapshot") {
2977
+ if (progress) {
2978
+ progress(`Resolving public snapshot ${manifest.artifact?.snapshot_name}`);
2979
+ }
2980
+ snapshotId = await resolveSnapshotId(client, manifest);
2981
+ }
2982
+ if (progress) {
2983
+ progress("Creating VM");
2984
+ }
2985
+ const vm = await createVm(client, manifest, snapshotId, progress);
2986
+ const vmId = String(vm.vm_id || "");
2987
+ const sessionId = String(vm.session_id || "");
2988
+ if (!vmId) {
2989
+ throw new CookbookError("VM creation did not return a VM ID.");
2990
+ }
2991
+ const sshTarget = `${vmId}@${settings.sshHost}`;
2992
+ const identityFile = resolveIdentityFile(sshKeyInfo.selected_path);
2993
+ let remoteBase = `/home/appuser/${DEFAULT_REMOTE_ROOT}`;
2994
+ let remoteRoot = `${remoteBase}/${manifest.slug}`;
2995
+ let workdir = remoteWorkdir(manifest, remoteRoot);
2996
+ const connectCommand = buildConnectCommand(vmId, settings.sshHost, identityFile);
2997
+ let serviceMode = "unknown";
2998
+ let serviceName = `instavm-cookbook-${manifest.slug}`;
2999
+ try {
3000
+ if (progress) {
3001
+ progress("Waiting for SSH access");
3002
+ }
3003
+ waitForSshReady(deps, sshTarget, identityFile);
3004
+ remoteBase = resolveRemoteBase(deps, sshTarget, identityFile);
3005
+ remoteRoot = `${remoteBase}/${manifest.slug}`;
3006
+ workdir = remoteWorkdir(manifest, remoteRoot);
3007
+ if (manifest.deploy.kind === "upload_and_run") {
3008
+ const archivePath = packageSource(manifest, deps);
3009
+ try {
3010
+ const remoteTarball = `/tmp/${manifest.slug}-${Date.now()}.tar.gz`;
3011
+ if (progress) {
3012
+ progress("Uploading cookbook source");
3013
+ }
3014
+ runScp(deps, archivePath, `${sshTarget}:${remoteTarball}`, "Failed to upload the cookbook bundle to the VM.", identityFile);
3015
+ runSsh(
3016
+ deps,
3017
+ sshTarget,
3018
+ `mkdir -p ${shellEscape(remoteRoot)} && tar -xzf ${shellEscape(remoteTarball)} -C ${shellEscape(remoteRoot)} && rm -f ${shellEscape(remoteTarball)}`,
3019
+ "Failed to extract the cookbook bundle.",
3020
+ false,
3021
+ identityFile
3022
+ );
3023
+ if (progress) {
3024
+ progress("Running cookbook setup");
3025
+ }
3026
+ runSsh(deps, sshTarget, `cd ${shellEscape(workdir)} && ${manifest.source?.setup_command}`, "Cookbook setup command failed.", false, identityFile);
3027
+ } finally {
3028
+ import_fs3.default.rmSync(archivePath, { force: true });
3029
+ }
3030
+ }
3031
+ if (progress) {
3032
+ progress("Starting application service");
3033
+ }
3034
+ const started = startRemoteService(deps, sshTarget, manifest, remoteRoot, workdir, secretValues, identityFile);
3035
+ serviceName = started.serviceName;
3036
+ serviceMode = started.serviceMode;
3037
+ if (progress) {
3038
+ progress("Waiting for application healthcheck");
3039
+ }
3040
+ waitForInternalHealth(deps, sshTarget, manifest.app.port, manifest.app.healthcheck_path, manifest.app.readiness_timeout_seconds, identityFile);
3041
+ if (progress) {
3042
+ progress("Creating public share");
3043
+ }
3044
+ const share = await client.shares.create({
3045
+ vm_id: vmId,
3046
+ port: manifest.app.port,
3047
+ is_public: manifest.app.share_public_default
3048
+ });
3049
+ const shareUrl = String(share.url || "");
3050
+ if (!shareUrl) {
3051
+ throw new CookbookError("Share creation did not return a URL.");
3052
+ }
3053
+ if (progress) {
3054
+ progress("Verifying public share URL");
3055
+ }
3056
+ const healthcheckUrl = new URL(manifest.app.healthcheck_path.replace(/^\//, ""), shareUrl.endsWith("/") ? shareUrl : `${shareUrl}/`).toString();
3057
+ await verifyPublicShare(healthcheckUrl, manifest.app.readiness_timeout_seconds);
3058
+ return {
3059
+ slug: manifest.slug,
3060
+ version: manifest.version,
3061
+ deploy_kind: manifest.deploy.kind,
3062
+ vm_id: vmId,
3063
+ session_id: sessionId,
3064
+ service_name: serviceName,
3065
+ service_mode: serviceMode,
3066
+ port: manifest.app.port,
3067
+ share_url: shareUrl,
3068
+ healthcheck_url: healthcheckUrl,
3069
+ connect_command: connectCommand,
3070
+ logs_command: logsCommand(vmId, settings.sshHost, manifest.slug, serviceName, serviceMode, remoteBase, manifest.run.logs_hint, identityFile),
3071
+ destroy_command: `instavm rm ${vmId}`,
3072
+ configured_secrets: configuredNames,
3073
+ notes: manifest.post_deploy_notes
3074
+ };
3075
+ } catch (error) {
3076
+ const message = error instanceof Error ? error.message : String(error);
3077
+ throw new CookbookDeployError(message, {
3078
+ slug: manifest.slug,
3079
+ version: manifest.version,
3080
+ deploy_kind: manifest.deploy.kind,
3081
+ vm_id: vmId,
3082
+ session_id: sessionId,
3083
+ service_name: serviceName,
3084
+ service_mode: serviceMode,
3085
+ port: manifest.app.port,
3086
+ connect_command: connectCommand,
3087
+ logs_command: logsCommand(vmId, settings.sshHost, manifest.slug, serviceName, serviceMode, remoteBase, manifest.run.logs_hint, identityFile),
3088
+ destroy_command: `instavm rm ${vmId}`,
3089
+ configured_secrets: configuredNames
3090
+ });
3091
+ }
3092
+ }
3093
+ async function deployCookbook(manifest, client, settings, deps, progress) {
3094
+ return runDeploy(manifest, client, settings, deps, progress);
3095
+ }
3096
+ function packageSource(manifest, deps) {
3097
+ if (!manifest.source) {
3098
+ throw new CookbookError("Upload-and-run cookbooks require `source` config.");
3099
+ }
3100
+ const stagingRoot = import_fs3.default.mkdtempSync(import_path12.default.join(import_os2.default.tmpdir(), `${manifest.slug}-src-`));
3101
+ const archivePath = import_path12.default.join(import_os2.default.tmpdir(), `${manifest.slug}-${Date.now()}.tar.gz`);
3102
+ try {
3103
+ const excludes = /* @__PURE__ */ new Set([...DEFAULT_SOURCE_EXCLUDES, ...manifest.source.exclude]);
3104
+ copySelectedTree(manifest.path, stagingRoot, manifest.source.include, excludes);
3105
+ runCommand(deps, "tar", ["-czf", archivePath, "-C", stagingRoot, "."], "Failed to package the cookbook source.");
3106
+ } finally {
3107
+ import_fs3.default.rmSync(stagingRoot, { recursive: true, force: true });
3108
+ }
3109
+ return archivePath;
3110
+ }
3111
+ function copySelectedTree(sourceRoot, targetRoot, include, exclude) {
3112
+ const visit = (currentSource, currentRelative) => {
3113
+ const entries = import_fs3.default.readdirSync(currentSource, { withFileTypes: true });
3114
+ for (const entry of entries) {
3115
+ const relative = currentRelative ? `${currentRelative}/${entry.name}` : entry.name;
3116
+ if (!shouldInclude(relative, include, exclude)) {
3117
+ continue;
3118
+ }
3119
+ const sourcePath = import_path12.default.join(currentSource, entry.name);
3120
+ const targetPath = import_path12.default.join(targetRoot, relative);
3121
+ if (entry.isDirectory()) {
3122
+ import_fs3.default.mkdirSync(targetPath, { recursive: true });
3123
+ visit(sourcePath, relative);
3124
+ } else {
3125
+ import_fs3.default.mkdirSync(import_path12.default.dirname(targetPath), { recursive: true });
3126
+ import_fs3.default.copyFileSync(sourcePath, targetPath);
3127
+ }
3128
+ }
3129
+ };
3130
+ visit(sourceRoot, "");
3131
+ }
3132
+ function shouldInclude(relative, include, exclude) {
3133
+ const normalized = relative.replace(/\\/g, "/");
3134
+ for (const pattern of exclude) {
3135
+ if (matchesPattern(normalized, pattern)) {
3136
+ return false;
3137
+ }
3138
+ }
3139
+ if (include.length === 0) {
3140
+ return true;
3141
+ }
3142
+ return include.some((pattern) => matchesPattern(normalized, pattern) || normalized.startsWith(`${pattern.replace(/\/$/, "")}/`));
3143
+ }
3144
+ function matchesPattern(value, pattern) {
3145
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3146
+ return new RegExp(`^${escaped}$`).test(value);
3147
+ }
3148
+ function startRemoteService(deps, sshTarget, manifest, remoteRoot, workdir, secretValues, identityFile) {
3149
+ const serviceName = `instavm-cookbook-${manifest.slug}`;
3150
+ const remoteBase = remoteRoot.slice(0, remoteRoot.lastIndexOf("/"));
3151
+ const logFile = `${remoteBase}/logs/${manifest.slug}.log`;
3152
+ const environment = Object.entries(secretValues).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${key}=${shellEscape(value)}`).join(" ");
3153
+ const launchBody = `cd ${shellEscape(workdir)} && ${environment} ${manifest.run.start_command}`.trim();
3154
+ const remoteScript = `
3155
+ set -e
3156
+ mkdir -p ${shellEscape(`${remoteBase}/logs`)}
3157
+ mkdir -p ${shellEscape(remoteRoot)}
3158
+ if command -v systemd-run >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
3159
+ if systemd-run --unit ${shellEscape(serviceName)} --collect --quiet --property=WorkingDirectory=${shellEscape(workdir)} /bin/bash -lc ${shellEscape(launchBody)} >/dev/null 2>&1; then
3160
+ printf '%s' 'systemd'
3161
+ exit 0
3162
+ fi
3163
+ fi
3164
+ nohup /bin/bash -lc ${shellEscape(launchBody)} > ${shellEscape(logFile)} 2>&1 < /dev/null &
3165
+ echo $! > ${shellEscape(`${remoteBase}/logs/${manifest.slug}.pid`)}
3166
+ printf '%s' 'nohup'
3167
+ `.trim();
3168
+ const result = runSsh(deps, sshTarget, remoteScript, "Failed to start the cookbook service.", true, identityFile);
3169
+ const stdout = typeof result.stdout === "string" ? result.stdout : result.stdout?.toString("utf8") || "";
3170
+ return {
3171
+ serviceName,
3172
+ serviceMode: stdout.trim() || "nohup"
3173
+ };
3174
+ }
3175
+ function waitForInternalHealth(deps, sshTarget, port, healthcheckPath, timeoutSeconds, identityFile) {
3176
+ const url = `http://127.0.0.1:${port}${healthcheckPath}`;
3177
+ const attempts = Math.max(Math.floor(timeoutSeconds / 2), 1);
3178
+ const remoteScript = `
3179
+ set -e
3180
+ URL=${shellEscape(url)}
3181
+ for _ in $(seq 1 ${attempts}); do
3182
+ if command -v curl >/dev/null 2>&1; then
3183
+ curl -fsS "$URL" >/dev/null && exit 0
3184
+ elif command -v wget >/dev/null 2>&1; then
3185
+ wget -q -O /dev/null "$URL" && exit 0
3186
+ elif command -v python3 >/dev/null 2>&1; then
3187
+ python3 - <<'PY' && exit 0
3188
+ from urllib.request import urlopen
3189
+ with urlopen(${JSON.stringify(url)}, timeout=3) as response:
3190
+ if response.status >= 400:
3191
+ raise SystemExit(1)
3192
+ PY
3193
+ fi
3194
+ sleep 2
3195
+ done
3196
+ exit 1
3197
+ `.trim();
3198
+ runSsh(deps, sshTarget, remoteScript, "Timed out waiting for the app healthcheck.", false, identityFile);
3199
+ }
3200
+ async function verifyPublicShare(url, timeoutSeconds) {
3201
+ const deadline = Date.now() + timeoutSeconds * 1e3;
3202
+ while (Date.now() < deadline) {
3203
+ try {
3204
+ const response = await fetch(url, { redirect: "follow" });
3205
+ if (response.ok) {
3206
+ return;
3207
+ }
3208
+ } catch (error) {
3209
+ }
3210
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
3211
+ }
3212
+ throw new CookbookError(`Timed out verifying the public share URL: ${url}`);
3213
+ }
3214
+ function runSsh(deps, sshTarget, remoteCommand, errorMessage, captureOutput = false, identityFile) {
3215
+ const args = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new"];
3216
+ if (identityFile) {
3217
+ args.push("-o", "IdentitiesOnly=yes", "-i", identityFile);
3218
+ }
3219
+ args.push(sshTarget, remoteCommand);
3220
+ return runCommand(
3221
+ deps,
3222
+ "ssh",
3223
+ args,
3224
+ errorMessage,
3225
+ captureOutput
3226
+ );
3227
+ }
3228
+ function runScp(deps, localPath, remotePath, errorMessage, identityFile) {
3229
+ const args = ["-q"];
3230
+ if (identityFile) {
3231
+ args.push("-o", "IdentitiesOnly=yes", "-i", identityFile);
3232
+ }
3233
+ args.push(localPath, remotePath);
3234
+ return runCommand(deps, "scp", args, errorMessage);
3235
+ }
3236
+ function waitForSshReady(deps, sshTarget, identityFile, timeoutSeconds = 90) {
3237
+ const deadline = Date.now() + timeoutSeconds * 1e3;
3238
+ let lastError;
3239
+ while (Date.now() < deadline) {
3240
+ try {
3241
+ runSsh(deps, sshTarget, "true", "Timed out waiting for SSH access to the VM.", true, identityFile);
3242
+ return;
3243
+ } catch (error) {
3244
+ lastError = error instanceof Error ? error.message : String(error);
3245
+ }
3246
+ sleepMs(2e3);
3247
+ }
3248
+ if (lastError) {
3249
+ throw new CookbookError(`Timed out waiting for SSH access to the VM: ${sshTarget}. Last SSH error: ${lastError}`);
3250
+ }
3251
+ throw new CookbookError(`Timed out waiting for SSH access to the VM: ${sshTarget}`);
3252
+ }
3253
+ function emitCookbookDeployError(deps, options, payload) {
3254
+ if (options.json) {
3255
+ deps.stdout.write(`${JSON.stringify(payload)}
3256
+ `);
3257
+ return;
3258
+ }
3259
+ deps.stderr.write(`Deploy failed: ${payload.error}
3260
+ `);
3261
+ for (const [label, value] of [
3262
+ ["VM", payload.vm_id],
3263
+ ["Connect", payload.connect_command],
3264
+ ["Logs", payload.logs_command],
3265
+ ["Destroy", payload.destroy_command]
3266
+ ]) {
3267
+ deps.stderr.write(`${label}: ${value || "-"}
3268
+ `);
3269
+ }
3270
+ }
3271
+ function runCommand(deps, command, args, errorMessage, captureOutput = false) {
3272
+ const result = deps.spawnSync(command, args, {
3273
+ encoding: "utf8",
3274
+ stdio: captureOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "ignore", "pipe"]
3275
+ });
3276
+ if (result.error) {
3277
+ throw new CookbookError(errorMessage);
3278
+ }
3279
+ if ((result.status ?? 0) !== 0) {
3280
+ const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr || "");
3281
+ const detail = stderr.trim() ? ` ${stderr.trim()}` : "";
3282
+ throw new CookbookError(`${errorMessage}${detail}`);
3283
+ }
3284
+ return result;
3285
+ }
3286
+ function logsCommand(vmId, sshHost, slug, serviceName, serviceMode, remoteBase, logsHint, identityFile) {
3287
+ if (logsHint) {
3288
+ return logsHint;
3289
+ }
3290
+ const sshPrefix = buildSshPrefix(vmId, sshHost, identityFile);
3291
+ if (serviceMode === "systemd") {
3292
+ return `${sshPrefix} journalctl --no-pager -u ${serviceName} -n 200`;
3293
+ }
3294
+ return `${sshPrefix} tail -n 200 ${shellEscape(`${remoteBase}/logs/${slug}.log`)}`;
3295
+ }
3296
+ function remoteWorkdir(manifest, remoteRoot) {
3297
+ if (manifest.run.workdir.startsWith("/")) {
3298
+ return manifest.run.workdir;
3299
+ }
3300
+ if (manifest.run.workdir === "." || manifest.run.workdir === "./") {
3301
+ return remoteRoot;
3302
+ }
3303
+ return `${remoteRoot}/${manifest.run.workdir.replace(/^\.\//, "").replace(/\/$/, "")}`;
3304
+ }
3305
+ function resolveRemoteBase(deps, sshTarget, identityFile) {
3306
+ const result = runSsh(
3307
+ deps,
3308
+ sshTarget,
3309
+ 'printf %s "$HOME"',
3310
+ "Failed to determine the remote home directory.",
3311
+ true,
3312
+ identityFile
3313
+ );
3314
+ const stdout = typeof result.stdout === "string" ? result.stdout : result.stdout?.toString("utf8") || "";
3315
+ const remoteHome = stdout.trim() || "/tmp";
3316
+ return `${remoteHome}/${DEFAULT_REMOTE_ROOT}`;
3317
+ }
3318
+ function findExecutable(deps, binary) {
3319
+ const result = deps.spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
3320
+ encoding: "utf8",
3321
+ stdio: ["ignore", "pipe", "ignore"]
3322
+ });
3323
+ if ((result.status ?? 1) !== 0) {
3324
+ return null;
3325
+ }
3326
+ const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout || "");
3327
+ const output = stdout.split(/\r?\n/).find(Boolean);
3328
+ return output || null;
3329
+ }
3330
+ function shellEscape(value) {
3331
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
3332
+ }
3333
+ function resolveIdentityFile(selectedPath) {
3334
+ if (!selectedPath) {
3335
+ return void 0;
3336
+ }
3337
+ const candidate = selectedPath.endsWith(".pub") ? selectedPath.slice(0, -4) : selectedPath;
3338
+ return import_fs3.default.existsSync(candidate) ? candidate : void 0;
3339
+ }
3340
+ function buildSshPrefix(vmId, sshHost, identityFile) {
3341
+ if (identityFile) {
3342
+ return `ssh -o IdentitiesOnly=yes -i ${shellEscape(identityFile)} ${vmId}@${sshHost}`;
3343
+ }
3344
+ return `ssh ${vmId}@${sshHost}`;
3345
+ }
3346
+ function buildConnectCommand(vmId, sshHost, identityFile) {
3347
+ if (identityFile) {
3348
+ return buildSshPrefix(vmId, sshHost, identityFile);
3349
+ }
3350
+ return `instavm connect ${vmId}`;
3351
+ }
3352
+ function sleepMs(durationMs) {
3353
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, durationMs);
3354
+ }
3355
+ function question(rl, prompt) {
3356
+ return new Promise((resolve) => {
3357
+ rl.question(prompt, resolve);
3358
+ });
3359
+ }
3360
+
3361
+ // src/cli/detect.ts
3362
+ var import_fs4 = __toESM(require("fs"));
3363
+ var import_path13 = __toESM(require("path"));
3364
+ var NON_SECRET_VARS = /* @__PURE__ */ new Set([
3365
+ "PORT",
3366
+ "HOST",
3367
+ "NODE_ENV",
3368
+ "DEBUG",
3369
+ "PYTHONPATH",
3370
+ "PATH",
3371
+ "HOME",
3372
+ "HOSTNAME",
3373
+ "LANG",
3374
+ "TZ",
3375
+ "TERM",
3376
+ "CI",
3377
+ "VIRTUAL_ENV",
3378
+ "PWD",
3379
+ "SHELL",
3380
+ "USER",
3381
+ "LOGNAME",
3382
+ "TMPDIR",
3383
+ "PYTHONDONTWRITEBYTECODE",
3384
+ "PYTHONUNBUFFERED",
3385
+ "NPM_CONFIG_LOGLEVEL",
3386
+ "NEXT_TELEMETRY_DISABLED",
3387
+ // Model/config env vars that typically have defaults in source
3388
+ "OPENAI_MODEL",
3389
+ "GOOGLE_MODEL",
3390
+ "ANTHROPIC_MODEL",
3391
+ "OPENAI_COMPAT_API_BASE",
3392
+ "OPENAI_COMPAT_MODEL"
3393
+ ]);
3394
+ var DEP_SECRET_HINTS = {
3395
+ // Python
3396
+ "anthropic": ["ANTHROPIC_API_KEY", "Anthropic API key"],
3397
+ "openai-agents": ["OPENAI_API_KEY", "OpenAI API key"],
3398
+ "openai": ["OPENAI_API_KEY", "OpenAI API key"],
3399
+ "google-adk": ["GOOGLE_API_KEY", "Google API key"],
3400
+ "google-generativeai": ["GOOGLE_API_KEY", "Google API key"],
3401
+ "replicate": ["REPLICATE_API_TOKEN", "Replicate API token"],
3402
+ // Node
3403
+ "@anthropic-ai/claude-agent-sdk": ["ANTHROPIC_API_KEY", "Anthropic API key"],
3404
+ "@anthropic-ai/sdk": ["ANTHROPIC_API_KEY", "Anthropic API key"],
3405
+ "@openai/agents": ["OPENAI_API_KEY", "OpenAI API key"],
3406
+ "@ai-sdk/anthropic": ["ANTHROPIC_API_KEY", "Anthropic API key"],
3407
+ "@ai-sdk/openai": ["OPENAI_API_KEY", "OpenAI API key"],
3408
+ "@ai-sdk/google": ["GOOGLE_API_KEY", "Google API key"]
3409
+ };
3410
+ var PYTHON_APP_CANDIDATES = ["app.py", "main.py", "server.py", "bot.py", "start.py", "src/app.py", "src/main.py"];
3411
+ var NODE_SOURCE_CANDIDATES = [
3412
+ "server.mjs",
3413
+ "server.js",
3414
+ "server.ts",
3415
+ "index.js",
3416
+ "index.ts",
3417
+ "src/index.ts",
3418
+ "src/index.js",
3419
+ "src/server.ts",
3420
+ "src/server.js",
3421
+ "server/server.ts",
3422
+ "server/server.js"
3423
+ ];
3424
+ var GO_SOURCE_CANDIDATES = ["main.go", "server.go", "app.go", "cmd/server/main.go", "cmd/main.go"];
3425
+ var DENO_MAIN_CANDIDATES = ["main.ts", "main.js", "main.mjs", "main.mts", "server.ts", "server.js", "app.ts", "app.js"];
3426
+ var STATIC_ROOT_CANDIDATES = ["public", "dist", "build"];
3427
+ var HEALTH_ROUTE_RE = /(?:@app\.(?:get|route)|app\.get|router\.get)\s*\(\s*['"](\/(health|healthz|api\/health))['"]\s*/;
3428
+ var GO_HEALTH_RE = /(?:HandleFunc|Handle)\s*\(\s*['"](\/(health|healthz|api\/health))['"]/;
3429
+ var GO_PORT_RE = /ListenAndServe\s*\(\s*["']:(\d{2,5})["']/;
3430
+ var PORT_PATTERN_RE = /PORT\s*(?:\|\||===?\s*undefined\s*\?\s*|\?\?)\s*['"]?(\d{2,5})['"]?/;
3431
+ var PYTHON_PORT_PATTERNS = [
3432
+ /os\.(?:getenv|environ\.get)\s*\(\s*['"]PORT['"]\s*,\s*['"]?(\d{2,5})['"]?\s*\)/,
3433
+ /runserver\s+0\.0\.0\.0:(\d{2,5})/,
3434
+ /--server\.port\s+(\d{2,5})/,
3435
+ /--port\s+(\d{2,5})/
3436
+ ];
3437
+ var PYTHON_ENV_RE = /os\.(?:environ\s*(?:\.get\s*\(|(?:\[)))\s*['"]([\w]+)['"]|os\.getenv\s*\(\s*['"]([\w]+)['"]/g;
3438
+ var NODE_ENV_RE = /process\.env\.([\w]+)/g;
3439
+ var GO_ENV_RE = /os\.(?:Getenv|LookupEnv)\s*\(\s*["'](\w+)["']\)/g;
3440
+ var DENO_ENV_RE = /Deno\.env\.get\s*\(\s*["'](\w+)["']\)/g;
3441
+ var DJANGO_WSGI_RE = /WSGI_APPLICATION\s*=\s*["']([A-Za-z_][\w.]*)\.application["']/;
3442
+ var NVM_SETUP_PREFIX = 'export NVM_DIR="$HOME/.nvm" && if [ ! -s "$NVM_DIR/nvm.sh" ]; then curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash; fi && . "$NVM_DIR/nvm.sh" && nvm install {NODE_VERSION} && nvm use {NODE_VERSION} && ';
3443
+ var NVM_RUN_PREFIX = 'export NVM_DIR="$HOME/.nvm" && . "$NVM_DIR/nvm.sh" && nvm use {NODE_VERSION} >/dev/null && ';
3444
+ var COREPACK_ENABLE_PREFIX = "corepack enable >/dev/null 2>&1 && ";
3445
+ function shellQuote(value) {
3446
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
3447
+ }
3448
+ function detectProject(projectPath) {
3449
+ const resolved = import_path13.default.resolve(projectPath);
3450
+ if (!import_fs4.default.existsSync(resolved) || !import_fs4.default.statSync(resolved).isDirectory()) {
3451
+ throw new Error(`Not a directory: ${resolved}`);
3452
+ }
3453
+ if (import_fs4.default.existsSync(import_path13.default.join(resolved, "package.json"))) {
3454
+ return detectNode(resolved);
3455
+ }
3456
+ if (import_fs4.default.existsSync(import_path13.default.join(resolved, "requirements.txt")) || import_fs4.default.existsSync(import_path13.default.join(resolved, "pyproject.toml")) || import_fs4.default.existsSync(import_path13.default.join(resolved, "Pipfile"))) {
3457
+ return detectPython(resolved);
3458
+ }
3459
+ if (import_fs4.default.existsSync(import_path13.default.join(resolved, "go.mod")) || import_fs4.default.existsSync(import_path13.default.join(resolved, "main.go"))) {
3460
+ return detectGo(resolved);
3461
+ }
3462
+ if (import_fs4.default.existsSync(import_path13.default.join(resolved, "deno.json")) || import_fs4.default.existsSync(import_path13.default.join(resolved, "deno.jsonc"))) {
3463
+ return detectDeno(resolved);
3464
+ }
3465
+ if (hasStaticSignals(resolved)) {
3466
+ return detectStatic(resolved);
3467
+ }
3468
+ throw new Error(
3469
+ "Could not detect project type. Expected package.json, requirements.txt, pyproject.toml, Pipfile, go.mod, deno.json, or index.html."
3470
+ );
3471
+ }
3472
+ function planToManifest(plan, projectPath) {
3473
+ let setupCommand = plan.installCommand;
3474
+ if (plan.buildCommand) {
3475
+ setupCommand += ` && ${plan.buildCommand}`;
3476
+ }
3477
+ let startCommand = plan.startCommand;
3478
+ if (plan.runtime === "node") {
3479
+ const nodeVersion = detectNodeVersion(projectPath);
3480
+ setupCommand = NVM_SETUP_PREFIX.replace(/\{NODE_VERSION\}/g, nodeVersion) + setupCommand;
3481
+ startCommand = NVM_RUN_PREFIX.replace(/\{NODE_VERSION\}/g, nodeVersion) + startCommand;
3482
+ }
3483
+ return {
3484
+ path: import_path13.default.resolve(projectPath),
3485
+ schema_version: 1,
3486
+ slug: plan.slug,
3487
+ title: plan.slug,
3488
+ version: "0.0.0",
3489
+ summary: "",
3490
+ category: "deploy",
3491
+ runtime: plan.runtime,
3492
+ deploy: { kind: "upload_and_run" },
3493
+ vm: {
3494
+ memory_mb: 2048,
3495
+ vcpu_count: 2,
3496
+ timeout_seconds: 3600
3497
+ },
3498
+ app: {
3499
+ port: plan.port,
3500
+ healthcheck_path: plan.healthPath,
3501
+ share_public_default: true,
3502
+ readiness_timeout_seconds: 120
3503
+ },
3504
+ run: {
3505
+ workdir: ".",
3506
+ start_command: startCommand
3507
+ },
3508
+ secrets: plan.secrets,
3509
+ post_deploy_notes: [],
3510
+ source: {
3511
+ include: ["*"],
3512
+ exclude: plan.runtime === "static" ? [] : ["dist", "dist/*", "build", "build/*"],
3513
+ setup_command: setupCommand
3514
+ }
3515
+ };
3516
+ }
3517
+ function detectNode(projectPath) {
3518
+ const pkgPath = import_path13.default.join(projectPath, "package.json");
3519
+ let pkg;
3520
+ try {
3521
+ pkg = JSON.parse(import_fs4.default.readFileSync(pkgPath, "utf-8"));
3522
+ } catch (err) {
3523
+ throw new Error(`Cannot read package.json: ${err}`);
3524
+ }
3525
+ const pm = detectNodePm(projectPath, pkg);
3526
+ const scripts = pkg.scripts || {};
3527
+ const deps = readNodeDeps(pkg);
3528
+ let installCommand;
3529
+ if (pm === "npm" && import_fs4.default.existsSync(import_path13.default.join(projectPath, "package-lock.json"))) {
3530
+ installCommand = "npm ci";
3531
+ } else if (pm === "yarn") {
3532
+ installCommand = wrapNodePmCommand(pm, isYarnBerry(projectPath, pkg) ? "yarn install --check-cache" : "yarn install --frozen-lockfile");
3533
+ } else if (pm === "pnpm") {
3534
+ installCommand = wrapNodePmCommand(pm, "pnpm install");
3535
+ } else {
3536
+ installCommand = `${pm} install`;
3537
+ }
3538
+ let buildCommand = null;
3539
+ if (scripts.build) {
3540
+ buildCommand = wrapNodePmCommand(pm, `${pm} run build`);
3541
+ }
3542
+ let startCommand = null;
3543
+ if (scripts.start) {
3544
+ startCommand = wrapNodePmCommand(pm, `${pm} start`);
3545
+ } else if (scripts.serve) {
3546
+ startCommand = wrapNodePmCommand(pm, `${pm} run serve`);
3547
+ } else if (pkg.main) {
3548
+ startCommand = `node ${shellQuote(String(pkg.main))}`;
3549
+ }
3550
+ if (!startCommand) {
3551
+ throw new Error(
3552
+ "Could not detect start command. Add a 'start' script to package.json or use --start-command."
3553
+ );
3554
+ }
3555
+ let port = detectPortFromStartScript(scripts.start || "");
3556
+ if (port == null) {
3557
+ port = detectPortFromSource(projectPath, NODE_SOURCE_CANDIDATES);
3558
+ }
3559
+ if (port == null) {
3560
+ port = 3e3;
3561
+ }
3562
+ return {
3563
+ slug: slugify(pkg.name || import_path13.default.basename(projectPath)),
3564
+ runtime: "node",
3565
+ packageManager: pm,
3566
+ installCommand,
3567
+ buildCommand,
3568
+ startCommand,
3569
+ port,
3570
+ healthPath: detectHealthPath(projectPath),
3571
+ secrets: collectSecrets(projectPath, deps, "node")
3572
+ };
3573
+ }
3574
+ function detectNodePm(projectPath, pkg) {
3575
+ const pmField = String(pkg?.packageManager || "").trim();
3576
+ if (pmField) {
3577
+ const pmName = pmField.split("@")[0].trim().toLowerCase();
3578
+ if (["pnpm", "yarn", "bun", "npm"].includes(pmName)) return pmName;
3579
+ }
3580
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "pnpm-lock.yaml"))) return "pnpm";
3581
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "yarn.lock"))) return "yarn";
3582
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "bun.lockb")) || import_fs4.default.existsSync(import_path13.default.join(projectPath, "bun.lock"))) return "bun";
3583
+ return "npm";
3584
+ }
3585
+ function isYarnBerry(projectPath, pkg) {
3586
+ const pmField = String(pkg?.packageManager || "").trim();
3587
+ if (pmField.toLowerCase().startsWith("yarn@")) {
3588
+ const versionPart = pmField.split("@")[1].split("+")[0];
3589
+ const major = versionPart.split(".")[0];
3590
+ if (/^\d+$/.test(major) && parseInt(major, 10) >= 2) return true;
3591
+ }
3592
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, ".yarnrc.yml")) || import_fs4.default.existsSync(import_path13.default.join(projectPath, ".yarnrc.yaml"))) return true;
3593
+ return false;
3594
+ }
3595
+ function wrapNodePmCommand(pm, command) {
3596
+ if (pm === "yarn" || pm === "pnpm") {
3597
+ return `${COREPACK_ENABLE_PREFIX}${command}`;
3598
+ }
3599
+ return command;
3600
+ }
3601
+ function readNodeDeps(pkg) {
3602
+ const deps = [];
3603
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
3604
+ if (pkg[section]) {
3605
+ deps.push(...Object.keys(pkg[section]));
3606
+ }
3607
+ }
3608
+ return deps;
3609
+ }
3610
+ function detectNodeVersion(projectPath) {
3611
+ const pkgPath = import_path13.default.join(projectPath, "package.json");
3612
+ try {
3613
+ const pkg = JSON.parse(import_fs4.default.readFileSync(pkgPath, "utf-8"));
3614
+ const nodeConstraint = pkg?.engines?.node || "";
3615
+ const m = nodeConstraint.match(/(\d+)/);
3616
+ if (m) return m[1];
3617
+ } catch {
3618
+ }
3619
+ return "22";
3620
+ }
3621
+ function detectPython(projectPath) {
3622
+ const deps = readPythonDeps(projectPath);
3623
+ const pm = detectPythonPm(projectPath);
3624
+ let installCommand;
3625
+ if (pm === "poetry") {
3626
+ installCommand = "poetry install";
3627
+ } else if (pm === "uv") {
3628
+ installCommand = "uv sync";
3629
+ } else if (pm === "pdm") {
3630
+ installCommand = "pdm install";
3631
+ } else if (pm === "pipenv") {
3632
+ installCommand = import_fs4.default.existsSync(import_path13.default.join(projectPath, "Pipfile.lock")) ? "pipenv install --deploy" : "pipenv install";
3633
+ } else if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "requirements.txt"))) {
3634
+ installCommand = "pip install -r requirements.txt";
3635
+ } else {
3636
+ installCommand = "pip install .";
3637
+ }
3638
+ const depNamesLower = new Set(
3639
+ deps.map((d) => d.toLowerCase().split("==")[0].split(">=")[0].split("[")[0].trim())
3640
+ );
3641
+ const appModule = findPythonAppModule(projectPath);
3642
+ let startCommand = null;
3643
+ const port = detectPythonPort(projectPath);
3644
+ if (depNamesLower.has("django") && import_fs4.default.existsSync(import_path13.default.join(projectPath, "manage.py"))) {
3645
+ const wsgiModule = findDjangoWsgi(projectPath);
3646
+ if (depNamesLower.has("gunicorn")) {
3647
+ startCommand = `python manage.py migrate && gunicorn ${wsgiModule}:application -b 0.0.0.0:${port}`;
3648
+ } else {
3649
+ startCommand = `python manage.py migrate && python manage.py runserver 0.0.0.0:${port}`;
3650
+ }
3651
+ } else if (depNamesLower.has("uvicorn") || depNamesLower.has("python-fasthtml") || depNamesLower.has("fasthtml")) {
3652
+ startCommand = `python -m uvicorn ${appModule}:app --host 0.0.0.0 --port ${port}`;
3653
+ } else if (depNamesLower.has("gunicorn")) {
3654
+ startCommand = `gunicorn ${appModule}:app -b 0.0.0.0:${port}`;
3655
+ } else if (depNamesLower.has("flask")) {
3656
+ startCommand = `python -m flask run --host 0.0.0.0 --port ${port}`;
3657
+ } else if (depNamesLower.has("streamlit")) {
3658
+ const entry = findStreamlitEntry(projectPath);
3659
+ startCommand = `python -m streamlit run ${entry} --server.port ${port} --server.address 0.0.0.0`;
3660
+ }
3661
+ if (!startCommand) {
3662
+ throw new Error(
3663
+ "Could not detect start command. No django, uvicorn, gunicorn, flask, fasthtml, or streamlit found in dependencies. Use --start-command to specify how to start the app."
3664
+ );
3665
+ }
3666
+ return {
3667
+ slug: slugify(import_path13.default.basename(projectPath)),
3668
+ runtime: "python",
3669
+ packageManager: pm,
3670
+ installCommand,
3671
+ buildCommand: null,
3672
+ startCommand,
3673
+ port,
3674
+ healthPath: detectHealthPath(projectPath),
3675
+ secrets: collectSecrets(projectPath, [...depNamesLower], "python")
3676
+ };
3677
+ }
3678
+ function detectPythonPm(projectPath) {
3679
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "uv.lock"))) return "uv";
3680
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "pdm.lock"))) return "pdm";
3681
+ const pyprojectPath = import_path13.default.join(projectPath, "pyproject.toml");
3682
+ if (import_fs4.default.existsSync(pyprojectPath)) {
3683
+ try {
3684
+ const text = import_fs4.default.readFileSync(pyprojectPath, "utf-8");
3685
+ if (text.includes("[tool.uv]")) return "uv";
3686
+ if (text.includes("[tool.poetry]")) return "poetry";
3687
+ } catch {
3688
+ }
3689
+ }
3690
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "Pipfile")) || import_fs4.default.existsSync(import_path13.default.join(projectPath, "Pipfile.lock"))) return "pipenv";
3691
+ return "pip";
3692
+ }
3693
+ function readPythonDeps(projectPath) {
3694
+ const reqPath = import_path13.default.join(projectPath, "requirements.txt");
3695
+ if (import_fs4.default.existsSync(reqPath)) {
3696
+ try {
3697
+ const lines = import_fs4.default.readFileSync(reqPath, "utf-8").split("\n");
3698
+ const deps = lines.map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
3699
+ if (deps.length > 0) return deps;
3700
+ } catch {
3701
+ }
3702
+ }
3703
+ const pyprojectPath = import_path13.default.join(projectPath, "pyproject.toml");
3704
+ if (import_fs4.default.existsSync(pyprojectPath)) {
3705
+ try {
3706
+ const text = import_fs4.default.readFileSync(pyprojectPath, "utf-8");
3707
+ return parsePyprojectDeps(text);
3708
+ } catch {
3709
+ }
3710
+ }
3711
+ const pipfilePath = import_path13.default.join(projectPath, "Pipfile");
3712
+ if (import_fs4.default.existsSync(pipfilePath)) {
3713
+ return parsePipfileDeps(pipfilePath);
3714
+ }
3715
+ return [];
3716
+ }
3717
+ function parsePyprojectDeps(text) {
3718
+ const deps = [];
3719
+ const inlineMatch = text.match(/(?<![a-z-])dependencies\s*=\s*\[([^\]]*)\]/);
3720
+ if (inlineMatch) {
3721
+ for (const item of inlineMatch[1].split(",")) {
3722
+ const cleaned = item.trim().replace(/^["']|["']$/g, "").trim();
3723
+ if (cleaned) deps.push(cleaned);
3724
+ }
3725
+ }
3726
+ const lines = text.split("\n");
3727
+ let inPoetryDeps = false;
3728
+ for (const line of lines) {
3729
+ const stripped = line.trim();
3730
+ if (stripped === "[tool.poetry.dependencies]") {
3731
+ inPoetryDeps = true;
3732
+ continue;
3733
+ }
3734
+ if (inPoetryDeps) {
3735
+ if (stripped.startsWith("[")) break;
3736
+ if (stripped.includes("=") && !stripped.startsWith("#")) {
3737
+ const key = stripped.split("=")[0].trim();
3738
+ if (key && key !== "python") deps.push(key);
3739
+ }
3740
+ }
3741
+ }
3742
+ return deps;
3743
+ }
3744
+ function parsePipfileDeps(pipfilePath) {
3745
+ const deps = [];
3746
+ let inPackages = false;
3747
+ try {
3748
+ const text = import_fs4.default.readFileSync(pipfilePath, "utf-8");
3749
+ for (const line of text.split("\n")) {
3750
+ const stripped = line.trim();
3751
+ if (stripped === "[packages]") {
3752
+ inPackages = true;
3753
+ continue;
3754
+ }
3755
+ if (inPackages) {
3756
+ if (stripped.startsWith("[")) break;
3757
+ if (stripped.includes("=") && !stripped.startsWith("#")) {
3758
+ const key = stripped.split("=")[0].trim();
3759
+ if (key) deps.push(key);
3760
+ }
3761
+ }
3762
+ }
3763
+ } catch {
3764
+ }
3765
+ return deps;
3766
+ }
3767
+ function findPythonAppModule(projectPath) {
3768
+ for (const candidate of PYTHON_APP_CANDIDATES) {
3769
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, candidate))) {
3770
+ return candidate.replace(/\//g, ".").replace(/\.py$/, "");
3771
+ }
3772
+ }
3773
+ return "app";
3774
+ }
3775
+ function findStreamlitEntry(projectPath) {
3776
+ for (const name of ["app.py", "main.py", "streamlit_app.py"]) {
3777
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, name))) return name;
3778
+ }
3779
+ return "app.py";
3780
+ }
3781
+ function findDjangoWsgi(projectPath) {
3782
+ const entries = import_fs4.default.readdirSync(projectPath, { withFileTypes: true });
3783
+ for (const entry of entries) {
3784
+ if (entry.isDirectory()) {
3785
+ const settingsPath = import_path13.default.join(projectPath, entry.name, "settings.py");
3786
+ if (import_fs4.default.existsSync(settingsPath)) {
3787
+ try {
3788
+ const text = import_fs4.default.readFileSync(settingsPath, "utf-8");
3789
+ const m = text.match(DJANGO_WSGI_RE);
3790
+ if (m) return m[1];
3791
+ } catch {
3792
+ continue;
3793
+ }
3794
+ }
3795
+ }
3796
+ }
3797
+ return `${import_path13.default.basename(projectPath)}.wsgi`;
3798
+ }
3799
+ function detectGo(projectPath) {
3800
+ let buildTarget = ".";
3801
+ const rootGoFiles = import_fs4.default.readdirSync(projectPath).filter((f) => f.endsWith(".go"));
3802
+ if (rootGoFiles.length > 0) {
3803
+ buildTarget = ".";
3804
+ } else {
3805
+ const cmdDir = import_path13.default.join(projectPath, "cmd");
3806
+ if (import_fs4.default.existsSync(cmdDir) && import_fs4.default.statSync(cmdDir).isDirectory()) {
3807
+ const subdirs = import_fs4.default.readdirSync(cmdDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
3808
+ if (subdirs.length > 0) {
3809
+ buildTarget = `./cmd/${subdirs[0]}`;
3810
+ }
3811
+ }
3812
+ }
3813
+ const goVersion = detectGoVersion(projectPath);
3814
+ const buildCommand = `export GOROOT="$HOME/.local/go" && export PATH="$GOROOT/bin:$PATH" && CGO_ENABLED=0 go build -ldflags='-w -s' -o app ${buildTarget}`;
3815
+ const installCommand = `GO_VERSION="${goVersion}" && case "$GO_VERSION" in *.*.*) ;; *) GO_VERSION="$GO_VERSION.0" ;; esac && export GOROOT="$HOME/.local/go" && export PATH="$GOROOT/bin:$PATH" && if [ ! -x "$GOROOT/bin/go" ] || ! "$GOROOT/bin/go" version | grep -q "go$GO_VERSION"; then rm -rf "$GOROOT" && mkdir -p "$HOME/.local" && curl -fsSL "https://dl.google.com/go/go$GO_VERSION.linux-amd64.tar.gz" -o /tmp/instavm-go.tgz && tar -C "$HOME/.local" -xzf /tmp/instavm-go.tgz; fi && go mod download`;
3816
+ const port = detectGoPort(projectPath);
3817
+ const healthPath = detectGoHealthPath(projectPath);
3818
+ const secrets = collectSecrets(projectPath, [], "go");
3819
+ return {
3820
+ slug: slugify(import_path13.default.basename(projectPath)),
3821
+ runtime: "go",
3822
+ packageManager: "go",
3823
+ installCommand,
3824
+ buildCommand,
3825
+ startCommand: "./app",
3826
+ port,
3827
+ healthPath,
3828
+ secrets
3829
+ };
3830
+ }
3831
+ function detectGoPort(projectPath) {
3832
+ for (const candidate of GO_SOURCE_CANDIDATES) {
3833
+ const full = import_path13.default.join(projectPath, candidate);
3834
+ if (!import_fs4.default.existsSync(full)) continue;
3835
+ try {
3836
+ const text = import_fs4.default.readFileSync(full, "utf-8");
3837
+ const m = text.match(GO_PORT_RE);
3838
+ if (m) return parseInt(m[1], 10);
3839
+ const m2 = text.match(PORT_PATTERN_RE);
3840
+ if (m2) return parseInt(m2[1], 10);
3841
+ } catch {
3842
+ continue;
3843
+ }
3844
+ }
3845
+ try {
3846
+ for (const f of import_fs4.default.readdirSync(projectPath)) {
3847
+ if (!f.endsWith(".go")) continue;
3848
+ const text = import_fs4.default.readFileSync(import_path13.default.join(projectPath, f), "utf-8");
3849
+ const m = text.match(GO_PORT_RE);
3850
+ if (m) return parseInt(m[1], 10);
3851
+ }
3852
+ } catch {
3853
+ }
3854
+ return 8080;
3855
+ }
3856
+ function detectGoVersion(projectPath) {
3857
+ const goModPath = import_path13.default.join(projectPath, "go.mod");
3858
+ if (!import_fs4.default.existsSync(goModPath)) return "1.23.0";
3859
+ try {
3860
+ const text = import_fs4.default.readFileSync(goModPath, "utf-8");
3861
+ const match = text.match(/^go\s+([0-9]+(?:\.[0-9]+){1,2})\s*$/m);
3862
+ return match?.[1] || "1.23.0";
3863
+ } catch {
3864
+ return "1.23.0";
3865
+ }
3866
+ }
3867
+ function detectGoHealthPath(projectPath) {
3868
+ for (const candidate of GO_SOURCE_CANDIDATES) {
3869
+ const full = import_path13.default.join(projectPath, candidate);
3870
+ if (!import_fs4.default.existsSync(full)) continue;
3871
+ try {
3872
+ const text = import_fs4.default.readFileSync(full, "utf-8");
3873
+ const m = text.match(GO_HEALTH_RE);
3874
+ if (m) return m[1];
3875
+ } catch {
3876
+ continue;
3877
+ }
3878
+ }
3879
+ try {
3880
+ for (const f of import_fs4.default.readdirSync(projectPath)) {
3881
+ if (!f.endsWith(".go")) continue;
3882
+ const text = import_fs4.default.readFileSync(import_path13.default.join(projectPath, f), "utf-8");
3883
+ const m = text.match(GO_HEALTH_RE);
3884
+ if (m) return m[1];
3885
+ }
3886
+ } catch {
3887
+ }
3888
+ return "/";
3889
+ }
3890
+ function detectDeno(projectPath) {
3891
+ const mainFile = findDenoMain(projectPath);
3892
+ if (!mainFile) {
3893
+ throw new Error(
3894
+ "Could not detect start command. No main.ts, main.js, server.ts, server.js, app.ts, or app.js found. Use --start-command to specify how to start the app."
3895
+ );
3896
+ }
3897
+ let port = 8e3;
3898
+ const full = import_path13.default.join(projectPath, mainFile);
3899
+ if (import_fs4.default.existsSync(full)) {
3900
+ try {
3901
+ const text = import_fs4.default.readFileSync(full, "utf-8");
3902
+ const m = text.match(/port:\s*(\d{2,5})/);
3903
+ if (m) {
3904
+ port = parseInt(m[1], 10);
3905
+ } else {
3906
+ const m2 = text.match(PORT_PATTERN_RE);
3907
+ if (m2) port = parseInt(m2[1], 10);
3908
+ }
3909
+ } catch {
3910
+ }
3911
+ }
3912
+ const secrets = collectSecrets(projectPath, [], "deno");
3913
+ return {
3914
+ slug: slugify(import_path13.default.basename(projectPath)),
3915
+ runtime: "deno",
3916
+ packageManager: "deno",
3917
+ installCommand: "curl -fsSL https://deno.land/install.sh | sh",
3918
+ buildCommand: null,
3919
+ startCommand: `$HOME/.deno/bin/deno run --allow-all ${mainFile}`,
3920
+ port,
3921
+ healthPath: "/",
3922
+ secrets
3923
+ };
3924
+ }
3925
+ function findDenoMain(projectPath) {
3926
+ for (const candidate of DENO_MAIN_CANDIDATES) {
3927
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, candidate))) return candidate;
3928
+ }
3929
+ return null;
3930
+ }
3931
+ function hasStaticSignals(projectPath) {
3932
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "index.html"))) return true;
3933
+ return STATIC_ROOT_CANDIDATES.some(
3934
+ (d) => import_fs4.default.existsSync(import_path13.default.join(projectPath, d, "index.html"))
3935
+ );
3936
+ }
3937
+ function findStaticRoot(projectPath) {
3938
+ for (const candidate of STATIC_ROOT_CANDIDATES) {
3939
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, candidate, "index.html"))) return candidate;
3940
+ }
3941
+ if (import_fs4.default.existsSync(import_path13.default.join(projectPath, "index.html"))) return ".";
3942
+ throw new Error(
3943
+ "Could not detect project type. Expected package.json, requirements.txt, pyproject.toml, Pipfile, go.mod, deno.json, or index.html."
3944
+ );
3945
+ }
3946
+ function detectStatic(projectPath) {
3947
+ const rootDir = findStaticRoot(projectPath);
3948
+ const port = 3e3;
3949
+ return {
3950
+ slug: slugify(import_path13.default.basename(projectPath)),
3951
+ runtime: "static",
3952
+ packageManager: "none",
3953
+ installCommand: "echo 'No dependencies'",
3954
+ buildCommand: null,
3955
+ startCommand: `python3 -m http.server ${port} --directory ${rootDir} --bind 0.0.0.0`,
3956
+ port,
3957
+ healthPath: "/",
3958
+ secrets: []
3959
+ };
3960
+ }
3961
+ function detectPortFromStartScript(script) {
3962
+ const m1 = script.match(/--port\s+(\d+)/);
3963
+ if (m1) return parseInt(m1[1], 10);
3964
+ const m2 = script.match(/-p\s+(\d+)/);
3965
+ if (m2) return parseInt(m2[1], 10);
3966
+ return null;
3967
+ }
3968
+ function detectPortFromSource(projectPath, candidates) {
3969
+ for (const candidate of candidates) {
3970
+ const full = import_path13.default.join(projectPath, candidate);
3971
+ if (!import_fs4.default.existsSync(full)) continue;
3972
+ try {
3973
+ const text = import_fs4.default.readFileSync(full, "utf-8");
3974
+ const m = text.match(PORT_PATTERN_RE);
3975
+ if (m) return parseInt(m[1], 10);
3976
+ } catch {
3977
+ continue;
3978
+ }
3979
+ }
3980
+ return null;
3981
+ }
3982
+ function detectPythonPort(projectPath) {
3983
+ const visit = (dir) => {
3984
+ let entries;
3985
+ try {
3986
+ entries = import_fs4.default.readdirSync(dir, { withFileTypes: true });
3987
+ } catch {
3988
+ return null;
3989
+ }
3990
+ for (const entry of entries) {
3991
+ const full = import_path13.default.join(dir, entry.name);
3992
+ if (entry.isDirectory()) {
3993
+ if (shouldSkipFile(full, projectPath)) continue;
3994
+ const nested = visit(full);
3995
+ if (nested != null) return nested;
3996
+ continue;
3997
+ }
3998
+ if (!entry.isFile() || !entry.name.endsWith(".py")) continue;
3999
+ if (shouldSkipFile(full, projectPath)) continue;
4000
+ try {
4001
+ const text = import_fs4.default.readFileSync(full, "utf-8");
4002
+ for (const pattern of PYTHON_PORT_PATTERNS) {
4003
+ const match = text.match(pattern);
4004
+ if (match) return parseInt(match[1], 10);
4005
+ }
4006
+ } catch {
4007
+ continue;
4008
+ }
4009
+ }
4010
+ return null;
4011
+ };
4012
+ return visit(projectPath) ?? 8e3;
4013
+ }
4014
+ function detectHealthPath(projectPath) {
4015
+ const allCandidates = [...PYTHON_APP_CANDIDATES, ...NODE_SOURCE_CANDIDATES];
4016
+ for (const candidate of allCandidates) {
4017
+ const full = import_path13.default.join(projectPath, candidate);
4018
+ if (!import_fs4.default.existsSync(full)) continue;
4019
+ try {
4020
+ const text = import_fs4.default.readFileSync(full, "utf-8");
4021
+ const m = text.match(HEALTH_ROUTE_RE);
4022
+ if (m) return m[1];
4023
+ } catch {
4024
+ continue;
4025
+ }
4026
+ }
4027
+ return "/health";
4028
+ }
4029
+ function collectSecrets(projectPath, deps, runtime) {
4030
+ const seen = /* @__PURE__ */ new Set();
4031
+ const secrets = [];
4032
+ for (const dep of deps) {
4033
+ const depKey = dep.toLowerCase().split("==")[0].split(">=")[0].split("[")[0].trim();
4034
+ const hint = DEP_SECRET_HINTS[depKey];
4035
+ if (hint && !seen.has(hint[0])) {
4036
+ seen.add(hint[0]);
4037
+ secrets.push({ name: hint[0], prompt: hint[1], env_name: hint[0], required: true });
4038
+ }
4039
+ }
4040
+ const scanned = scanEnvVars(projectPath, runtime);
4041
+ for (const varName of [...scanned].sort()) {
4042
+ if (!seen.has(varName) && !NON_SECRET_VARS.has(varName)) {
4043
+ seen.add(varName);
4044
+ secrets.push({ name: varName, prompt: varName, env_name: varName, required: false });
4045
+ }
4046
+ }
4047
+ return secrets;
4048
+ }
4049
+ function scanEnvVars(projectPath, runtime) {
4050
+ const found = /* @__PURE__ */ new Set();
4051
+ let extensions;
4052
+ let pattern;
4053
+ if (runtime === "python") {
4054
+ extensions = [".py"];
4055
+ pattern = PYTHON_ENV_RE;
4056
+ } else if (runtime === "go") {
4057
+ extensions = [".go"];
4058
+ pattern = GO_ENV_RE;
4059
+ } else if (runtime === "deno") {
4060
+ extensions = [".ts", ".js", ".mjs", ".mts"];
4061
+ pattern = DENO_ENV_RE;
4062
+ } else {
4063
+ extensions = [".js", ".ts", ".mjs", ".tsx", ".jsx"];
4064
+ pattern = NODE_ENV_RE;
4065
+ }
4066
+ const scanDir = (dir) => {
4067
+ let entries;
4068
+ try {
4069
+ entries = import_fs4.default.readdirSync(dir, { withFileTypes: true });
4070
+ } catch {
4071
+ return;
4072
+ }
4073
+ for (const entry of entries) {
4074
+ const full = import_path13.default.join(dir, entry.name);
4075
+ if (entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) {
4076
+ if (shouldSkipFile(full, projectPath)) continue;
4077
+ try {
4078
+ const text = import_fs4.default.readFileSync(full, "utf-8");
4079
+ let m;
4080
+ const re = new RegExp(pattern.source, pattern.flags);
4081
+ while ((m = re.exec(text)) !== null) {
4082
+ const varName = m[1] || m[2];
4083
+ if (varName) found.add(varName);
4084
+ }
4085
+ } catch {
4086
+ continue;
4087
+ }
4088
+ }
4089
+ }
4090
+ };
4091
+ scanDir(projectPath);
4092
+ for (const subdir of ["src", "server", "api", "lib", "cmd"]) {
4093
+ const sub = import_path13.default.join(projectPath, subdir);
4094
+ if (import_fs4.default.existsSync(sub) && import_fs4.default.statSync(sub).isDirectory()) {
4095
+ scanDir(sub);
4096
+ }
4097
+ }
4098
+ return found;
4099
+ }
4100
+ function shouldSkipFile(filePath, projectRoot) {
4101
+ const rel = import_path13.default.relative(projectRoot, filePath);
4102
+ const parts = rel.split(import_path13.default.sep);
4103
+ const skipDirs = /* @__PURE__ */ new Set(["node_modules", ".venv", "venv", "__pycache__", ".git", "dist", "build", ".next"]);
4104
+ return parts.some((p) => skipDirs.has(p));
4105
+ }
4106
+ function slugify(name) {
4107
+ const slug = name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4108
+ return slug || "app";
4109
+ }
4110
+
4111
+ // src/cli.ts
4112
+ var defaultDeps = {
4113
+ stdout: process.stdout,
4114
+ stderr: process.stderr,
4115
+ spawnSync: import_child_process.spawnSync,
4116
+ resolveRuntimeConfig,
4117
+ updateProfileSettings,
4118
+ clientFactory: (apiKey, options) => new InstaVM(apiKey, options),
4119
+ promptSecret,
4120
+ readStdin,
4121
+ writeFile: (outputPath, content) => {
4122
+ import_fs5.default.writeFileSync(outputPath, content);
4123
+ }
4124
+ };
4125
+ var ACTIVE_VM_STATUSES = /* @__PURE__ */ new Set(["active", "running", "creating"]);
4126
+ var INTERACTIVE_PROGRESS_PREFIXES = ["Resolving public snapshot ", "Reusing saved snapshot ", "Retrying VM creation "];
4127
+ var INTERACTIVE_PROGRESS_STEPS = /* @__PURE__ */ new Set([
4128
+ "Creating VM",
4129
+ "Waiting for SSH access",
4130
+ "Uploading cookbook source",
4131
+ "Uploading source",
4132
+ "Running cookbook setup",
4133
+ "Running setup",
4134
+ "Starting application service",
4135
+ "Starting service",
4136
+ "Waiting for application healthcheck",
4137
+ "Waiting for healthcheck",
4138
+ "Creating public share",
4139
+ "Verifying public share URL",
4140
+ "Detecting project",
4141
+ "Creating snapshot"
4142
+ ]);
4143
+ function printJson(deps, payload) {
4144
+ deps.stdout.write(`${JSON.stringify(payload)}
4145
+ `);
4146
+ }
4147
+ function printLines(deps, lines) {
4148
+ for (const line of lines) {
4149
+ deps.stdout.write(`${line}
4150
+ `);
4151
+ }
4152
+ }
4153
+ function printErrorLines(deps, lines) {
4154
+ for (const line of lines) {
4155
+ deps.stderr.write(`${line}
4156
+ `);
4157
+ }
4158
+ }
4159
+ function supportsAnsi(stream) {
4160
+ return Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
4161
+ }
4162
+ function styleText(text, codes, enabled) {
4163
+ if (!enabled || codes.length === 0) {
4164
+ return text;
4165
+ }
4166
+ return `\x1B[${codes.join(";")}m${text}\x1B[0m`;
4167
+ }
4168
+ function titleCaseLabel(value) {
4169
+ return value.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
4170
+ }
4171
+ function displayValue(value, fallback = "-") {
4172
+ if (value === null || value === void 0) {
4173
+ return fallback;
4174
+ }
4175
+ const text = String(value).trim();
4176
+ return text || fallback;
4177
+ }
4178
+ function formatTable(headers, rows) {
4179
+ const widths = headers.map((header) => header.length);
4180
+ const normalizedRows = rows.map((row) => row.map((cell) => displayValue(cell)));
4181
+ for (const row of normalizedRows) {
4182
+ row.forEach((cell, index) => {
4183
+ widths[index] = Math.max(widths[index], cell.length);
4184
+ });
4185
+ }
4186
+ const formatRow = (row) => ` ${row.map((cell, index) => cell.padEnd(widths[index], " ")).join(" ")}`;
4187
+ const separator = ` ${widths.map((width) => "-".repeat(width)).join(" ")}`;
4188
+ return [formatRow(headers), separator, ...normalizedRows.map((row) => formatRow(row))];
4189
+ }
4190
+ function formatCookbookList(cookbooks) {
4191
+ if (cookbooks.length === 0) {
4192
+ return ["No cookbooks found."];
4193
+ }
4194
+ const categoryOrder = /* @__PURE__ */ new Map([
4195
+ ["web", 0],
4196
+ ["graphics", 1],
4197
+ ["agents", 2]
4198
+ ]);
4199
+ const sorted = [...cookbooks].sort((left, right) => {
4200
+ const leftCategory = String(left.category || "other");
4201
+ const rightCategory = String(right.category || "other");
4202
+ const categoryRankCompare = (categoryOrder.get(leftCategory) ?? 99) - (categoryOrder.get(rightCategory) ?? 99);
4203
+ if (categoryRankCompare !== 0) {
4204
+ return categoryRankCompare;
4205
+ }
4206
+ const categoryCompare = leftCategory.localeCompare(rightCategory);
4207
+ if (categoryCompare !== 0) {
4208
+ return categoryCompare;
4209
+ }
4210
+ const secretCompare = Number(Boolean(left.requires_secrets)) - Number(Boolean(right.requires_secrets));
4211
+ if (secretCompare !== 0) {
4212
+ return secretCompare;
4213
+ }
4214
+ return String(left.title || left.slug || "").localeCompare(String(right.title || right.slug || ""));
4215
+ });
4216
+ const featured = sorted.filter((entry) => !entry.requires_secrets);
4217
+ const titleWidth = Math.min(
4218
+ 20,
4219
+ Math.max(...sorted.map((entry) => String(entry.title || "").length), "Title".length)
4220
+ );
4221
+ const slugWidth = Math.max(...sorted.map((entry) => String(entry.slug || "").length), "Slug".length);
4222
+ const renderEntry = (entry) => {
4223
+ const title = String(entry.title || "").padEnd(titleWidth, " ");
4224
+ const slug = String(entry.slug || "").padEnd(slugWidth, " ");
4225
+ const secretState = entry.requires_secrets ? "key" : "no key";
4226
+ return ` ${title} ${slug} :${entry.port} ${secretState}`;
4227
+ };
4228
+ const lines = [`Cookbooks (${cookbooks.length})`];
4229
+ if (featured.length > 0) {
4230
+ lines.push("", "Try first");
4231
+ for (const entry of featured) {
4232
+ lines.push(renderEntry(entry));
4233
+ }
4234
+ }
4235
+ let currentCategory = null;
4236
+ for (const entry of sorted) {
4237
+ if (!entry.requires_secrets) {
4238
+ continue;
4239
+ }
4240
+ const category = String(entry.category || "other");
4241
+ if (category !== currentCategory) {
4242
+ lines.push("", titleCaseLabel(category));
4243
+ currentCategory = category;
4244
+ }
4245
+ lines.push(renderEntry(entry));
4246
+ }
4247
+ lines.push("", "Use `instavm cookbook info <slug>` for details.", "Use `instavm cookbook deploy <slug>` to launch one.");
4248
+ return lines;
4249
+ }
4250
+ function formatWhoamiOutput(payload) {
4251
+ const sshKeys = Array.isArray(payload.ssh_keys) ? payload.ssh_keys : [];
4252
+ const lines = [
4253
+ "Account",
4254
+ ` Email: ${displayValue(payload.email)}`,
4255
+ ` User ID: ${displayValue(payload.id)}`,
4256
+ ` Name: ${displayValue(payload.name)}`,
4257
+ ` Verified: ${payload.is_verified ? "yes" : "no"}`,
4258
+ "",
4259
+ `SSH Keys (${sshKeys.length})`
4260
+ ];
4261
+ if (sshKeys.length > 0) {
4262
+ lines.push(...formatTable(
4263
+ ["ID", "Fingerprint", "Comment"],
4264
+ sshKeys.map((key) => [
4265
+ displayValue(key.id),
4266
+ displayValue(key.fingerprint),
4267
+ displayValue(key.comment)
4268
+ ])
4269
+ ));
4270
+ } else {
4271
+ lines.push(" none");
4272
+ }
4273
+ return lines;
4274
+ }
4275
+ function formatVmList(vms, includeAll) {
4276
+ return [
4277
+ includeAll ? `VMs (${vms.length})` : `Active VMs (${vms.length})`,
4278
+ ...includeAll ? [] : ["Use `instavm ls --all` to include terminated VMs."],
4279
+ "",
4280
+ ...formatTable(
4281
+ ["ID", "Status", "Created", "SSH"],
4282
+ vms.map((vm) => [
4283
+ displayValue(vm.vm_id),
4284
+ displayValue(vm.status),
4285
+ displayValue(vm.created_at),
4286
+ displayValue(vm.ssh_host)
4287
+ ])
4288
+ )
4289
+ ];
4290
+ }
4291
+ function formatSnapshotList(snapshots) {
4292
+ return [
4293
+ `Snapshots (${snapshots.length})`,
4294
+ "",
4295
+ ...formatTable(
4296
+ ["ID", "Name", "Status", "Type"],
4297
+ snapshots.map((snapshot) => [
4298
+ displayValue(snapshot.id),
4299
+ displayValue(snapshot.name),
4300
+ displayValue(snapshot.status),
4301
+ displayValue(snapshot.type)
4302
+ ])
4303
+ )
4304
+ ];
4305
+ }
4306
+ function formatSshKeyList(keys) {
4307
+ return [
4308
+ `SSH Keys (${keys.length})`,
4309
+ "",
4310
+ ...formatTable(
4311
+ ["ID", "Fingerprint", "Comment"],
4312
+ keys.map((key) => [
4313
+ displayValue(key.id),
4314
+ displayValue(key.fingerprint),
4315
+ displayValue(key.comment)
4316
+ ])
4317
+ )
4318
+ ];
4319
+ }
4320
+ function formatVolumeList(volumes) {
4321
+ return [
4322
+ `Volumes (${volumes.length})`,
4323
+ "",
4324
+ ...formatTable(
4325
+ ["ID", "Name", "Usage", "Status"],
4326
+ volumes.map((entry) => [
4327
+ displayValue(entry.id),
4328
+ displayValue(entry.name),
4329
+ `${humanBytes(entry.used_bytes)} / ${humanBytes(entry.quota_bytes)}`,
4330
+ displayValue(entry.status)
4331
+ ])
4332
+ )
4333
+ ];
4334
+ }
4335
+ function formatCheckpointList(checkpoints) {
4336
+ return [
4337
+ `Checkpoints (${checkpoints.length})`,
4338
+ "",
4339
+ ...formatTable(
4340
+ ["ID", "Name", "Status"],
4341
+ checkpoints.map((entry) => [
4342
+ displayValue(entry.id),
4343
+ displayValue(entry.name),
4344
+ displayValue(entry.status)
4345
+ ])
4346
+ )
4347
+ ];
4348
+ }
4349
+ function formatFileList(files) {
4350
+ return [
4351
+ `Files (${files.length})`,
4352
+ "",
4353
+ ...formatTable(
4354
+ ["Path", "Size"],
4355
+ files.map((entry) => [
4356
+ displayValue(entry.path),
4357
+ humanBytes(entry.size)
4358
+ ])
4359
+ )
4360
+ ];
4361
+ }
4362
+ var ProgressRenderer = class {
4363
+ constructor(deps, options) {
4364
+ this.deps = deps;
4365
+ this.options = options;
4366
+ this.currentMessage = null;
4367
+ this.startedAt = 0;
4368
+ this.timer = null;
4369
+ this.frame = 0;
4370
+ this.enabled = !options.json && supportsAnsi(deps.stderr);
4371
+ }
4372
+ step(message) {
4373
+ if (this.options.json) {
4374
+ return;
4375
+ }
4376
+ if (!this.enabled) {
4377
+ this.note(`${message}...`);
4378
+ return;
4379
+ }
4380
+ if (message !== this.currentMessage) {
4381
+ this.startedAt = Date.now();
4382
+ this.frame = 0;
4383
+ }
4384
+ this.currentMessage = message;
4385
+ if (!this.timer) {
4386
+ this.timer = setInterval(() => this.render(), 180);
4387
+ this.render();
4388
+ }
4389
+ }
4390
+ note(message) {
4391
+ if (this.options.json) {
4392
+ return;
4393
+ }
4394
+ if (!this.enabled) {
4395
+ this.deps.stderr.write(`${message}
4396
+ `);
4397
+ return;
4398
+ }
4399
+ this.currentMessage = null;
4400
+ this.startedAt = 0;
4401
+ this.clearLine();
4402
+ this.deps.stderr.write(`${message}
4403
+ `);
4404
+ }
4405
+ stop() {
4406
+ if (this.timer) {
4407
+ clearInterval(this.timer);
4408
+ this.timer = null;
4409
+ }
4410
+ if (this.enabled) {
4411
+ this.currentMessage = null;
4412
+ this.clearLine();
4413
+ }
4414
+ }
4415
+ render() {
4416
+ if (!this.currentMessage) {
4417
+ return;
4418
+ }
4419
+ const suffixes = ["", ".", "..", "..."];
4420
+ const elapsed = this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0;
4421
+ this.deps.stderr.write(`\r\x1B[2K${this.currentMessage}${suffixes[this.frame % suffixes.length]} ${elapsed}s`);
4422
+ this.frame += 1;
4423
+ }
4424
+ clearLine() {
4425
+ this.deps.stderr.write("\r\x1B[2K");
4426
+ }
4427
+ };
4428
+ function filterActiveVms(vms) {
4429
+ return vms.filter((vm) => ACTIVE_VM_STATUSES.has(String(vm.status || "").toLowerCase()));
4430
+ }
4431
+ function normalizeArgv(argv) {
4432
+ return argv.map((token) => token === "-all" ? "--all" : token);
4433
+ }
4434
+ function emitOutput(deps, options, payload, textLines) {
4435
+ if (options.json) {
4436
+ printJson(deps, payload);
4437
+ } else {
4438
+ printLines(deps, textLines);
4439
+ }
4440
+ return 0;
4441
+ }
4442
+ function shouldSpin(message) {
4443
+ return INTERACTIVE_PROGRESS_STEPS.has(message) || INTERACTIVE_PROGRESS_PREFIXES.some((prefix) => message.startsWith(prefix));
4444
+ }
4445
+ function cookbookProgress(renderer, message) {
4446
+ if (shouldSpin(message)) {
4447
+ renderer.step(message);
4448
+ } else {
4449
+ renderer.note(message);
4450
+ }
4451
+ }
4452
+ function formatCookbookDeploySuccess(options, deps, payload) {
4453
+ const colorEnabled = !options.json && supportsAnsi(deps.stdout);
4454
+ const vmId = String(payload.vm_id || "");
4455
+ const slug = String(payload.slug || "");
4456
+ const displayUrl = String(payload.share_url || "").replace(/\/$/, "");
4457
+ const connectHint = vmId ? `instavm connect ${vmId}` : "-";
4458
+ const logsHint = slug ? `After connecting: tail -n 200 ~/.instavm-cookbooks/logs/${slug}.log` : "-";
4459
+ return [
4460
+ styleText("Cookbook ready", ["1", "32"], colorEnabled),
4461
+ "",
4462
+ ` Cookbook ${slug}`,
4463
+ ` VM ${vmId}`,
4464
+ ` URL ${styleText(displayUrl, ["32", "4"], colorEnabled)}`,
4465
+ ` Connect ${connectHint}`,
4466
+ ` Logs ${logsHint}`,
4467
+ ` Destroy ${payload.destroy_command}`,
4468
+ ...payload.notes?.length ? ["", " Notes", ...payload.notes.map((note) => ` - ${note}`)] : []
4469
+ ];
4470
+ }
4471
+ function formatCookbookDeployFailure(options, deps, message, payload) {
4472
+ const colorEnabled = !options.json && supportsAnsi(deps.stderr);
4473
+ const vmId = String(payload.vm_id || "");
4474
+ const slug = String(payload.slug || "");
4475
+ const connectHint = vmId ? `instavm connect ${vmId}` : "-";
4476
+ const logsHint = vmId && slug ? `After connecting: tail -n 200 ~/.instavm-cookbooks/logs/${slug}.log` : "-";
4477
+ return [
4478
+ styleText("Deploy failed", ["1", "31"], colorEnabled),
4479
+ ` Error ${message}`,
4480
+ ` VM ${vmId || "-"}`,
4481
+ ` Connect ${connectHint}`,
4482
+ ` Logs ${logsHint}`,
4483
+ ` Destroy ${payload.destroy_command || "-"}`
4484
+ ];
4485
+ }
4486
+ function emitCookbookStyleFailure(deps, options, error, commanderCode, fallbackPayload = {}) {
4487
+ if (!(error instanceof CookbookError)) {
4488
+ throw error;
4489
+ }
4490
+ const payload = {
4491
+ status: "failed",
4492
+ error: error.message,
4493
+ ...fallbackPayload,
4494
+ ...error instanceof CookbookDeployError ? error.payload : {}
4495
+ };
4496
+ if (options.json) {
4497
+ emitCookbookDeployError(deps, options, payload);
4498
+ } else {
4499
+ printErrorLines(deps, formatCookbookDeployFailure(options, deps, error.message, payload));
4500
+ }
4501
+ throw new import_commander.CommanderError(1, commanderCode, "");
4502
+ }
4503
+ function printDeployPlan(deps, plan) {
4504
+ const runtimeLabel = { node: "Node.js", python: "Python", static: "Static Site", go: "Go", deno: "Deno" };
4505
+ const label = runtimeLabel[plan.runtime] || plan.runtime;
4506
+ const lines = [
4507
+ "",
4508
+ ` Detected: ${label} (${plan.packageManager})`,
4509
+ "",
4510
+ ` Install: ${plan.installCommand}`
4511
+ ];
4512
+ if (plan.buildCommand) lines.push(` Build: ${plan.buildCommand}`);
4513
+ lines.push(
4514
+ ` Start: ${plan.startCommand}`,
4515
+ ` Port: ${plan.port}`,
4516
+ ` Health: ${plan.healthPath}`
4517
+ );
4518
+ if (plan.secrets.length > 0) {
4519
+ lines.push("", " Secrets:");
4520
+ for (const s of plan.secrets) {
4521
+ const envVal = process.env[s.env_name] || "";
4522
+ if (envVal) {
4523
+ lines.push(` ${s.env_name}: set (from environment)`);
4524
+ } else {
4525
+ lines.push(` ${s.env_name}: (not set \u2014 will prompt)`);
4526
+ }
4527
+ }
4528
+ }
4529
+ lines.push("");
4530
+ printLines(deps, lines);
4531
+ }
4532
+ function getDeployCacheDir() {
4533
+ const cacheDir = import_path14.default.join(getConfigDir(), "deploys");
4534
+ import_fs5.default.mkdirSync(cacheDir, { recursive: true, mode: 448 });
4535
+ try {
4536
+ import_fs5.default.chmodSync(cacheDir, 448);
4537
+ } catch {
4538
+ }
4539
+ return cacheDir;
4540
+ }
4541
+ function getDeployCachePath(projectPath) {
4542
+ const digest = import_crypto2.default.createHash("sha256").update(import_path14.default.resolve(projectPath)).digest("hex");
4543
+ return import_path14.default.join(getDeployCacheDir(), `${digest}.json`);
4544
+ }
4545
+ function deployFingerprint(plan) {
4546
+ return import_crypto2.default.createHash("sha256").update(JSON.stringify({
4547
+ slug: plan.slug,
4548
+ runtime: plan.runtime,
4549
+ package_manager: plan.packageManager,
4550
+ install_command: plan.installCommand,
4551
+ build_command: plan.buildCommand,
4552
+ start_command: plan.startCommand,
4553
+ port: plan.port,
4554
+ health_path: plan.healthPath
4555
+ })).digest("hex");
4556
+ }
4557
+ function loadDeployRecord(projectPath) {
4558
+ const recordPath = getDeployCachePath(projectPath);
4559
+ if (!import_fs5.default.existsSync(recordPath)) return null;
4560
+ try {
4561
+ return JSON.parse(import_fs5.default.readFileSync(recordPath, "utf8"));
4562
+ } catch {
4563
+ return null;
4564
+ }
4565
+ }
4566
+ function saveDeployRecord(projectPath, record) {
4567
+ const recordPath = getDeployCachePath(projectPath);
4568
+ import_fs5.default.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}
4569
+ `, { encoding: "utf8", mode: 384 });
4570
+ try {
4571
+ import_fs5.default.chmodSync(recordPath, 384);
4572
+ } catch {
4573
+ }
4574
+ }
4575
+ async function resolveSavedSnapshot(client, projectPath, plan, progress) {
4576
+ const record = loadDeployRecord(projectPath);
4577
+ if (!record) return void 0;
4578
+ const snapshotId = String(record.snapshot_id || "").trim();
4579
+ if (!snapshotId || record.fingerprint !== deployFingerprint(plan)) return void 0;
4580
+ try {
4581
+ const snapshot = await client.snapshots.get(snapshotId);
4582
+ const status = String(snapshot.status || "").toLowerCase();
4583
+ if (status && status !== "ready") return void 0;
4584
+ } catch {
4585
+ return void 0;
4586
+ }
4587
+ progress?.(`Reusing saved snapshot ${snapshotId}`);
4588
+ return snapshotId;
4589
+ }
4590
+ async function createDeploySnapshot(client, projectPath, plan, payload, progress) {
4591
+ const vmId = String(payload.vm_id || "").trim();
4592
+ if (!vmId) return void 0;
4593
+ progress?.("Creating snapshot");
4594
+ let result;
4595
+ try {
4596
+ result = await client.vms.snapshot(vmId, { name: `deploy/${plan.slug}` }, true);
4597
+ } catch (error) {
4598
+ const message = error instanceof Error ? error.message : String(error);
4599
+ throw new CookbookError(`Failed to create deploy snapshot: ${message}`);
4600
+ }
4601
+ const snapshotId = String(result.snapshot_id || result.id || "").trim();
4602
+ if (!snapshotId) return void 0;
4603
+ saveDeployRecord(projectPath, {
4604
+ slug: plan.slug,
4605
+ project_path: import_path14.default.resolve(projectPath),
4606
+ snapshot_id: snapshotId,
4607
+ vm_id: vmId,
4608
+ share_url: payload.share_url || null,
4609
+ last_deployed: (/* @__PURE__ */ new Date()).toISOString(),
4610
+ fingerprint: deployFingerprint(plan),
4611
+ plan: {
4612
+ runtime: plan.runtime,
4613
+ package_manager: plan.packageManager,
4614
+ install_command: plan.installCommand,
4615
+ build_command: plan.buildCommand,
4616
+ start_command: plan.startCommand,
4617
+ port: plan.port,
4618
+ health_path: plan.healthPath
4619
+ }
4620
+ });
4621
+ return snapshotId;
4622
+ }
4623
+ function formatDeploySuccess(options, deps, payload) {
4624
+ const colorEnabled = !options.json && supportsAnsi(deps.stdout);
4625
+ const vmId = String(payload.vm_id || "");
4626
+ const slug = String(payload.slug || "");
4627
+ const displayUrl = String(payload.share_url || "").replace(/\/$/, "");
4628
+ const connectHint = vmId ? `instavm connect ${vmId}` : "-";
4629
+ return [
4630
+ styleText("Deployed", ["1", "32"], colorEnabled),
4631
+ "",
4632
+ ` App ${slug}`,
4633
+ ` VM ${vmId}`,
4634
+ ` URL ${styleText(displayUrl, ["32", "4"], colorEnabled)}`,
4635
+ ` Connect ${connectHint}`,
4636
+ ` Destroy ${payload.destroy_command || ""}`,
4637
+ ...payload.reused_snapshot_id ? [` Snapshot reused ${payload.reused_snapshot_id}`] : [],
4638
+ ...payload.saved_snapshot_id ? [` Snapshot saved ${payload.saved_snapshot_id}`] : []
4639
+ ];
4640
+ }
4641
+ function addRuntimeOptions(command, options = {}) {
4642
+ command.option("--api-key <apiKey>", "Override the InstaVM API key for this command");
4643
+ command.option("--base-url <baseUrl>", "Override the InstaVM API base URL for this command");
4644
+ if (options.includeSshHost) {
4645
+ command.option("--ssh-host <sshHost>", "Override the SSH gateway host for this command");
4646
+ }
4647
+ if (options.includeJson !== false) {
4648
+ command.option("-j, --json", "Emit JSON output");
4649
+ }
4650
+ return command;
4651
+ }
4652
+ function parseSize(value) {
4653
+ const raw = value.trim().toLowerCase();
4654
+ if (!raw) {
4655
+ throw new Error("size is required");
4656
+ }
4657
+ const units = [
4658
+ ["tb", 1024 ** 4],
4659
+ ["gb", 1024 ** 3],
4660
+ ["mb", 1024 ** 2],
4661
+ ["kb", 1024],
4662
+ ["b", 1]
4663
+ ];
4664
+ for (const [suffix, multiplier] of units) {
4665
+ if (raw.endsWith(suffix) && raw !== suffix) {
4666
+ const number = raw.slice(0, -suffix.length).trim();
4667
+ return Math.floor(Number.parseFloat(number) * multiplier);
4668
+ }
4669
+ }
4670
+ return Number.parseInt(raw, 10);
4671
+ }
4672
+ function humanBytes(value) {
4673
+ let amount = Number(value || 0);
4674
+ for (const unit of ["B", "KB", "MB", "GB", "TB"]) {
4675
+ if (amount < 1024 || unit === "TB") {
4676
+ if (unit === "B") {
4677
+ return `${Math.trunc(amount)}${unit}`;
4678
+ }
4679
+ return `${amount.toFixed(1)}${unit}`;
4680
+ }
4681
+ amount /= 1024;
4682
+ }
4683
+ return `${Math.trunc(Number(value || 0))}B`;
4684
+ }
4685
+ function parseEnvPairs(values) {
4686
+ const pairs = {};
4687
+ for (const entry of values || []) {
4688
+ const separatorIndex = entry.indexOf("=");
4689
+ if (separatorIndex < 1) {
4690
+ throw new Error(`Expected KEY=VALUE, got: ${entry}`);
4691
+ }
4692
+ const key = entry.slice(0, separatorIndex).trim();
4693
+ const value = entry.slice(separatorIndex + 1);
4694
+ if (!key) {
4695
+ throw new Error(`Invalid env key: ${entry}`);
4696
+ }
4697
+ pairs[key] = value;
4698
+ }
4699
+ return pairs;
4700
+ }
4701
+ function parseVolumeSpec(spec) {
4702
+ const parts = spec.split(":");
4703
+ if (parts.length < 2) {
4704
+ throw new Error("volume mounts must use <volume_id>:<mount_path>[:rw|ro[:checkpoint_id]]");
4705
+ }
4706
+ const volumeId = parts[0].trim();
4707
+ const mountPath = parts[1].trim();
4708
+ let mode = "rw";
4709
+ let checkpointId = null;
4710
+ if (parts[2]?.trim()) {
4711
+ mode = parts[2].trim().toLowerCase();
4712
+ }
4713
+ if (parts[3]?.trim()) {
4714
+ checkpointId = parts[3].trim();
4715
+ }
4716
+ if (parts.length > 4) {
4717
+ throw new Error("volume mounts must use <volume_id>:<mount_path>[:rw|ro[:checkpoint_id]]");
4718
+ }
4719
+ if (!["rw", "ro"].includes(mode)) {
4720
+ throw new Error("volume mount mode must be rw or ro");
4721
+ }
4722
+ if (mode === "rw" && checkpointId) {
4723
+ throw new Error("checkpoint_id is only allowed for ro mounts");
4724
+ }
4725
+ if (mode === "ro" && !checkpointId) {
4726
+ checkpointId = "latest";
4727
+ }
4728
+ return {
4729
+ volume_id: volumeId,
4730
+ mount_path: mountPath,
4731
+ mode,
4732
+ checkpoint_id: checkpointId
4733
+ };
4734
+ }
4735
+ function looksLikeUuid(value) {
4736
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
4737
+ }
4738
+ function statusPayload(deps, options) {
4739
+ const settings = deps.resolveRuntimeConfig({
4740
+ apiKey: options.apiKey,
4741
+ baseURL: options.baseUrl,
4742
+ sshHost: options.sshHost
4743
+ });
4744
+ const profile = getActiveProfile(settings.config);
4745
+ const storedKey = profile.auth?.api_key ? String(profile.auth.api_key) : void 0;
4746
+ return {
4747
+ config_path: settings.configPath,
4748
+ active_profile: settings.config.active_profile,
4749
+ api_key: {
4750
+ configured: Boolean(settings.apiKey),
4751
+ redacted: redactSecret(settings.apiKey),
4752
+ source: settings.apiKeySource,
4753
+ stored: Boolean(storedKey)
4754
+ },
4755
+ base_url: {
4756
+ value: settings.baseURL,
4757
+ source: settings.baseURLSource
4758
+ },
4759
+ ssh_host: {
4760
+ value: settings.sshHost,
4761
+ source: settings.sshHostSource
4762
+ }
4763
+ };
4764
+ }
4765
+ function statusText(payload) {
4766
+ const lines = [
4767
+ `Config path: ${payload.config_path}`,
4768
+ `Active profile: ${payload.active_profile}`,
4769
+ `API key: ${payload.api_key.redacted || "not configured"} (${payload.api_key.source})`,
4770
+ `Base URL: ${payload.base_url.value} (${payload.base_url.source})`,
4771
+ `SSH host: ${payload.ssh_host.value} (${payload.ssh_host.source})`
4772
+ ];
4773
+ if (payload.api_key.stored) {
4774
+ lines.push("Stored key: present");
4775
+ }
4776
+ return lines;
4777
+ }
4778
+ function resolveSettings(deps, options) {
4779
+ return deps.resolveRuntimeConfig({
4780
+ apiKey: options.apiKey,
4781
+ baseURL: options.baseUrl,
4782
+ sshHost: options.sshHost
4783
+ });
4784
+ }
4785
+ function requireClient(deps, options) {
4786
+ const settings = resolveSettings(deps, options);
4787
+ if (!settings.apiKey) {
4788
+ throw new Error("No InstaVM API key configured. Run `instavm auth set-key` or export INSTAVM_API_KEY.");
4789
+ }
4790
+ return {
4791
+ client: deps.clientFactory(settings.apiKey, { baseURL: settings.baseURL }),
4792
+ settings
4793
+ };
4794
+ }
4795
+ async function resolveDesktopTarget(client, target) {
4796
+ if (looksLikeUuid(target)) {
4797
+ const status = await client.getSessionStatus(target);
4798
+ return { ...status, session_id: target };
4799
+ }
4800
+ const vm = await client.vms.get(target);
4801
+ const sessionId = vm.session_id ? String(vm.session_id) : null;
4802
+ if (sessionId) {
4803
+ const status = await client.getSessionStatus(sessionId);
4804
+ return {
4805
+ ...status,
4806
+ session_id: sessionId,
4807
+ vm_id: status.vm_id || vm.vm_id
4808
+ };
4809
+ }
4810
+ return {
4811
+ session_id: null,
4812
+ vm_id: vm.vm_id,
4813
+ vm_status: vm.status,
4814
+ vm_alive: vm.status === "active"
4815
+ };
4816
+ }
4817
+ async function desktopPayload(client, target) {
4818
+ const payload = await resolveDesktopTarget(client, target);
4819
+ if (payload.vm_alive && payload.session_id) {
4820
+ try {
4821
+ Object.assign(payload, await client.computerUse.viewerUrl(String(payload.session_id)));
4822
+ } catch (error) {
4823
+ }
4824
+ }
4825
+ return payload;
4826
+ }
4827
+ function createProgram(deps = defaultDeps) {
2749
4828
  const program = new import_commander.Command();
2750
4829
  program.name("instavm").description("InstaVM CLI for VM, snapshot, volume, desktop, identity, and sharing workflows.").showHelpAfterError().showSuggestionAfterError().configureOutput({
2751
4830
  writeOut: (str) => deps.stdout.write(str),
@@ -2823,19 +4902,7 @@ function createProgram(deps = defaultDeps) {
2823
4902
  is_verified: user.is_verified,
2824
4903
  ssh_keys: sshKeys
2825
4904
  };
2826
- emitOutput(
2827
- deps,
2828
- options,
2829
- payload,
2830
- [
2831
- `Email: ${payload.email || "-"}`,
2832
- `User ID: ${payload.id}`,
2833
- `Name: ${payload.name || "-"}`,
2834
- `Verified: ${payload.is_verified ? "yes" : "no"}`,
2835
- "SSH Keys:",
2836
- ...sshKeys.length ? sshKeys.map((key) => ` ${key.id} ${key.fingerprint} ${key.comment || ""}`.trimEnd()) : [" none"]
2837
- ]
2838
- );
4905
+ emitOutput(deps, options, payload, formatWhoamiOutput(payload));
2839
4906
  })
2840
4907
  );
2841
4908
  addRuntimeOptions(
@@ -2881,14 +4948,15 @@ function createProgram(deps = defaultDeps) {
2881
4948
  })
2882
4949
  );
2883
4950
  addRuntimeOptions(
2884
- program.command("ls").alias("list").description("List your VMs").option("--all", "Include all VM records, not just active VMs").action(async (options) => {
4951
+ program.command("ls").alias("list").description("List active VMs by default").option("-a, --all", "Include all VM records, including terminated VMs").action(async (options) => {
2885
4952
  const { client } = requireClient(deps, options);
2886
- const vms = options.all ? await client.vms.listAllRecords() : await client.vms.list();
4953
+ const listedVms = options.all ? await client.vms.listAllRecords() : await client.vms.list();
4954
+ const vms = options.all ? listedVms : filterActiveVms(listedVms);
2887
4955
  emitOutput(
2888
4956
  deps,
2889
4957
  options,
2890
4958
  { vms },
2891
- vms.length > 0 ? vms.map((vm) => `${vm.vm_id} ${vm.status || "-"} ${vm.created_at || "-"} ${vm.ssh_host || "-"}`) : ["No VMs."]
4959
+ vms.length > 0 ? formatVmList(vms, Boolean(options.all)) : [options.all ? "No VMs." : "No active VMs. Use `instavm ls -a` or `instavm ls --all` to include terminated VMs."]
2892
4960
  );
2893
4961
  })
2894
4962
  );
@@ -2927,7 +4995,7 @@ function createProgram(deps = defaultDeps) {
2927
4995
  addRuntimeOptions(
2928
4996
  program.command("connect").description("SSH into a VM with the local ssh client").argument("<vmId>").argument("[sshArgs...]").action(async (vmId, sshArgs, options) => {
2929
4997
  const settings = resolveSettings(deps, options);
2930
- const sshBinary = process.platform === "win32" ? findExecutable(deps, "ssh.exe") || findExecutable(deps, "ssh") : findExecutable(deps, "ssh") || findExecutable(deps, "ssh.exe");
4998
+ const sshBinary = process.platform === "win32" ? findExecutable2(deps, "ssh.exe") || findExecutable2(deps, "ssh") : findExecutable2(deps, "ssh") || findExecutable2(deps, "ssh.exe");
2931
4999
  if (!sshBinary) {
2932
5000
  throw new Error("ssh client not found on PATH");
2933
5001
  }
@@ -2959,7 +5027,7 @@ function createProgram(deps = defaultDeps) {
2959
5027
  deps,
2960
5028
  options,
2961
5029
  { snapshots },
2962
- snapshots.length > 0 ? snapshots.map((snap) => `${snap.id} ${snap.name || "-"} ${snap.status || "-"} ${snap.type || "-"}`) : ["No snapshots."]
5030
+ snapshots.length > 0 ? formatSnapshotList(snapshots) : ["No snapshots."]
2963
5031
  );
2964
5032
  })
2965
5033
  );
@@ -3081,7 +5149,7 @@ function createProgram(deps = defaultDeps) {
3081
5149
  deps,
3082
5150
  options,
3083
5151
  { keys },
3084
- keys.length > 0 ? keys.map((key) => `${key.id} ${key.fingerprint} ${key.comment || ""}`.trimEnd()) : ["No SSH keys."]
5152
+ keys.length > 0 ? formatSshKeyList(keys) : ["No SSH keys."]
3085
5153
  );
3086
5154
  })
3087
5155
  );
@@ -3198,7 +5266,7 @@ function createProgram(deps = defaultDeps) {
3198
5266
  deps,
3199
5267
  options,
3200
5268
  { volumes },
3201
- volumes.length > 0 ? volumes.map((entry) => `${entry.id} ${entry.name} ${humanBytes(entry.used_bytes)}/${humanBytes(entry.quota_bytes)} ${entry.status}`) : ["No volumes."]
5269
+ volumes.length > 0 ? formatVolumeList(volumes) : ["No volumes."]
3202
5270
  );
3203
5271
  })
3204
5272
  );
@@ -3261,7 +5329,7 @@ function createProgram(deps = defaultDeps) {
3261
5329
  deps,
3262
5330
  options,
3263
5331
  { checkpoints },
3264
- checkpoints.length > 0 ? checkpoints.map((entry) => `${entry.id} ${entry.name || "-"} ${entry.status || "-"}`) : ["No checkpoints."]
5332
+ checkpoints.length > 0 ? formatCheckpointList(checkpoints) : ["No checkpoints."]
3265
5333
  );
3266
5334
  })
3267
5335
  );
@@ -3292,14 +5360,14 @@ function createProgram(deps = defaultDeps) {
3292
5360
  deps,
3293
5361
  options,
3294
5362
  { files: result },
3295
- result.length > 0 ? result.map((entry) => `${entry.path} ${humanBytes(entry.size)}`) : ["No files."]
5363
+ result.length > 0 ? formatFileList(result) : ["No files."]
3296
5364
  );
3297
5365
  })
3298
5366
  );
3299
5367
  addRuntimeOptions(
3300
5368
  files.command("upload").description("Upload a file into a volume").argument("<volumeId>").argument("<localPath>").option("--path <remotePath>", "Remote path inside the volume").option("--overwrite", "Overwrite if the path already exists").action(async (volumeId, localPath, options) => {
3301
5369
  const { client } = requireClient(deps, options);
3302
- const remotePath = options.path || import_path12.default.basename(localPath);
5370
+ const remotePath = options.path || import_path14.default.basename(localPath);
3303
5371
  const result = await client.volumes.uploadFile(volumeId, {
3304
5372
  filePath: localPath,
3305
5373
  path: remotePath,
@@ -3312,7 +5380,7 @@ function createProgram(deps = defaultDeps) {
3312
5380
  files.command("download").description("Download a file from a volume").argument("<volumeId>").argument("<remotePath>").option("--out <outputPath>", "Local output path").action(async (volumeId, remotePath, options) => {
3313
5381
  const { client } = requireClient(deps, options);
3314
5382
  const result = await client.volumes.downloadFile(volumeId, remotePath);
3315
- const outputPath = options.out || import_path12.default.basename(remotePath);
5383
+ const outputPath = options.out || import_path14.default.basename(remotePath);
3316
5384
  deps.writeFile(outputPath, result.content);
3317
5385
  emitOutput(
3318
5386
  deps,
@@ -3340,20 +5408,173 @@ function createProgram(deps = defaultDeps) {
3340
5408
  program.command("billing").description("Show the billing portal URL").option("-j, --json", "Emit JSON output").action((options) => {
3341
5409
  emitOutput(deps, options, { url: DEFAULT_BILLING_URL }, [DEFAULT_BILLING_URL]);
3342
5410
  });
5411
+ addRuntimeOptions(
5412
+ program.command("deploy").description("Detect and deploy an app from a local directory").argument("[path]", "Project directory", ".").option("-y, --yes", "Skip confirmation prompt").option("--env <key=value...>", "Set environment variable (repeatable)", (val, prev) => [...prev, val], []).option("--port <port>", "Override detected port", parseInt).option("--health-path <path>", "Override detected health check path").option("--start-command <cmd>", "Override detected start command").option("--install-command <cmd>", "Override detected install command").option("--build-command <cmd>", "Override detected build command").option("--plan", "Show detected plan and exit (dry-run)").option("--private", "Don't create public share").option("--save-snapshot", "Create VM snapshot after successful deploy").action(async (projectPath, options) => {
5413
+ const resolved = import_path14.default.resolve(projectPath);
5414
+ let plan;
5415
+ try {
5416
+ plan = detectProject(resolved);
5417
+ } catch (err) {
5418
+ const msg = err instanceof Error ? err.message : String(err);
5419
+ deps.stderr.write(`Error: ${msg}
5420
+ `);
5421
+ throw new import_commander.CommanderError(1, "deploy.detect.failed", "");
5422
+ }
5423
+ if (options.port) plan.port = options.port;
5424
+ if (options.healthPath) plan.healthPath = options.healthPath;
5425
+ if (options.startCommand) plan.startCommand = options.startCommand;
5426
+ if (options.installCommand) plan.installCommand = options.installCommand;
5427
+ if (options.buildCommand !== void 0) plan.buildCommand = options.buildCommand;
5428
+ for (const kv of options.env || []) {
5429
+ const eqIdx = kv.indexOf("=");
5430
+ if (eqIdx > 0) process.env[kv.slice(0, eqIdx)] = kv.slice(eqIdx + 1);
5431
+ }
5432
+ if (options.plan) {
5433
+ if (options.json) {
5434
+ printJson(deps, {
5435
+ slug: plan.slug,
5436
+ runtime: plan.runtime,
5437
+ package_manager: plan.packageManager,
5438
+ install_command: plan.installCommand,
5439
+ build_command: plan.buildCommand,
5440
+ start_command: plan.startCommand,
5441
+ port: plan.port,
5442
+ health_path: plan.healthPath,
5443
+ secrets: plan.secrets.map((s) => s.env_name)
5444
+ });
5445
+ } else {
5446
+ printDeployPlan(deps, plan);
5447
+ }
5448
+ return;
5449
+ }
5450
+ if (!options.yes && deps.stdout.isTTY) {
5451
+ printDeployPlan(deps, plan);
5452
+ const rl = (await import("readline")).createInterface({ input: process.stdin, output: process.stdout });
5453
+ const answer = await new Promise((resolve) => rl.question(" Deploy? [Y/n] ", resolve));
5454
+ rl.close();
5455
+ if (answer.trim().toLowerCase() === "n") return;
5456
+ }
5457
+ const manifest = planToManifest(plan, resolved);
5458
+ if (options.private) manifest.app.share_public_default = false;
5459
+ const { client, settings } = requireClient(deps, options);
5460
+ const progressRenderer = new ProgressRenderer(deps, options);
5461
+ const reusedSnapshotId = await resolveSavedSnapshot(
5462
+ client,
5463
+ resolved,
5464
+ plan,
5465
+ (message) => cookbookProgress(progressRenderer, message)
5466
+ );
5467
+ try {
5468
+ const payload = await runDeploy(
5469
+ manifest,
5470
+ client,
5471
+ settings,
5472
+ deps,
5473
+ (message) => cookbookProgress(progressRenderer, message),
5474
+ reusedSnapshotId
5475
+ );
5476
+ if (reusedSnapshotId) {
5477
+ payload.reused_snapshot_id = reusedSnapshotId;
5478
+ }
5479
+ if (options.saveSnapshot) {
5480
+ const savedSnapshotId = await createDeploySnapshot(
5481
+ client,
5482
+ resolved,
5483
+ plan,
5484
+ payload,
5485
+ (message) => cookbookProgress(progressRenderer, message)
5486
+ );
5487
+ if (savedSnapshotId) {
5488
+ payload.saved_snapshot_id = savedSnapshotId;
5489
+ }
5490
+ }
5491
+ progressRenderer.stop();
5492
+ emitOutput(deps, options, payload, formatDeploySuccess(options, deps, payload));
5493
+ } catch (error) {
5494
+ progressRenderer.stop();
5495
+ emitCookbookStyleFailure(deps, options, error, "deploy.failed");
5496
+ }
5497
+ }),
5498
+ { includeSshHost: true }
5499
+ );
5500
+ const cookbook = program.command("cookbook").description("Discover and deploy curated cookbook applications");
5501
+ cookbook.command("list").description("List available cookbooks").option("-j, --json", "Emit JSON output").action(async (options) => {
5502
+ const root = resolveCookbookRoot(deps);
5503
+ const cookbooks = discoverCookbooks(root).map(manifestDisplayRecord);
5504
+ emitOutput(
5505
+ deps,
5506
+ options,
5507
+ { cookbooks },
5508
+ formatCookbookList(cookbooks)
5509
+ );
5510
+ });
5511
+ cookbook.command("info").description("Show cookbook details and required secrets").argument("<slug>").option("-j, --json", "Emit JSON output").action(async (slug, options) => {
5512
+ const root = resolveCookbookRoot(deps);
5513
+ const manifest = loadManifest(import_path14.default.join(root, slug, "instavm.yaml"));
5514
+ emitOutput(
5515
+ deps,
5516
+ options,
5517
+ {
5518
+ ...manifestDisplayRecord(manifest),
5519
+ vm: manifest.vm,
5520
+ app: manifest.app,
5521
+ run: manifest.run,
5522
+ secrets: manifest.secrets,
5523
+ notes: manifest.post_deploy_notes
5524
+ },
5525
+ [
5526
+ `Slug: ${manifest.slug}`,
5527
+ `Title: ${manifest.title}`,
5528
+ `Summary: ${manifest.summary}`,
5529
+ `Runtime: ${manifest.runtime}`,
5530
+ `Deploy: ${manifest.deploy.kind}`,
5531
+ `VM: ${manifest.vm.vcpu_count} vCPU, ${manifest.vm.memory_mb}MB`,
5532
+ `Port: ${manifest.app.port}`,
5533
+ `Healthcheck: ${manifest.app.healthcheck_path}`,
5534
+ `Secrets: ${manifest.secrets.map((secret) => secret.env_name).join(", ") || "none"}`,
5535
+ ...manifest.post_deploy_notes.length > 0 ? ["Notes:", ...manifest.post_deploy_notes.map((note) => ` - ${note}`)] : []
5536
+ ]
5537
+ );
5538
+ });
5539
+ addRuntimeOptions(
5540
+ cookbook.command("deploy").description("Create a VM, start the cookbook app, and return the public share URL").argument("<slug>").action(async (slug, options) => {
5541
+ const progressRenderer = new ProgressRenderer(deps, options);
5542
+ try {
5543
+ const root = resolveCookbookRoot(deps, (message) => cookbookProgress(progressRenderer, message));
5544
+ const manifest = loadManifest(import_path14.default.join(root, slug, "instavm.yaml"));
5545
+ const { client, settings } = requireClient(deps, options);
5546
+ const payload = await deployCookbook(
5547
+ manifest,
5548
+ client,
5549
+ settings,
5550
+ deps,
5551
+ (message) => cookbookProgress(progressRenderer, message)
5552
+ );
5553
+ progressRenderer.stop();
5554
+ emitOutput(deps, options, payload, formatCookbookDeploySuccess(options, deps, payload));
5555
+ } catch (error) {
5556
+ progressRenderer.stop();
5557
+ emitCookbookStyleFailure(deps, options, error, "cookbook.deploy.failed", { slug });
5558
+ }
5559
+ }),
5560
+ { includeSshHost: true }
5561
+ );
3343
5562
  return program;
3344
5563
  }
3345
5564
  async function runCli(argv, deps = defaultDeps) {
3346
5565
  const program = createProgram(deps);
3347
5566
  try {
3348
- await program.parseAsync(["node", "instavm", ...argv]);
5567
+ await program.parseAsync(["node", "instavm", ...normalizeArgv(argv)]);
3349
5568
  return 0;
3350
5569
  } catch (error) {
3351
5570
  if (error instanceof import_commander.CommanderError) {
3352
5571
  if (error.code === "commander.helpDisplayed") {
3353
5572
  return error.exitCode;
3354
5573
  }
3355
- deps.stderr.write(`${error.message}
5574
+ if (error.message) {
5575
+ deps.stderr.write(`${error.message}
3356
5576
  `);
5577
+ }
3357
5578
  return error.exitCode || 1;
3358
5579
  }
3359
5580
  const message = error instanceof Error ? error.message : String(error);
@@ -3362,7 +5583,7 @@ async function runCli(argv, deps = defaultDeps) {
3362
5583
  return 1;
3363
5584
  }
3364
5585
  }
3365
- function findExecutable(deps, binary) {
5586
+ function findExecutable2(deps, binary) {
3366
5587
  const result = deps.spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
3367
5588
  encoding: "utf8",
3368
5589
  stdio: ["ignore", "pipe", "ignore"]