@groundfloorcloud/cli 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +155 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -180,7 +180,7 @@ wrong identity for testing Coderunner Vault/Files as the workload.
180
180
  | `--app-id <uuid>` | Bind a **new** coderunner to an App as a helper. Ignored on reuse. |
181
181
  | `--secret <NAME>` | Inject a workspace secret (repeatable). Only works where the platform has injection enabled; prefer runtime reads via M2M `secrets:read`. |
182
182
  | `--api-url <url>` | Control Plane base URL override. |
183
- | `-y, --yes` | Skip the reuse confirmation (required without a terminal). |
183
+ | `-y, --yes` | Skip the reuse confirmation (required without a terminal) and the prompt shown with `-c` when the deploy would drop previously deployed env variables (key names only; values are not stored). |
184
184
  | `-p, --path <dir>` | Directory to package (default: cwd). |
185
185
  | `--git <url>` / `--ref <ref>` | Clone and deploy a remote repo. |
186
186
  | `--subdir <dir>` | Package a subdirectory of the source. |
package/dist/index.js CHANGED
@@ -535,12 +535,14 @@ async function logoutCommand() {
535
535
 
536
536
  // src/cp.ts
537
537
  var CpError = class extends Error {
538
- constructor(status, message) {
538
+ constructor(status, message, reason = null) {
539
539
  super(message);
540
540
  this.status = status;
541
+ this.reason = reason;
541
542
  this.name = "CpError";
542
543
  }
543
544
  status;
545
+ reason;
544
546
  };
545
547
  function formatCpErrorDetail(detail, fallback) {
546
548
  if (typeof detail === "string" && detail.trim()) return detail.trim();
@@ -560,6 +562,40 @@ function formatCpErrorDetail(detail, fallback) {
560
562
  }
561
563
  return fallback;
562
564
  }
565
+ function parseRetryAfter(value, now = Date.now()) {
566
+ if (!value) return null;
567
+ const trimmed = value.trim();
568
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
569
+ const at = Date.parse(trimmed);
570
+ if (Number.isNaN(at)) return null;
571
+ return Math.max(0, Math.ceil((at - now) / 1e3));
572
+ }
573
+ function formatWait(seconds) {
574
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
575
+ const minutes = Math.ceil(seconds / 60);
576
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"}`;
577
+ const hours = Math.ceil(minutes / 60);
578
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
579
+ }
580
+ var REASON_HEADER = "x-groundfloor-reason";
581
+ function formatCpErrorMessage(status, detail, retryAfterSeconds, reason = null) {
582
+ let message = `${status} ${detail}`;
583
+ const wait = retryAfterSeconds !== null ? `try again in ${formatWait(retryAfterSeconds)}` : "try again later";
584
+ if (status === 429 && reason === "sending_limited") {
585
+ message += ` (workspace sign-in email limit reached: ${wait}, or give access with a copy-link or temporary password instead)`;
586
+ } else if (status === 429) {
587
+ message += ` (rate limited: ${wait})`;
588
+ } else if (status === 423 && reason === "sending_paused") {
589
+ message += " (sign-in email is paused: only Groundfloor support can resume it; meanwhile give access with a copy-link or temporary password, or switch to your own email server)";
590
+ } else if (status === 423 && !/support/i.test(detail)) {
591
+ message += " (locked: Groundfloor has paused or frozen this; contact Groundfloor support)";
592
+ }
593
+ return message;
594
+ }
595
+ function reasonHeader(res) {
596
+ const value = res.headers?.get?.(REASON_HEADER);
597
+ return value ? value.trim() : null;
598
+ }
563
599
  async function parseError(res) {
564
600
  let detail = res.statusText;
565
601
  try {
@@ -569,7 +605,12 @@ async function parseError(res) {
569
605
  }
570
606
  } catch {
571
607
  }
572
- return `${res.status} ${detail}`;
608
+ return formatCpErrorMessage(
609
+ res.status,
610
+ detail,
611
+ parseRetryAfter(res.headers?.get?.("retry-after")),
612
+ reasonHeader(res)
613
+ );
573
614
  }
574
615
  async function cpGet(apiUrl, path11) {
575
616
  const token = await getValidAccessToken();
@@ -580,7 +621,7 @@ async function cpGet(apiUrl, path11) {
580
621
  }
581
622
  });
582
623
  if (!res.ok) {
583
- throw new CpError(res.status, await parseError(res));
624
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
584
625
  }
585
626
  return await res.json();
586
627
  }
@@ -596,7 +637,7 @@ async function cpPostJson(apiUrl, path11, body) {
596
637
  body: JSON.stringify(body)
597
638
  });
598
639
  if (!res.ok) {
599
- throw new CpError(res.status, await parseError(res));
640
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
600
641
  }
601
642
  return await res.json();
602
643
  }
@@ -607,7 +648,7 @@ async function cpDelete(apiUrl, path11) {
607
648
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
608
649
  });
609
650
  if (!res.ok) {
610
- throw new CpError(res.status, await parseError(res));
651
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
611
652
  }
612
653
  }
613
654
  async function cpPutJson(apiUrl, path11, body) {
@@ -622,7 +663,7 @@ async function cpPutJson(apiUrl, path11, body) {
622
663
  body: JSON.stringify(body)
623
664
  });
624
665
  if (!res.ok) {
625
- throw new CpError(res.status, await parseError(res));
666
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
626
667
  }
627
668
  return await res.json();
628
669
  }
@@ -638,7 +679,7 @@ async function cpPatchJson(apiUrl, path11, body) {
638
679
  body: JSON.stringify(body)
639
680
  });
640
681
  if (!res.ok) {
641
- throw new CpError(res.status, await parseError(res));
682
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
642
683
  }
643
684
  return await res.json();
644
685
  }
@@ -654,7 +695,7 @@ async function cpPutBytes(apiUrl, path11, body, contentType) {
654
695
  body
655
696
  });
656
697
  if (!res.ok) {
657
- throw new CpError(res.status, await parseError(res));
698
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
658
699
  }
659
700
  }
660
701
  function queryString(params) {
@@ -710,7 +751,7 @@ async function uploadZip(apiUrl, workspaceId, coderunnerId, zip, description) {
710
751
  }
711
752
  );
712
753
  if (!res.ok) {
713
- throw new CpError(res.status, await parseError(res));
754
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
714
755
  }
715
756
  return await res.json();
716
757
  }
@@ -748,7 +789,27 @@ async function getDeployment(apiUrl, workspaceId, coderunnerId) {
748
789
  );
749
790
  if (res.status === 204) return null;
750
791
  if (!res.ok) {
751
- throw new CpError(res.status, await parseError(res));
792
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
793
+ }
794
+ return await res.json();
795
+ }
796
+ async function getDeploymentById(apiUrl, workspaceId, coderunnerId, deploymentId) {
797
+ const token = await getValidAccessToken();
798
+ const res = await fetch(
799
+ `${apiUrl}/v1/workspaces/${encodeURIComponent(
800
+ workspaceId
801
+ )}/coderunners/${encodeURIComponent(
802
+ coderunnerId
803
+ )}/deployments/${encodeURIComponent(deploymentId)}`,
804
+ {
805
+ headers: {
806
+ Authorization: `Bearer ${token}`,
807
+ Accept: "application/json"
808
+ }
809
+ }
810
+ );
811
+ if (!res.ok) {
812
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
752
813
  }
753
814
  return await res.json();
754
815
  }
@@ -1014,7 +1075,7 @@ async function mintM2mToken(apiUrl, workspaceId, clientId, clientSecret) {
1014
1075
  }
1015
1076
  );
1016
1077
  if (!res.ok) {
1017
- throw new CpError(res.status, await parseError(res));
1078
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
1018
1079
  }
1019
1080
  return await res.json();
1020
1081
  }
@@ -1055,7 +1116,7 @@ async function controlPlaneRequest(apiUrl, method, path11, body) {
1055
1116
  });
1056
1117
  if (res.status === 204) return void 0;
1057
1118
  if (!res.ok) {
1058
- throw new CpError(res.status, await parseError(res));
1119
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
1059
1120
  }
1060
1121
  const text = await res.text();
1061
1122
  if (!text) return void 0;
@@ -2041,9 +2102,12 @@ function priorEnvKeys(coderunner2) {
2041
2102
  const keys = manifest?.coderunner?.deploy?.env_keys;
2042
2103
  return Array.isArray(keys) ? keys.map(String) : [];
2043
2104
  }
2044
- function envRedeployWarning(priorKeys, sentKeys) {
2105
+ function envKeysNotResent(priorKeys, sentKeys) {
2045
2106
  const sent = new Set(sentKeys);
2046
- const missing = priorKeys.filter((k) => !sent.has(k)).sort();
2107
+ return priorKeys.filter((k) => !sent.has(k)).sort();
2108
+ }
2109
+ function envRedeployWarning(priorKeys, sentKeys) {
2110
+ const missing = envKeysNotResent(priorKeys, sentKeys);
2047
2111
  const base = 'Environment variable VALUES are not stored by Groundfloor: every deploy must resend them with -e KEY=VALUE or groundfloor.json "env".';
2048
2112
  if (priorKeys.length > 0) {
2049
2113
  if (missing.length === 0) return [];
@@ -2076,6 +2140,7 @@ var VERSION_POLL_INTERVAL_MS = 2e3;
2076
2140
  var VERSION_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
2077
2141
  var DEPLOY_POLL_INTERVAL_MS = 2e3;
2078
2142
  var DEPLOY_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
2143
+ var DEPLOY_TERMINAL_FAILURE = /* @__PURE__ */ new Set([4, 5, 6]);
2079
2144
  function slugify(input) {
2080
2145
  const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2081
2146
  return slug || "app";
@@ -2235,19 +2300,30 @@ async function deployCommand(opts) {
2235
2300
  const injectSecretKeys = (opts.secret ?? []).map((key) => key.trim()).filter(Boolean);
2236
2301
  let coderunnerId = opts.coderunner;
2237
2302
  if (coderunnerId && !opts.skipDeploy) {
2303
+ let known;
2238
2304
  try {
2239
- const known = (await listCoderunners(cfg.apiUrl, ws)).find(
2305
+ known = (await listCoderunners(cfg.apiUrl, ws)).find(
2240
2306
  (c) => c.id === coderunnerId
2241
2307
  );
2242
- if (known) {
2243
- for (const line of envRedeployWarning(
2244
- priorEnvKeys(known),
2245
- Object.keys(envVariable)
2246
- )) {
2247
- log(line);
2308
+ } catch {
2309
+ }
2310
+ if (known) {
2311
+ const prior = priorEnvKeys(known);
2312
+ const sentKeys = Object.keys(envVariable);
2313
+ const warning = envRedeployWarning(prior, sentKeys);
2314
+ if (envKeysNotResent(prior, sentKeys).length > 0) {
2315
+ if (io.isTTY && !opts.yes) {
2316
+ await confirmAction({
2317
+ summary: warning,
2318
+ question: "Deploy anyway and clear these variables?",
2319
+ io
2320
+ });
2321
+ } else {
2322
+ for (const line of warning) log(line);
2248
2323
  }
2324
+ } else {
2325
+ for (const line of warning) log(line);
2249
2326
  }
2250
- } catch {
2251
2327
  }
2252
2328
  }
2253
2329
  if (opts.appId && coderunnerId) {
@@ -2400,24 +2476,43 @@ async function deployCommand(opts) {
2400
2476
  throw err;
2401
2477
  }
2402
2478
  log("Waiting for deployment...");
2479
+ const newDeploymentId = result.deployment_id || void 0;
2480
+ const readNewDeployment = async () => {
2481
+ if (newDeploymentId) {
2482
+ try {
2483
+ return await getDeploymentById(
2484
+ cfg.apiUrl,
2485
+ ws,
2486
+ coderunnerId,
2487
+ newDeploymentId
2488
+ );
2489
+ } catch (err) {
2490
+ if (err instanceof CpError && err.status === 404) return null;
2491
+ throw err;
2492
+ }
2493
+ }
2494
+ const current = await getDeployment(cfg.apiUrl, ws, coderunnerId);
2495
+ return current?.version_id === uploaded.version_id ? current : null;
2496
+ };
2403
2497
  const deployDeadline = Date.now() + DEPLOY_POLL_TIMEOUT_MS;
2404
2498
  let deployOk = false;
2405
2499
  while (Date.now() < deployDeadline) {
2406
- const deployment = await getDeployment(cfg.apiUrl, ws, coderunnerId);
2500
+ const deployment = await readNewDeployment();
2407
2501
  const dStatus = deployment?.status ?? 0;
2408
2502
  if (dStatus === 3) {
2409
2503
  deployOk = true;
2410
2504
  break;
2411
2505
  }
2412
- if (dStatus === 4) {
2506
+ if (DEPLOY_TERMINAL_FAILURE.has(dStatus)) {
2507
+ process.stderr.write("\n");
2413
2508
  return await failDeploy({
2414
2509
  apiUrl: cfg.apiUrl,
2415
2510
  workspaceId: ws,
2416
- headline: `Deployment failed for coderunner ${coderunnerId} (${statusLabel(4)}).`,
2511
+ headline: `Deployment failed for coderunner ${coderunnerId} (${statusLabel(dStatus)}).`,
2417
2512
  coderunnerId,
2418
2513
  versionId: uploaded.version_id,
2419
- deploymentId: result.deployment_id ?? deployment?.id,
2420
- status: 4
2514
+ deploymentId: newDeploymentId ?? deployment?.id,
2515
+ status: dStatus
2421
2516
  });
2422
2517
  }
2423
2518
  process.stderr.write(".");
@@ -2426,9 +2521,9 @@ async function deployCommand(opts) {
2426
2521
  process.stderr.write("\n");
2427
2522
  if (!deployOk) {
2428
2523
  let lastStatus = null;
2429
- let deploymentId = result.deployment_id;
2524
+ let deploymentId = newDeploymentId;
2430
2525
  try {
2431
- const latest = await getDeployment(cfg.apiUrl, ws, coderunnerId);
2526
+ const latest = await readNewDeployment();
2432
2527
  lastStatus = latest?.status ?? null;
2433
2528
  deploymentId = latest?.id ?? deploymentId;
2434
2529
  } catch {
@@ -3753,6 +3848,33 @@ function commanderExitCode(err) {
3753
3848
  return 0;
3754
3849
  }
3755
3850
 
3851
+ // src/email-delivery.ts
3852
+ var REASONS = {
3853
+ smtp_not_configured: "no email server is set up for this workspace's sign-in email",
3854
+ send_failed: "the email server refused or failed to send it",
3855
+ delivery_unconfirmed: "the email server timed out; the email may still arrive",
3856
+ not_requested: "sending was turned off for this request (send_email: false)",
3857
+ sending_limited: "the workspace or this recipient reached the sign-in email limit (200 per hour and 1,000 per day per workspace; 5 per hour and 10 per day per recipient); try again later, or share a copy-link or temporary password instead",
3858
+ sending_paused: "Groundfloor paused this workspace's sign-in email after too many bounces or spam reports; contact Groundfloor support, or share a copy-link or temporary password instead",
3859
+ recipient_suppressed: "this address bounced or reported spam, so Groundfloor will not email it; check the address, or clear it from the workspace suppression list (GET/DELETE /v1/workspaces/{workspace_id}/mail/suppressions; for a spam report only Groundfloor support can also clear it at the mail provider)"
3860
+ };
3861
+ function describeEmailSkippedReason(reason) {
3862
+ return REASONS[reason] ?? reason;
3863
+ }
3864
+ function emailDeliveryNote(data) {
3865
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
3866
+ const obj = data;
3867
+ if (!("email_sent" in obj) || obj.email_sent === true) return null;
3868
+ const reason = typeof obj.email_skipped_reason === "string" && obj.email_skipped_reason ? obj.email_skipped_reason : null;
3869
+ if (obj.email_sent === false) {
3870
+ return reason ? `Note: the email was not sent (${reason}): ${describeEmailSkippedReason(reason)}.` : "Note: the email was not sent.";
3871
+ }
3872
+ if (obj.email_sent === null || obj.email_sent === void 0) {
3873
+ return reason ? `Note: email delivery is unknown (${reason}): ${describeEmailSkippedReason(reason)}.` : null;
3874
+ }
3875
+ return null;
3876
+ }
3877
+
3756
3878
  // src/commands/api.ts
3757
3879
  var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
3758
3880
  async function apiCommand(opts) {
@@ -3775,6 +3897,9 @@ async function apiCommand(opts) {
3775
3897
  return;
3776
3898
  }
3777
3899
  process.stdout.write(`${JSON.stringify(data, null, 2)}
3900
+ `);
3901
+ const note = emailDeliveryNote(data);
3902
+ if (note) process.stderr.write(`${note}
3778
3903
  `);
3779
3904
  }
3780
3905
 
@@ -4164,7 +4289,7 @@ program.command("deploy").description(
4164
4289
  []
4165
4290
  ).option(
4166
4291
  "-y, --yes",
4167
- "Confirm updating an existing same-named coderunner (required with no interactive terminal). Does not bypass the saved-default-workspace check: pass -w"
4292
+ "Confirm updating an existing same-named coderunner (required with no interactive terminal), and skip the prompt about previously deployed env variables that this deploy does not resend (-c). Does not bypass the saved-default-workspace check: pass -w"
4168
4293
  ).option("-m, --message <text>", "Version description").option("--skip-deploy", "Upload a new version but do not deploy it").action((opts) => run(() => deployCommand(opts)));
4169
4294
  program.command("usage").description(
4170
4295
  "Show account quota/usage and the billing estimate (read-only). The account is the billing parent of the workspace, not the workspace itself"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Groundfloor gf CLI — sign in, deploy coderunners, publish Shell apps, and manage secrets, files, and Dataplane.",
5
5
  "type": "module",
6
6
  "bin": {