@insforge/cli 0.1.57 → 0.1.60

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync8 } from "fs";
4
+ import { readFileSync as readFileSync7 } from "fs";
5
5
  import { join as join12, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { Command } from "commander";
@@ -41,8 +41,8 @@ var LineReader = class {
41
41
  this.output.write(prompt);
42
42
  if (this.queue.length > 0) return this.queue.shift();
43
43
  if (this.closed) return null;
44
- return new Promise((resolve4) => {
45
- this.waiter = resolve4;
44
+ return new Promise((resolve5) => {
45
+ this.waiter = resolve5;
46
46
  });
47
47
  }
48
48
  close() {
@@ -411,8 +411,8 @@ function startCallbackServer() {
411
411
  return new Promise((resolveServer) => {
412
412
  let resolveResult;
413
413
  let rejectResult;
414
- const resultPromise = new Promise((resolve4, reject) => {
415
- resolveResult = resolve4;
414
+ const resultPromise = new Promise((resolve5, reject) => {
415
+ resolveResult = resolve5;
416
416
  rejectResult = reject;
417
417
  });
418
418
  const server = createServer((req, res) => {
@@ -539,6 +539,9 @@ To sign in, open this URL in your browser:
539
539
 
540
540
  // src/lib/credentials.ts
541
541
  import * as clack3 from "@clack/prompts";
542
+ function isPatLogin(creds) {
543
+ return creds?.refresh_token?.startsWith("uak_") ?? false;
544
+ }
542
545
  async function requireAuth(apiUrl, allowOssBypass = true) {
543
546
  const projConfig = getProjectConfig();
544
547
  if (allowOssBypass && projConfig?.project_id === FAKE_PROJECT_ID) {
@@ -556,6 +559,10 @@ async function requireAuth(apiUrl, allowOssBypass = true) {
556
559
  }
557
560
  const creds = getCredentials();
558
561
  if (creds && creds.access_token) return creds;
562
+ if (isPatLogin(creds)) {
563
+ await refreshAccessToken(apiUrl);
564
+ return getCredentials();
565
+ }
559
566
  clack3.log.info("You need to log in to continue.");
560
567
  for (; ; ) {
561
568
  try {
@@ -573,10 +580,43 @@ async function requireAuth(apiUrl, allowOssBypass = true) {
573
580
  }
574
581
  async function refreshAccessToken(apiUrl) {
575
582
  const creds = getCredentials();
576
- if (!creds?.refresh_token) {
577
- throw new AuthError("Refresh token not found. Run `npx @insforge/cli login` again.");
583
+ if (!creds) {
584
+ throw new AuthError("Not logged in. Run `npx @insforge/cli login` first.");
578
585
  }
579
586
  const platformUrl = getPlatformApiUrl(apiUrl);
587
+ if (isPatLogin(creds)) {
588
+ let res;
589
+ try {
590
+ res = await fetch(`${platformUrl}/auth/v1/exchange-api-key`, {
591
+ method: "POST",
592
+ headers: { "Content-Type": "application/json" },
593
+ body: JSON.stringify({ apiKey: creds.refresh_token })
594
+ });
595
+ } catch {
596
+ throw new AuthError(
597
+ `Unable to reach auth server at ${platformUrl}. Check your network connection.`
598
+ );
599
+ }
600
+ if (!res.ok) {
601
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
602
+ throw new AuthError(
603
+ "API key is invalid or revoked. Run `npx @insforge/cli login --user-api-key <new-key>` again."
604
+ );
605
+ }
606
+ throw new AuthError(
607
+ `Auth server returned HTTP ${res.status} while refreshing session. Please retry shortly.`
608
+ );
609
+ }
610
+ const data = await res.json().catch(() => ({}));
611
+ if (typeof data.token !== "string" || data.token.length === 0) {
612
+ throw new AuthError("Exchange endpoint returned an invalid response (missing token).");
613
+ }
614
+ saveCredentials({ ...creds, access_token: data.token });
615
+ return data.token;
616
+ }
617
+ if (!creds.refresh_token) {
618
+ throw new AuthError("Refresh token not found. Run `npx @insforge/cli login` again.");
619
+ }
580
620
  const config = getGlobalConfig();
581
621
  const clientId = config.oauth_client_id ?? DEFAULT_CLIENT_ID;
582
622
  try {
@@ -708,7 +748,7 @@ async function reportAgentConnected(payload, apiUrl) {
708
748
  await fetch(`${baseUrl}/tracking/v1/agent-connected`, {
709
749
  method: "POST",
710
750
  headers,
711
- body: JSON.stringify(payload)
751
+ body: JSON.stringify({ ...payload, client: "cli" })
712
752
  });
713
753
  }
714
754
  async function streamDiagnosticAnalysis(payload, onEvent, apiUrl) {
@@ -774,10 +814,12 @@ async function createProject(orgId, name, region, apiUrl) {
774
814
 
775
815
  // src/commands/login.ts
776
816
  function registerLoginCommand(program2) {
777
- program2.command("login").description("Authenticate with InsForge platform").option("--email", "Login with email and password instead of browser").option("--client-id <id>", "OAuth client ID (defaults to insforge-cli)").action(async (opts, cmd) => {
817
+ program2.command("login").description("Authenticate with InsForge platform").option("--email", "Login with email and password instead of browser").option("--client-id <id>", "OAuth client ID (defaults to insforge-cli)").option("--user-api-key <key>", "Authenticate with a uak_ personal access token").action(async (opts, cmd) => {
778
818
  const { json, apiUrl } = getRootOpts(cmd);
779
819
  try {
780
- if (opts.email) {
820
+ if (opts.userApiKey) {
821
+ await loginWithUserApiKey(opts.userApiKey, json, apiUrl);
822
+ } else if (opts.email) {
781
823
  await loginWithEmail(json, apiUrl);
782
824
  } else {
783
825
  await loginWithOAuth(json, apiUrl);
@@ -846,6 +888,78 @@ async function loginWithOAuth(json, apiUrl) {
846
888
  console.log(JSON.stringify({ success: true, user: creds.user }));
847
889
  }
848
890
  }
891
+ async function loginWithUserApiKey(key, json, apiUrl) {
892
+ if (!json) {
893
+ clack4.intro("InsForge CLI");
894
+ }
895
+ if (!key.startsWith("uak_")) {
896
+ throw new CLIError('Invalid API key \u2014 must start with "uak_".');
897
+ }
898
+ const s = !json ? clack4.spinner() : null;
899
+ s?.start("Verifying API key...");
900
+ let jwt;
901
+ let user;
902
+ try {
903
+ const exchanged = await exchangePatForJwt(key, apiUrl);
904
+ jwt = exchanged.token;
905
+ user = exchanged.user;
906
+ } catch (err) {
907
+ s?.stop("API key verification failed");
908
+ throw err instanceof CLIError ? err : new CLIError(err instanceof Error ? err.message : String(err));
909
+ }
910
+ saveCredentials({
911
+ access_token: jwt,
912
+ refresh_token: key,
913
+ user
914
+ });
915
+ if (!json) {
916
+ s?.stop(`Authenticated as ${user.email}`);
917
+ clack4.outro("Done");
918
+ } else {
919
+ console.log(JSON.stringify({ success: true, user }));
920
+ }
921
+ }
922
+ async function exchangePatForJwt(apiKey, apiUrl) {
923
+ const baseUrl = getPlatformApiUrl(apiUrl);
924
+ const fullUrl = `${baseUrl}/auth/v1/exchange-api-key`;
925
+ let res;
926
+ try {
927
+ res = await fetch(fullUrl, {
928
+ method: "POST",
929
+ headers: { "Content-Type": "application/json" },
930
+ body: JSON.stringify({ apiKey })
931
+ });
932
+ } catch (err) {
933
+ throw new CLIError(formatFetchError(err, fullUrl));
934
+ }
935
+ if (!res.ok) {
936
+ const body = await res.json().catch(() => ({}));
937
+ const msg = body.message ?? body.error ?? `HTTP ${res.status}`;
938
+ throw new CLIError(`API key is invalid or revoked: ${msg}`);
939
+ }
940
+ const data = await res.json().catch(() => ({}));
941
+ if (typeof data.token !== "string" || data.token.length === 0) {
942
+ throw new CLIError("Exchange endpoint returned an invalid response (missing token).");
943
+ }
944
+ const jwt = data.token;
945
+ let profileRes;
946
+ try {
947
+ profileRes = await fetch(`${baseUrl}/auth/v1/profile`, {
948
+ headers: { Authorization: `Bearer ${jwt}` }
949
+ });
950
+ } catch (err) {
951
+ throw new CLIError(formatFetchError(err, `${baseUrl}/auth/v1/profile`));
952
+ }
953
+ if (!profileRes.ok) {
954
+ throw new CLIError(`Exchange succeeded but could not fetch profile: HTTP ${profileRes.status}`);
955
+ }
956
+ const profile = await profileRes.json().catch(() => null);
957
+ const user = profile && typeof profile === "object" && "user" in profile ? profile.user : profile ?? void 0;
958
+ if (!user) {
959
+ throw new CLIError("Exchange succeeded but profile response was empty");
960
+ }
961
+ return { token: jwt, user };
962
+ }
849
963
 
850
964
  // src/lib/output.ts
851
965
  import Table from "cli-table3";
@@ -1208,10 +1322,11 @@ async function ossFetch(path5, options = {}) {
1208
1322
  message += `
1209
1323
  ${err.nextActions}`;
1210
1324
  }
1211
- if (res.status === 404 && path5.startsWith("/api/compute")) {
1325
+ const isRouteLevel404 = !err.error || err.error === "NOT_FOUND";
1326
+ if (res.status === 404 && isRouteLevel404 && path5.startsWith("/api/compute")) {
1212
1327
  message = "Compute services are not available on this backend.\nSelf-hosted: upgrade your InsForge instance. Cloud: contact your InsForge admin to enable compute.";
1213
1328
  }
1214
- if (res.status === 404 && path5 === "/api/database/migrations") {
1329
+ if (res.status === 404 && isRouteLevel404 && path5 === "/api/database/migrations") {
1215
1330
  message = "Database migrations are not available on this backend.\nSelf-hosted: upgrade your InsForge instance. Cloud: contact your InsForge admin about database migration support.";
1216
1331
  }
1217
1332
  throw new CLIError(message);
@@ -1351,11 +1466,11 @@ async function collectDeploymentFiles(sourceDir) {
1351
1466
  return files;
1352
1467
  }
1353
1468
  async function createZipBuffer(sourceDir) {
1354
- return new Promise((resolve4, reject) => {
1469
+ return new Promise((resolve5, reject) => {
1355
1470
  const archive = archiver("zip", { zlib: { level: 9 } });
1356
1471
  const chunks = [];
1357
1472
  archive.on("data", (chunk) => chunks.push(chunk));
1358
- archive.on("end", () => resolve4(Buffer.concat(chunks)));
1473
+ archive.on("end", () => resolve5(Buffer.concat(chunks)));
1359
1474
  archive.on("error", (err) => reject(err));
1360
1475
  archive.directory(sourceDir, false, (entry) => {
1361
1476
  if (shouldExclude(entry.name)) return false;
@@ -1437,7 +1552,7 @@ async function pollDeployment(deploymentId, spinner7, syncBeforeRead) {
1437
1552
  const startTime = Date.now();
1438
1553
  let deployment = null;
1439
1554
  while (Date.now() - startTime < POLL_TIMEOUT_MS) {
1440
- await new Promise((resolve4) => setTimeout(resolve4, POLL_INTERVAL_MS));
1555
+ await new Promise((resolve5) => setTimeout(resolve5, POLL_INTERVAL_MS));
1441
1556
  try {
1442
1557
  if (syncBeforeRead) {
1443
1558
  await ossFetch(`/api/deployments/${deploymentId}/sync`, { method: "POST" });
@@ -2754,13 +2869,17 @@ import { join as join8 } from "path";
2754
2869
  // src/lib/migrations.ts
2755
2870
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync } from "fs";
2756
2871
  import { join as join7 } from "path";
2757
- var MIGRATION_VERSION_REGEX = /^\d{14}$/u;
2758
- var MIGRATION_FILENAME_REGEX = /^(\d{14})_([a-z0-9-]+)\.sql$/u;
2872
+ var MIGRATION_VERSION_REGEX = /^\d{1,64}$/u;
2873
+ var MIGRATION_FILENAME_REGEX = /^(\d{1,64})_([a-z0-9-]+)\.sql$/u;
2759
2874
  function assertValidMigrationVersion(version) {
2760
2875
  if (!MIGRATION_VERSION_REGEX.test(version)) {
2761
- throw new CLIError(`Invalid migration version: ${version}. Expected YYYYMMDDHHmmss.`);
2876
+ throw new CLIError(`Invalid migration version: ${version}. Expected a numeric string of at most 64 digits (e.g. 0001 or 20260418091500).`);
2762
2877
  }
2763
2878
  }
2879
+ function canonicalMigrationVersion(version) {
2880
+ assertValidMigrationVersion(version);
2881
+ return BigInt(version).toString();
2882
+ }
2764
2883
  function parseMigrationFilename(filename) {
2765
2884
  const match = MIGRATION_FILENAME_REGEX.exec(filename);
2766
2885
  if (!match) {
@@ -2768,11 +2887,16 @@ function parseMigrationFilename(filename) {
2768
2887
  }
2769
2888
  return {
2770
2889
  filename,
2771
- version: match[1],
2890
+ version: canonicalMigrationVersion(match[1]),
2772
2891
  name: match[2]
2773
2892
  };
2774
2893
  }
2775
2894
  function compareMigrationVersions(left, right) {
2895
+ if (MIGRATION_VERSION_REGEX.test(left) && MIGRATION_VERSION_REGEX.test(right)) {
2896
+ const a = BigInt(left);
2897
+ const b = BigInt(right);
2898
+ return a < b ? -1 : a > b ? 1 : 0;
2899
+ }
2776
2900
  return left.localeCompare(right);
2777
2901
  }
2778
2902
  function getRemoteMigrationVersionStatus(version, appliedVersions, latestRemoteVersion) {
@@ -2794,6 +2918,10 @@ function formatMigrationVersion(date) {
2794
2918
  return `${year}${month}${day}${hour}${minute}${second}`;
2795
2919
  }
2796
2920
  function incrementMigrationVersion(version) {
2921
+ assertValidMigrationVersion(version);
2922
+ if (!/^\d{14}$/u.test(version)) {
2923
+ return String(BigInt(version) + 1n);
2924
+ }
2797
2925
  const year = Number(version.slice(0, 4));
2798
2926
  const month = Number(version.slice(4, 6)) - 1;
2799
2927
  const day = Number(version.slice(6, 8));
@@ -2876,8 +3004,9 @@ function findOlderThanHeadLocalMigrations(migrations, appliedVersions, latestRem
2876
3004
  );
2877
3005
  }
2878
3006
  function findLocalMigrationByVersion(version, filenames) {
3007
+ const canonicalVersion = canonicalMigrationVersion(version);
2879
3008
  const matches = filenames.map((filename) => parseMigrationFilename(filename)).filter(
2880
- (migration) => migration !== null && migration.version === version
3009
+ (migration) => migration !== null && migration.version === canonicalVersion
2881
3010
  );
2882
3011
  if (matches.length === 0) {
2883
3012
  throw new CLIError(`Local migration for version ${version} not found.`);
@@ -2890,7 +3019,7 @@ function findLocalMigrationByVersion(version, filenames) {
2890
3019
  return matches[0];
2891
3020
  }
2892
3021
  function resolveMigrationTarget(target, filenames) {
2893
- if (/^\d{14}$/u.test(target)) {
3022
+ if (/^\d{1,64}$/u.test(target)) {
2894
3023
  return findLocalMigrationByVersion(target, filenames);
2895
3024
  }
2896
3025
  const parsedTarget = parseMigrationFilename(target);
@@ -2929,7 +3058,7 @@ async function fetchRemoteMigrations() {
2929
3058
  const raw = await res.json();
2930
3059
  const migrations = Array.isArray(raw.migrations) ? raw.migrations : [];
2931
3060
  for (const migration of migrations) {
2932
- assertValidMigrationVersion(migration.version);
3061
+ migration.version = canonicalMigrationVersion(migration.version);
2933
3062
  }
2934
3063
  return migrations;
2935
3064
  }
@@ -2989,6 +3118,9 @@ function registerDbMigrationsCommand(dbCmd2) {
2989
3118
  await requireAuth();
2990
3119
  const migrations = await fetchRemoteMigrations();
2991
3120
  const migrationsDir = ensureMigrationsDir();
3121
+ const existingLocalVersions = new Set(
3122
+ listLocalMigrationFilenames().map((filename) => parseMigrationFilename(filename)).filter((migration) => migration !== null).map((migration) => migration.version)
3123
+ );
2992
3124
  const createdFiles = [];
2993
3125
  const skippedFiles = [];
2994
3126
  for (const migration of [...migrations].sort(
@@ -3000,12 +3132,13 @@ function registerDbMigrationsCommand(dbCmd2) {
3000
3132
  migration.name
3001
3133
  );
3002
3134
  const filePath = join8(migrationsDir, filename);
3003
- if (existsSync4(filePath)) {
3135
+ if (existingLocalVersions.has(migration.version) || existsSync4(filePath)) {
3004
3136
  skippedFiles.push(filename);
3005
3137
  continue;
3006
3138
  }
3007
3139
  writeFileSync3(filePath, formatMigrationSql(migration.statements));
3008
3140
  createdFiles.push(filename);
3141
+ existingLocalVersions.add(migration.version);
3009
3142
  }
3010
3143
  if (json) {
3011
3144
  outputJson({
@@ -4330,7 +4463,10 @@ function registerSchedulesGetCommand(schedulesCmd2) {
4330
4463
 
4331
4464
  // src/commands/schedules/create.ts
4332
4465
  function registerSchedulesCreateCommand(schedulesCmd2) {
4333
- schedulesCmd2.command("create").description("Create a new schedule").requiredOption("--name <name>", "Schedule name").requiredOption("--cron <expression>", "Cron expression (5-field format)").requiredOption("--url <url>", "URL to invoke").requiredOption("--method <method>", "HTTP method (GET, POST, PUT, PATCH, DELETE)").option("--headers <json>", "HTTP headers as JSON").option("--body <json>", "Request body as JSON").action(async (opts, cmd) => {
4466
+ schedulesCmd2.command("create").description("Create a new schedule").requiredOption("--name <name>", "Schedule name").requiredOption(
4467
+ "--cron <expression>",
4468
+ 'Cron expression. 5-field cron (e.g. "*/5 * * * *", "0 9 * * 1-5") or pg_cron interval syntax for sub-minute cadence (e.g. "30 seconds", "5 minutes").'
4469
+ ).requiredOption("--url <url>", "URL to invoke").requiredOption("--method <method>", "HTTP method (GET, POST, PUT, PATCH, DELETE)").option("--headers <json>", "HTTP headers as JSON").option("--body <json>", "Request body as JSON").action(async (opts, cmd) => {
4334
4470
  const { json } = getRootOpts(cmd);
4335
4471
  try {
4336
4472
  await requireAuth();
@@ -4374,7 +4510,10 @@ function registerSchedulesCreateCommand(schedulesCmd2) {
4374
4510
 
4375
4511
  // src/commands/schedules/update.ts
4376
4512
  function registerSchedulesUpdateCommand(schedulesCmd2) {
4377
- schedulesCmd2.command("update <id>").description("Update a schedule").option("--name <name>", "New schedule name").option("--cron <expression>", "New cron expression").option("--url <url>", "New URL to invoke").option("--method <method>", "New HTTP method").option("--headers <json>", "New HTTP headers as JSON").option("--body <json>", "New request body as JSON").option("--active <bool>", "Enable/disable schedule (true/false)").action(async (id, opts, cmd) => {
4513
+ schedulesCmd2.command("update <id>").description("Update a schedule").option("--name <name>", "New schedule name").option(
4514
+ "--cron <expression>",
4515
+ 'New cron expression. 5-field cron or pg_cron interval syntax (e.g. "30 seconds").'
4516
+ ).option("--url <url>", "New URL to invoke").option("--method <method>", "New HTTP method").option("--headers <json>", "New HTTP headers as JSON").option("--body <json>", "New request body as JSON").option("--active <bool>", "Enable/disable schedule (true/false)").action(async (id, opts, cmd) => {
4378
4517
  const { json } = getRootOpts(cmd);
4379
4518
  try {
4380
4519
  await requireAuth();
@@ -4550,48 +4689,6 @@ function registerComputeGetCommand(computeCmd2) {
4550
4689
  });
4551
4690
  }
4552
4691
 
4553
- // src/commands/compute/create.ts
4554
- function registerComputeCreateCommand(computeCmd2) {
4555
- computeCmd2.command("create").description("Create and deploy a compute service").requiredOption("--name <name>", "Service name (DNS-safe, e.g. my-api)").requiredOption("--image <image>", "Docker image URL (e.g. nginx:alpine)").option("--port <port>", "Container port", "8080").option("--cpu <tier>", "CPU tier (shared-1x, shared-2x, performance-1x, performance-2x, performance-4x)", "shared-1x").option("--memory <mb>", "Memory in MB (256, 512, 1024, 2048, 4096, 8192)", "512").option("--region <region>", "Fly.io region", "iad").option("--env <json>", "Environment variables as JSON object").action(async (opts, cmd) => {
4556
- const { json } = getRootOpts(cmd);
4557
- try {
4558
- await requireAuth();
4559
- const body = {
4560
- name: opts.name,
4561
- imageUrl: opts.image,
4562
- port: Number(opts.port),
4563
- cpu: opts.cpu,
4564
- memory: Number(opts.memory),
4565
- region: opts.region
4566
- };
4567
- if (opts.env) {
4568
- try {
4569
- body.envVars = JSON.parse(opts.env);
4570
- } catch {
4571
- throw new CLIError("Invalid JSON for --env");
4572
- }
4573
- }
4574
- const res = await ossFetch("/api/compute/services", {
4575
- method: "POST",
4576
- body: JSON.stringify(body)
4577
- });
4578
- const service = await res.json();
4579
- if (json) {
4580
- outputJson(service);
4581
- } else {
4582
- outputSuccess(`Service "${service.name}" created [${service.status}]`);
4583
- if (service.endpointUrl) {
4584
- console.log(` Endpoint: ${service.endpointUrl}`);
4585
- }
4586
- }
4587
- await reportCliUsage("cli.compute.create", true);
4588
- } catch (err) {
4589
- await reportCliUsage("cli.compute.create", false);
4590
- handleError(err, json);
4591
- }
4592
- });
4593
- }
4594
-
4595
4692
  // src/commands/compute/update.ts
4596
4693
  function registerComputeUpdateCommand(computeCmd2) {
4597
4694
  computeCmd2.command("update <id>").description("Update a compute service").option("--image <image>", "Docker image URL").option("--port <port>", "Container port").option("--cpu <tier>", "CPU tier").option("--memory <mb>", "Memory in MB").option("--region <region>", "Fly.io region").option("--env <json>", "Environment variables as JSON object").action(async (id, opts, cmd) => {
@@ -4747,175 +4844,237 @@ function registerComputeLogsCommand(computeCmd2) {
4747
4844
  }
4748
4845
 
4749
4846
  // src/commands/compute/deploy.ts
4750
- import { existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync5, unlinkSync as unlinkSync2, renameSync } from "fs";
4751
- import { join as join11 } from "path";
4752
- import { execSync, spawn } from "child_process";
4753
- function parseFlyToml(dir) {
4754
- const tomlPath = join11(dir, "fly.toml");
4755
- if (!existsSync7(tomlPath)) return {};
4756
- const content = readFileSync7(tomlPath, "utf-8");
4757
- const config = {};
4758
- const portMatch = content.match(/internal_port\s*=\s*(\d+)/);
4759
- if (portMatch) config.internalPort = Number(portMatch[1]);
4760
- const regionMatch = content.match(/primary_region\s*=\s*'([^']+)'/);
4761
- if (regionMatch) config.region = regionMatch[1];
4762
- const memoryMatch = content.match(/memory\s*=\s*'(\d+)/);
4763
- if (memoryMatch) config.memory = memoryMatch[1];
4764
- const cpuKindMatch = content.match(/cpu_kind\s*=\s*'([^']+)'/);
4765
- if (cpuKindMatch) config.cpuKind = cpuKindMatch[1];
4766
- const cpusMatch = content.match(/cpus\s*=\s*(\d+)/);
4767
- if (cpusMatch) config.cpus = Number(cpusMatch[1]);
4768
- return config;
4769
- }
4770
- function memoryToCpuTier(cpuKind, cpus) {
4771
- if (!cpuKind) return void 0;
4772
- const kind = cpuKind === "performance" ? "performance" : "shared";
4773
- const count = cpus ?? 1;
4774
- return `${kind}-${count}x`;
4775
- }
4776
- function generateFlyToml(appName, opts) {
4777
- const cpuParts = opts.cpu.split("-");
4778
- const cpuKind = cpuParts[0] ?? "shared";
4779
- const cpuCount = parseInt(cpuParts[1] ?? "1", 10);
4780
- return `# Auto-generated by InsForge CLI
4781
- app = '${appName}'
4782
- primary_region = '${opts.region}'
4783
-
4784
- [build]
4785
- dockerfile = 'Dockerfile'
4847
+ import { existsSync as existsSync7 } from "fs";
4848
+ import { join as join11, resolve as resolve4 } from "path";
4786
4849
 
4787
- [http_service]
4788
- internal_port = ${opts.port}
4789
- force_https = true
4790
- auto_stop_machines = 'stop'
4791
- auto_start_machines = true
4792
- min_machines_running = 0
4793
-
4794
- [[vm]]
4795
- memory = '${opts.memory}mb'
4796
- cpu_kind = '${cpuKind}'
4797
- cpus = ${cpuCount}
4798
- `;
4799
- }
4800
- function checkFlyctl() {
4801
- try {
4802
- execSync("flyctl version", { stdio: "pipe" });
4803
- } catch {
4850
+ // src/lib/flyctl.ts
4851
+ import { spawn, spawnSync } from "child_process";
4852
+ function ensureFlyctlAvailable() {
4853
+ const r = spawnSync("flyctl", ["version"], {
4854
+ encoding: "utf8",
4855
+ stdio: ["ignore", "pipe", "pipe"]
4856
+ });
4857
+ if (r.error || r.status !== 0) {
4804
4858
  throw new CLIError(
4805
- "flyctl is not installed.\nInstall it with: curl -L https://fly.io/install.sh | sh\nOr: brew install flyctl"
4859
+ "flyctl is required for source-mode deploy.\n \u2022 Install: curl -L https://fly.io/install.sh | sh\n \u2022 Or use --image <pre-built-image> instead.\n" + (r.stderr ? ` Detail: ${r.stderr.trim().slice(0, 200)}` : "")
4806
4860
  );
4807
4861
  }
4808
4862
  }
4809
- function getFlyToken() {
4810
- const token = process.env.FLY_API_TOKEN;
4811
- if (!token) {
4812
- throw new CLIError(
4813
- "FLY_API_TOKEN environment variable is required for compute deploy.\nSet it with: export FLY_API_TOKEN=<your-fly-token>"
4863
+ function flyctlBuildAndPush(opts) {
4864
+ return new Promise((resolve5, reject) => {
4865
+ const child = spawn(
4866
+ "flyctl",
4867
+ [
4868
+ "deploy",
4869
+ "--remote-only",
4870
+ "--build-only",
4871
+ "--push",
4872
+ "--app",
4873
+ opts.appId,
4874
+ "--image-label",
4875
+ opts.imageLabel,
4876
+ "--no-cache"
4877
+ ],
4878
+ {
4879
+ cwd: opts.dir,
4880
+ env: { ...process.env, FLY_API_TOKEN: opts.token },
4881
+ stdio: ["inherit", "pipe", "pipe"]
4882
+ }
4814
4883
  );
4815
- }
4816
- return token;
4884
+ let captured = "";
4885
+ child.stdout?.on("data", (b) => {
4886
+ const s = b.toString();
4887
+ captured += s;
4888
+ process.stdout.write(s);
4889
+ });
4890
+ child.stderr?.on("data", (b) => {
4891
+ const s = b.toString();
4892
+ captured += s;
4893
+ process.stderr.write(s);
4894
+ });
4895
+ child.on("error", (err) => {
4896
+ reject(new CLIError(`flyctl deploy could not start: ${err.message}`));
4897
+ });
4898
+ child.on("exit", (code) => {
4899
+ if (code !== 0) {
4900
+ return reject(
4901
+ new CLIError(`flyctl deploy --build-only failed (exit ${code}). See output above.`)
4902
+ );
4903
+ }
4904
+ const m = captured.match(/pushing manifest for registry\.fly\.io\/[^\s]+@(sha256:[0-9a-f]+)/);
4905
+ if (!m) {
4906
+ return reject(
4907
+ new CLIError(
4908
+ 'flyctl deploy succeeded but the buildkit "pushing manifest" line was not found. Cannot determine image digest \u2014 please re-run with FLY_LOG_LEVEL=debug and report.'
4909
+ )
4910
+ );
4911
+ }
4912
+ resolve5({ imageRef: `registry.fly.io/${opts.appId}@${m[1]}` });
4913
+ });
4914
+ });
4817
4915
  }
4916
+
4917
+ // src/commands/compute/deploy.ts
4818
4918
  function registerComputeDeployCommand(computeCmd2) {
4819
- computeCmd2.command("deploy [directory]").description("Build and deploy a Dockerfile as a compute service").requiredOption("--name <name>", "Service name").option("--port <port>", "Container port").option("--cpu <tier>", "CPU tier (shared-1x, shared-2x, performance-1x, etc.)").option("--memory <mb>", "Memory in MB (256, 512, 1024, 2048, 4096, 8192)").option("--region <region>", "Fly.io region").option("--env <json>", "Environment variables as JSON object").action(async (directory, opts, cmd) => {
4919
+ computeCmd2.command("deploy [dir]").description(
4920
+ "Deploy a compute service. Two modes:\n compute deploy <dir> --name <name> (source mode \u2014 flyctl remote build + push, requires flyctl on PATH; no Docker needed)\n compute deploy --image <url> --name <name> (image mode \u2014 deploys pre-built image, no flyctl/Docker required)"
4921
+ ).requiredOption("--name <name>", "Service name (DNS-safe, e.g. my-api)").option("--image <url>", "Pre-built image URL (image mode)").option("--port <port>", "Container port", "8080").option(
4922
+ "--cpu <tier>",
4923
+ "CPU tier in <kind>-<N>x format (e.g. shared-1x, performance-2x)",
4924
+ "shared-1x"
4925
+ ).option("--memory <mb>", "Memory in MB", "512").option("--region <region>", "Fly.io region", "iad").option("--env <json>", "Env vars as JSON object").action(async (dir, opts, cmd) => {
4820
4926
  const { json } = getRootOpts(cmd);
4821
4927
  try {
4822
4928
  await requireAuth();
4823
- checkFlyctl();
4824
- const flyToken = getFlyToken();
4825
- const dir = directory ?? process.cwd();
4826
- const dockerfilePath = join11(dir, "Dockerfile");
4827
- if (!existsSync7(dockerfilePath)) {
4828
- throw new CLIError(`No Dockerfile found in ${dir}`);
4929
+ if (dir && opts.image) {
4930
+ throw new CLIError("Cannot use both [dir] and --image \u2014 pick one mode.");
4931
+ }
4932
+ if (!dir && !opts.image) {
4933
+ throw new CLIError(
4934
+ "Must provide either [dir] (source mode) or --image <url> (image mode)."
4935
+ );
4936
+ }
4937
+ const port = Number(opts.port);
4938
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
4939
+ throw new CLIError(`Invalid --port: ${opts.port}`);
4940
+ }
4941
+ const memory = Number(opts.memory);
4942
+ if (!Number.isInteger(memory) || memory <= 0) {
4943
+ throw new CLIError(`Invalid --memory: ${opts.memory}`);
4829
4944
  }
4830
- const flyTomlDefaults = parseFlyToml(dir);
4831
- const port = Number(opts.port) || flyTomlDefaults.internalPort || 8080;
4832
- const cpu = opts.cpu || memoryToCpuTier(flyTomlDefaults.cpuKind, flyTomlDefaults.cpus) || "shared-1x";
4833
- const memory = Number(opts.memory) || Number(flyTomlDefaults.memory) || 512;
4834
- const region = opts.region || flyTomlDefaults.region || "iad";
4835
4945
  let envVars;
4836
4946
  if (opts.env) {
4947
+ let parsed;
4837
4948
  try {
4838
- envVars = JSON.parse(opts.env);
4949
+ parsed = JSON.parse(opts.env);
4839
4950
  } catch {
4840
4951
  throw new CLIError("Invalid JSON for --env");
4841
4952
  }
4953
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4954
+ throw new CLIError('--env must be a JSON object like {"KEY":"value"}');
4955
+ }
4956
+ for (const [k, v] of Object.entries(parsed)) {
4957
+ if (typeof v !== "string") {
4958
+ throw new CLIError(
4959
+ `--env values must be strings \u2014 got ${typeof v} for key "${k}"`
4960
+ );
4961
+ }
4962
+ }
4963
+ envVars = parsed;
4842
4964
  }
4843
- if (!json) outputInfo(`Checking for existing service "${opts.name}"...`);
4965
+ const baseBody = {
4966
+ name: opts.name,
4967
+ port,
4968
+ cpu: opts.cpu,
4969
+ memory,
4970
+ region: opts.region
4971
+ };
4972
+ if (envVars) baseBody.envVars = envVars;
4973
+ if (!dir) {
4974
+ const body = { ...baseBody, imageUrl: opts.image };
4975
+ const listRes2 = await ossFetch("/api/compute/services");
4976
+ const existing2 = (await listRes2.json()).find(
4977
+ (s) => s.name === opts.name
4978
+ );
4979
+ let res;
4980
+ if (existing2) {
4981
+ if (!json) outputInfo(`Found existing service "${opts.name}", updating...`);
4982
+ const updateBody2 = { ...body };
4983
+ delete updateBody2.name;
4984
+ res = await ossFetch(`/api/compute/services/${encodeURIComponent(existing2.id)}`, {
4985
+ method: "PATCH",
4986
+ body: JSON.stringify(updateBody2)
4987
+ });
4988
+ } else {
4989
+ res = await ossFetch("/api/compute/services", {
4990
+ method: "POST",
4991
+ body: JSON.stringify(body)
4992
+ });
4993
+ }
4994
+ const service2 = await res.json();
4995
+ if (json) {
4996
+ outputJson(service2);
4997
+ } else {
4998
+ const verb = existing2 ? "updated" : "deployed";
4999
+ outputSuccess(`Service "${service2.name}" ${verb} [${service2.status}]`);
5000
+ if (service2.endpointUrl) console.log(` Endpoint: ${service2.endpointUrl}`);
5001
+ }
5002
+ await reportCliUsage("cli.compute.deploy", true);
5003
+ return;
5004
+ }
5005
+ const absDir = resolve4(dir);
5006
+ const dockerfilePath = join11(absDir, "Dockerfile");
5007
+ if (!existsSync7(dockerfilePath)) {
5008
+ throw new CLIError(
5009
+ `No Dockerfile at ${dockerfilePath}.
5010
+ Either:
5011
+ \u2022 Create one (ask your AI agent \u2014 see the insforge-cli skill)
5012
+ \u2022 Use --image <url> to deploy a pre-built image instead`
5013
+ );
5014
+ }
5015
+ ensureFlyctlAvailable();
5016
+ if (!json) outputInfo(`Detected Dockerfile at ${dockerfilePath}`);
4844
5017
  const listRes = await ossFetch("/api/compute/services");
4845
- const services = await listRes.json();
4846
- const existing = services.find((s) => s.name === opts.name);
5018
+ const existing = (await listRes.json()).find((s) => s.name === opts.name);
4847
5019
  let serviceId;
4848
5020
  let flyAppId;
4849
5021
  if (existing) {
5022
+ if (!existing.flyAppId) {
5023
+ throw new CLIError(
5024
+ `Service "${opts.name}" exists but has no Fly app yet. Delete it and redeploy.`
5025
+ );
5026
+ }
4850
5027
  serviceId = existing.id;
4851
5028
  flyAppId = existing.flyAppId;
4852
- if (!json) outputInfo(`Found existing service, redeploying...`);
5029
+ if (!json) outputInfo(`Found existing service "${opts.name}" (${flyAppId}), updating...`);
4853
5030
  } else {
4854
5031
  if (!json) outputInfo(`Creating service "${opts.name}"...`);
4855
- const body = {
4856
- name: opts.name,
4857
- imageUrl: "dockerfile",
4858
- port,
4859
- cpu,
4860
- memory,
4861
- region
4862
- };
4863
- if (envVars) body.envVars = envVars;
4864
- const deployRes = await ossFetch("/api/compute/services/deploy", {
5032
+ const prepareRes = await ossFetch("/api/compute/services/deploy", {
4865
5033
  method: "POST",
4866
- body: JSON.stringify(body)
4867
- });
4868
- const service = await deployRes.json();
4869
- serviceId = service.id;
4870
- flyAppId = service.flyAppId;
4871
- }
4872
- const existingTomlPath = join11(dir, "fly.toml");
4873
- const backupTomlPath = join11(dir, "fly.toml.insforge-backup");
4874
- let hadExistingToml = false;
4875
- if (existsSync7(existingTomlPath)) {
4876
- hadExistingToml = true;
4877
- renameSync(existingTomlPath, backupTomlPath);
4878
- }
4879
- const tomlContent = generateFlyToml(flyAppId, { port, memory, cpu, region });
4880
- writeFileSync5(existingTomlPath, tomlContent);
4881
- try {
4882
- if (!json) outputInfo("Building and deploying (this may take a few minutes)...");
4883
- await new Promise((resolve4, reject) => {
4884
- const child = spawn(
4885
- "flyctl",
4886
- ["deploy", "--remote-only", "--app", flyAppId, "--access-token", flyToken, "--yes"],
4887
- { cwd: dir, stdio: json ? "pipe" : "inherit" }
4888
- );
4889
- child.on("close", (code) => {
4890
- if (code === 0) resolve4();
4891
- else reject(new CLIError(`flyctl deploy failed with exit code ${code}`));
4892
- });
4893
- child.on("error", (err) => reject(new CLIError(`flyctl deploy error: ${err.message}`)));
5034
+ body: JSON.stringify(baseBody)
4894
5035
  });
4895
- if (!json) outputInfo("Syncing deployment info...");
4896
- const syncRes = await ossFetch(
4897
- `/api/compute/services/${encodeURIComponent(serviceId)}/sync`,
4898
- { method: "PATCH" }
4899
- );
4900
- const synced = await syncRes.json();
4901
- if (json) {
4902
- outputJson(synced);
4903
- } else {
4904
- outputSuccess(`Service "${synced.name}" deployed [${synced.status}]`);
4905
- if (synced.endpointUrl) {
4906
- console.log(` Endpoint: ${synced.endpointUrl}`);
4907
- }
4908
- }
4909
- await reportCliUsage("cli.compute.deploy", true);
4910
- } finally {
4911
- try {
4912
- unlinkSync2(existingTomlPath);
4913
- if (hadExistingToml) {
4914
- renameSync(backupTomlPath, existingTomlPath);
4915
- }
4916
- } catch {
4917
- }
5036
+ const prepared = await prepareRes.json();
5037
+ serviceId = prepared.id;
5038
+ flyAppId = prepared.flyAppId;
5039
+ if (!json) outputInfo(`Created Fly app ${flyAppId}`);
5040
+ }
5041
+ if (!json) outputInfo("Requesting deploy token...");
5042
+ const tokenRes = await ossFetch(
5043
+ `/api/compute/services/${encodeURIComponent(serviceId)}/deploy-token`,
5044
+ { method: "POST" }
5045
+ );
5046
+ const tokenJson = await tokenRes.json();
5047
+ const imageLabel = `cli-${Date.now()}`;
5048
+ if (!json) outputInfo(`Building & pushing on Fly remote builder...`);
5049
+ const { imageRef } = await flyctlBuildAndPush({
5050
+ dir: absDir,
5051
+ appId: flyAppId,
5052
+ imageLabel,
5053
+ token: tokenJson.token
5054
+ });
5055
+ if (!json) outputInfo("Launching machine...");
5056
+ const updateBody = {
5057
+ imageUrl: imageRef,
5058
+ port,
5059
+ cpu: opts.cpu,
5060
+ memory,
5061
+ region: opts.region
5062
+ };
5063
+ if (envVars) updateBody.envVars = envVars;
5064
+ const finalRes = await ossFetch(
5065
+ `/api/compute/services/${encodeURIComponent(serviceId)}`,
5066
+ { method: "PATCH", body: JSON.stringify(updateBody) }
5067
+ );
5068
+ const service = await finalRes.json();
5069
+ if (json) {
5070
+ outputJson(service);
5071
+ } else {
5072
+ const verb = existing ? "updated" : "deployed";
5073
+ outputSuccess(`Service "${service.name}" ${verb} [${service.status}]`);
5074
+ if (service.endpointUrl) console.log(` Endpoint: ${service.endpointUrl}`);
5075
+ console.log(` Image: ${imageRef} (built remotely; no local image to clean up)`);
4918
5076
  }
5077
+ await reportCliUsage("cli.compute.deploy", true);
4919
5078
  } catch (err) {
4920
5079
  await reportCliUsage("cli.compute.deploy", false);
4921
5080
  handleError(err, json);
@@ -5602,7 +5761,7 @@ function registerDiagnoseCommands(diagnoseCmd2) {
5602
5761
  const s = !json ? clack10.spinner() : null;
5603
5762
  s?.start("Collecting diagnostic data...");
5604
5763
  const data2 = await collectDiagnosticData(projectId, ossMode, apiUrl);
5605
- const cliVersion = "0.1.57";
5764
+ const cliVersion = "0.1.60";
5606
5765
  s?.stop("Data collected");
5607
5766
  if (!json) {
5608
5767
  console.log(`
@@ -5831,7 +5990,7 @@ function formatBytesCompact(bytes) {
5831
5990
 
5832
5991
  // src/index.ts
5833
5992
  var __dirname = dirname(fileURLToPath(import.meta.url));
5834
- var pkg = JSON.parse(readFileSync8(join12(__dirname, "../package.json"), "utf-8"));
5993
+ var pkg = JSON.parse(readFileSync7(join12(__dirname, "../package.json"), "utf-8"));
5835
5994
  var INSFORGE_LOGO = `
5836
5995
  \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557
5837
5996
  \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D
@@ -5903,7 +6062,6 @@ registerDiagnoseCommands(diagnoseCmd);
5903
6062
  var computeCmd = program.command("compute").description("Manage compute services (Docker containers on Fly.io)");
5904
6063
  registerComputeListCommand(computeCmd);
5905
6064
  registerComputeGetCommand(computeCmd);
5906
- registerComputeCreateCommand(computeCmd);
5907
6065
  registerComputeDeployCommand(computeCmd);
5908
6066
  registerComputeUpdateCommand(computeCmd);
5909
6067
  registerComputeDeleteCommand(computeCmd);