@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.
- package/README.md +1 -1
- package/dist/index.js +155 -30
- 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
|
|
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
|
|
2105
|
+
function envKeysNotResent(priorKeys, sentKeys) {
|
|
2045
2106
|
const sent = new Set(sentKeys);
|
|
2046
|
-
|
|
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
|
-
|
|
2305
|
+
known = (await listCoderunners(cfg.apiUrl, ws)).find(
|
|
2240
2306
|
(c) => c.id === coderunnerId
|
|
2241
2307
|
);
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
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
|
|
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
|
|
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(
|
|
2511
|
+
headline: `Deployment failed for coderunner ${coderunnerId} (${statusLabel(dStatus)}).`,
|
|
2417
2512
|
coderunnerId,
|
|
2418
2513
|
versionId: uploaded.version_id,
|
|
2419
|
-
deploymentId:
|
|
2420
|
-
status:
|
|
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 =
|
|
2524
|
+
let deploymentId = newDeploymentId;
|
|
2430
2525
|
try {
|
|
2431
|
-
const latest = await
|
|
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