@groundfloorcloud/cli 0.1.13 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +66 -11
  2. package/dist/index.js +1167 -824
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ async function writeConfig(config) {
56
56
  `, {
57
57
  mode: 384
58
58
  });
59
+ await fs.chmod(CONFIG_FILE, 384);
59
60
  }
60
61
  async function updateConfig(patch) {
61
62
  const current = await readConfig();
@@ -394,7 +395,7 @@ var EXPIRY_SKEW_MS = 3e4;
394
395
  var NotLoggedInError = class extends Error {
395
396
  constructor() {
396
397
  super(
397
- "Not logged in. Set GROUNDFLOOR_TOKEN, or run `gf login` first."
398
+ "Not logged in. Set GROUNDFLOOR_TOKEN to a Groundfloor access token (e.g. from `gf m2m token`), or run `gf login` first."
398
399
  );
399
400
  this.name = "NotLoggedInError";
400
401
  }
@@ -425,11 +426,26 @@ function sessionFromTokens(tokens, ctx) {
425
426
  function isFresh(session) {
426
427
  return Date.now() < session.expiresAt - EXPIRY_SKEW_MS;
427
428
  }
429
+ function isExpiredJwt(token) {
430
+ const exp = decodeJwt(token)?.exp;
431
+ return typeof exp === "number" && Date.now() >= exp * 1e3 - EXPIRY_SKEW_MS;
432
+ }
428
433
  async function getValidAccessToken() {
429
434
  if (accessTokenProvider) {
430
435
  return accessTokenProvider();
431
436
  }
437
+ const envToken = process.env.GROUNDFLOOR_TOKEN?.trim();
438
+ let envTokenExpired = false;
439
+ if (envToken) {
440
+ if (!isExpiredJwt(envToken)) return envToken;
441
+ envTokenExpired = true;
442
+ }
432
443
  const session = await readAuth();
444
+ if (envTokenExpired && (!session || !session.accessToken)) {
445
+ throw new Error(
446
+ "GROUNDFLOOR_TOKEN has expired and there is no saved `gf login` session. Mint a fresh token (e.g. `gf m2m token`) or run `gf login`."
447
+ );
448
+ }
433
449
  if (!session || !session.accessToken) {
434
450
  throw new NotLoggedInError();
435
451
  }
@@ -781,36 +797,29 @@ function getProcessLogs(apiUrl, workspaceId, query = {}) {
781
797
  `/v1/workspaces/${encodeURIComponent(workspaceId)}/logs${qs ? `?${qs}` : ""}`
782
798
  );
783
799
  }
784
- function domainsBase(workspaceId, coderunnerId) {
800
+ function domainsBase(workspaceId, appId) {
785
801
  return `/v1/workspaces/${encodeURIComponent(
786
802
  workspaceId
787
- )}/coderunners/${encodeURIComponent(coderunnerId)}/domains`;
803
+ )}/apps/${encodeURIComponent(appId)}/coderunner/domains`;
788
804
  }
789
- function listDomains(apiUrl, workspaceId, coderunnerId) {
790
- return cpGet(
791
- apiUrl,
792
- domainsBase(workspaceId, coderunnerId)
793
- );
805
+ function listDomains(apiUrl, workspaceId, appId) {
806
+ return cpGet(apiUrl, domainsBase(workspaceId, appId));
794
807
  }
795
- function addDomain(apiUrl, workspaceId, coderunnerId, domain) {
796
- return cpPostJson(
797
- apiUrl,
798
- domainsBase(workspaceId, coderunnerId),
799
- { domain }
800
- );
808
+ function addDomain(apiUrl, workspaceId, appId, domain) {
809
+ return cpPostJson(apiUrl, domainsBase(workspaceId, appId), {
810
+ domain
811
+ });
801
812
  }
802
- function removeDomain(apiUrl, workspaceId, coderunnerId, domain) {
813
+ function removeDomain(apiUrl, workspaceId, appId, domain) {
803
814
  return cpDelete(
804
815
  apiUrl,
805
- `${domainsBase(workspaceId, coderunnerId)}/${encodeURIComponent(domain)}`
816
+ `${domainsBase(workspaceId, appId)}/${encodeURIComponent(domain)}`
806
817
  );
807
818
  }
808
- function verifyDomain(apiUrl, workspaceId, coderunnerId, domain) {
819
+ function verifyDomain(apiUrl, workspaceId, appId, domain) {
809
820
  return cpPostJson(
810
821
  apiUrl,
811
- `${domainsBase(workspaceId, coderunnerId)}/${encodeURIComponent(
812
- domain
813
- )}/verify`,
822
+ `${domainsBase(workspaceId, appId)}/${encodeURIComponent(domain)}/verify`,
814
823
  {}
815
824
  );
816
825
  }
@@ -1052,6 +1061,20 @@ async function controlPlaneRequest(apiUrl, method, path11, body) {
1052
1061
  if (!text) return void 0;
1053
1062
  return JSON.parse(text);
1054
1063
  }
1064
+ function getAccountQuota(apiUrl, accountId) {
1065
+ return cpGet(
1066
+ apiUrl,
1067
+ `/v1/accounts/${encodeURIComponent(accountId)}/quota`
1068
+ );
1069
+ }
1070
+ function getBillingSummary(apiUrl, accountId, sinceSeconds) {
1071
+ return cpGet(
1072
+ apiUrl,
1073
+ `/v1/accounts/${encodeURIComponent(accountId)}/billing/summary${queryString({
1074
+ since_seconds: sinceSeconds ? String(sinceSeconds) : void 0
1075
+ })}`
1076
+ );
1077
+ }
1055
1078
 
1056
1079
  // src/commands/whoami.ts
1057
1080
  async function whoamiCommand() {
@@ -1139,6 +1162,14 @@ Tip: run \`gf workspaces use <id>\` to set a default.
1139
1162
  }
1140
1163
  }
1141
1164
  async function workspacesUseCommand(workspaceId) {
1165
+ const cfg = await resolveConfig();
1166
+ const workspaces2 = await listWorkspaces(cfg.apiUrl);
1167
+ const match = workspaces2.find((ws) => ws.id === workspaceId);
1168
+ if (!match) {
1169
+ throw new Error(
1170
+ `Workspace ${workspaceId} was not found among the workspaces visible to you. Run \`gf workspaces\` to list valid ids.`
1171
+ );
1172
+ }
1142
1173
  await updateConfig({ workspaceId });
1143
1174
  process.stdout.write(`Default workspace set to ${workspaceId}
1144
1175
  `);
@@ -1256,6 +1287,49 @@ async function m2mTokenCommand(opts) {
1256
1287
  `);
1257
1288
  }
1258
1289
 
1290
+ // src/prompt.ts
1291
+ import readline from "readline";
1292
+ function defaultPromptIO() {
1293
+ return {
1294
+ isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
1295
+ ask(question) {
1296
+ return new Promise((resolve2) => {
1297
+ const rl = readline.createInterface({
1298
+ input: process.stdin,
1299
+ output: process.stderr
1300
+ });
1301
+ rl.question(question, (answer) => {
1302
+ rl.close();
1303
+ resolve2(answer);
1304
+ });
1305
+ });
1306
+ }
1307
+ };
1308
+ }
1309
+ function isYes(answer) {
1310
+ return /^(y|yes)$/i.test(answer.trim());
1311
+ }
1312
+ var AbortedError = class extends Error {
1313
+ constructor() {
1314
+ super("Aborted. Nothing was changed.");
1315
+ this.name = "AbortedError";
1316
+ }
1317
+ };
1318
+ async function confirmAction(opts) {
1319
+ const io = opts.io ?? defaultPromptIO();
1320
+ const write = opts.write ?? ((line) => process.stderr.write(`${line}
1321
+ `));
1322
+ for (const line of opts.summary) write(line);
1323
+ if (opts.yes) return;
1324
+ if (!io.isTTY) {
1325
+ throw new Error(
1326
+ `Refusing to continue without confirmation (no interactive terminal). Re-run with ${opts.bypassHint ?? "--yes"} to confirm.`
1327
+ );
1328
+ }
1329
+ const answer = await io.ask(`${opts.question ?? "Continue?"} [y/N] `);
1330
+ if (!isYes(answer)) throw new AbortedError();
1331
+ }
1332
+
1259
1333
  // src/commands/cancel-deployment.ts
1260
1334
  async function cancelDeploymentCommand(opts) {
1261
1335
  const cfg = await resolveConfig({ workspaceId: opts.workspace });
@@ -1264,14 +1338,26 @@ async function cancelDeploymentCommand(opts) {
1264
1338
  "No workspace selected. Run `gf workspaces use <id>` or pass --workspace <id>."
1265
1339
  );
1266
1340
  }
1267
- const coderunnerId = await resolveCoderunnerId(
1268
- cfg.apiUrl,
1269
- cfg.workspaceId,
1270
- opts.coderunner
1341
+ const ws = cfg.workspaceId;
1342
+ const all = await listCoderunners(cfg.apiUrl, ws);
1343
+ const target = all.find(
1344
+ (c) => c.id === opts.coderunner || c.slug === opts.coderunner
1271
1345
  );
1346
+ if (!target) {
1347
+ throw new Error(`Coderunner not found: ${opts.coderunner}`);
1348
+ }
1349
+ const coderunnerId = target.id;
1350
+ let workspaceName;
1351
+ try {
1352
+ workspaceName = (await listWorkspaces(cfg.apiUrl)).find(
1353
+ (item) => item.id === ws
1354
+ )?.name;
1355
+ } catch {
1356
+ }
1272
1357
  let deploymentId = opts.deploymentId?.trim();
1358
+ const defaulted = !deploymentId;
1273
1359
  if (!deploymentId) {
1274
- const current = await getDeployment(cfg.apiUrl, cfg.workspaceId, coderunnerId);
1360
+ const current = await getDeployment(cfg.apiUrl, ws, coderunnerId);
1275
1361
  deploymentId = current?.id?.trim();
1276
1362
  }
1277
1363
  if (!deploymentId) {
@@ -1279,12 +1365,20 @@ async function cancelDeploymentCommand(opts) {
1279
1365
  `No current deployment for coderunner ${coderunnerId}. Pass --deployment-id.`
1280
1366
  );
1281
1367
  }
1282
- await deleteCoderunnerDeployment(
1283
- cfg.apiUrl,
1284
- cfg.workspaceId,
1285
- coderunnerId,
1286
- deploymentId
1287
- );
1368
+ const label = (name, id) => name?.trim() ? `${name.trim()} (${id})` : id;
1369
+ await confirmAction({
1370
+ summary: [
1371
+ "This will DELETE a deployment. If it is the live one, the service goes offline.",
1372
+ ` workspace: ${label(workspaceName, ws)}`,
1373
+ ` coderunner: ${label(target.name ?? target.slug, coderunnerId)}`,
1374
+ ` deployment: ${deploymentId}${defaulted ? " (the current live deployment)" : ""}`
1375
+ ],
1376
+ yes: opts.yes,
1377
+ bypassHint: "--yes",
1378
+ question: "Delete this deployment?",
1379
+ io: opts.io
1380
+ });
1381
+ await deleteCoderunnerDeployment(cfg.apiUrl, ws, coderunnerId, deploymentId);
1288
1382
  process.stdout.write(
1289
1383
  `Deleted deployment ${deploymentId} for coderunner ${coderunnerId}
1290
1384
  `
@@ -1337,10 +1431,10 @@ function statusLabel(status) {
1337
1431
  function customerPortalOrigin(apiUrl) {
1338
1432
  try {
1339
1433
  const url = new URL(apiUrl);
1340
- url.host = url.host.replace(/^platform\./, "app.");
1434
+ url.host = url.host.replace(/^platform\./, "console.");
1341
1435
  return url.origin;
1342
1436
  } catch {
1343
- return "https://app.groundfloor.cloud";
1437
+ return "https://console.groundfloor.cloud";
1344
1438
  }
1345
1439
  }
1346
1440
  function processLogPortalUrl(portalOrigin, workspaceId, runnerId) {
@@ -1433,6 +1527,34 @@ async function diagnoseDeployFailure(opts) {
1433
1527
  }
1434
1528
 
1435
1529
  // src/commands/logs.ts
1530
+ var MAX_LOG_LIMIT = 1e3;
1531
+ var MAX_LOG_SINCE_SECONDS = 7 * 24 * 60 * 60;
1532
+ var DEFAULT_LOG_LIMIT = 50;
1533
+ var DEFAULT_LOG_SINCE_SECONDS = 3600;
1534
+ function parseWholeNumber(raw) {
1535
+ return /^\d+$/.test(raw.trim()) ? Number(raw) : Number.NaN;
1536
+ }
1537
+ function parseLogsWindow(opts) {
1538
+ const limit = opts.limit !== void 0 ? parseWholeNumber(opts.limit) : DEFAULT_LOG_LIMIT;
1539
+ const sinceSeconds = opts.sinceSeconds !== void 0 ? parseWholeNumber(opts.sinceSeconds) : DEFAULT_LOG_SINCE_SECONDS;
1540
+ if (!Number.isInteger(limit) || limit < 1) {
1541
+ throw new Error("Invalid --limit (expected a whole number from 1 to 1000).");
1542
+ }
1543
+ if (limit > MAX_LOG_LIMIT) {
1544
+ throw new Error(
1545
+ `--limit ${limit} is above the maximum of ${MAX_LOG_LIMIT} lines per request.`
1546
+ );
1547
+ }
1548
+ if (!Number.isInteger(sinceSeconds) || sinceSeconds < 10) {
1549
+ throw new Error("Invalid --since-seconds (minimum 10).");
1550
+ }
1551
+ if (sinceSeconds > MAX_LOG_SINCE_SECONDS) {
1552
+ throw new Error(
1553
+ `--since-seconds ${sinceSeconds} is above the maximum of ${MAX_LOG_SINCE_SECONDS} (7 days).`
1554
+ );
1555
+ }
1556
+ return { limit, sinceSeconds };
1557
+ }
1436
1558
  async function logsCommand(opts) {
1437
1559
  const cfg = await resolveConfig({ workspaceId: opts.workspace });
1438
1560
  if (!cfg.workspaceId) {
@@ -1445,14 +1567,7 @@ async function logsCommand(opts) {
1445
1567
  cfg.workspaceId,
1446
1568
  opts.coderunner
1447
1569
  );
1448
- const limit = opts.limit ? Number(opts.limit) : 50;
1449
- const sinceSeconds = opts.sinceSeconds ? Number(opts.sinceSeconds) : 3600;
1450
- if (!Number.isFinite(limit) || limit < 1) {
1451
- throw new Error("Invalid --limit (expected a positive number).");
1452
- }
1453
- if (!Number.isFinite(sinceSeconds) || sinceSeconds < 10) {
1454
- throw new Error("Invalid --since-seconds (minimum 10).");
1455
- }
1570
+ const { limit, sinceSeconds } = parseLogsWindow(opts);
1456
1571
  const page = await getProcessLogs(cfg.apiUrl, cfg.workspaceId, {
1457
1572
  runner_id: coderunnerId,
1458
1573
  limit,
@@ -1909,6 +2024,45 @@ function credentialEnvKeys(env) {
1909
2024
  return Object.keys(env).filter((key) => isCredentialEnvKey(key)).sort();
1910
2025
  }
1911
2026
 
2027
+ // src/deploy/safety.ts
2028
+ function workspaceSource(flag, env = process.env) {
2029
+ if (flag) return "flag";
2030
+ if (env.GROUNDFLOOR_WORKSPACE_ID) return "env";
2031
+ return "saved";
2032
+ }
2033
+ function labelWithId(name, id) {
2034
+ return name?.trim() ? `${name.trim()} (${id})` : id;
2035
+ }
2036
+ function savedDefaultRefusal(workspaceLabel) {
2037
+ return `Refusing to deploy to the saved default workspace ${workspaceLabel} without confirmation (no interactive terminal). The saved default is machine-wide state that another terminal can change. Pass -w <workspace-id> (or set GROUNDFLOOR_WORKSPACE_ID) to choose the target explicitly.`;
2038
+ }
2039
+ function priorEnvKeys(coderunner2) {
2040
+ const manifest = coderunner2.manifest;
2041
+ const keys = manifest?.coderunner?.deploy?.env_keys;
2042
+ return Array.isArray(keys) ? keys.map(String) : [];
2043
+ }
2044
+ function envRedeployWarning(priorKeys, sentKeys) {
2045
+ const sent = new Set(sentKeys);
2046
+ const missing = priorKeys.filter((k) => !sent.has(k)).sort();
2047
+ const base = 'Environment variable VALUES are not stored by Groundfloor: every deploy must resend them with -e KEY=VALUE or groundfloor.json "env".';
2048
+ if (priorKeys.length > 0) {
2049
+ if (missing.length === 0) return [];
2050
+ return [
2051
+ `WARNING: this deploy does not resend ${missing.length} variable(s) from the previous deployment: ${missing.join(", ")}.`,
2052
+ " The new deployment will run WITHOUT them. " + base
2053
+ ];
2054
+ }
2055
+ if (sentKeys.length === 0) {
2056
+ return [
2057
+ "WARNING: this deploy sends no environment variables. If the running service has any, the new deployment will run WITHOUT them. " + base
2058
+ ];
2059
+ }
2060
+ return [];
2061
+ }
2062
+ function credentialEnvMessage(keys) {
2063
+ return `Credential-shaped variable names cannot be passed as --env / groundfloor.json "env": ${keys.join(", ")}. Keep secrets out of deploy variables: store them with \`gf secrets set KEY\` and have the service read them at runtime with an M2M client that has the \`secrets:read\` scope (see \`gf m2m token\`). \`gf deploy --secret KEY\` (injection at deploy time) only works on platforms where secret injection is enabled.`;
2064
+ }
2065
+
1912
2066
  // src/commands/deploy.ts
1913
2067
  var FALLBACK_RUNTIMES = /* @__PURE__ */ new Set([
1914
2068
  "python",
@@ -1952,7 +2106,9 @@ function reportFiltering(filtered) {
1952
2106
  log(
1953
2107
  `Excluded ${filtered.excluded.length} credential file(s): ${names(filtered.excluded)}`
1954
2108
  );
1955
- log(" (use `gf secrets` or `--env KEY=VALUE` to pass values to the app)");
2109
+ log(
2110
+ " (use `--env KEY=VALUE` for non-secret config; read secrets at runtime via an M2M client with `secrets:read`)"
2111
+ );
1956
2112
  }
1957
2113
  if (filtered.dotfilesIncluded.length) {
1958
2114
  log(
@@ -1960,6 +2116,17 @@ function reportFiltering(filtered) {
1960
2116
  );
1961
2117
  }
1962
2118
  }
2119
+ async function environmentLabel(apiUrl, workspaceId, environmentId) {
2120
+ if (typeof environmentId !== "string" || !environmentId) {
2121
+ return "(workspace default)";
2122
+ }
2123
+ try {
2124
+ const envs = await listWorkspaceEnvironments(apiUrl, workspaceId);
2125
+ return labelWithId(envs.find((e) => e.id === environmentId)?.name, environmentId);
2126
+ } catch {
2127
+ return environmentId;
2128
+ }
2129
+ }
1963
2130
  async function failDeploy(opts) {
1964
2131
  throw new Error(await diagnoseDeployFailure(opts));
1965
2132
  }
@@ -1974,6 +2141,8 @@ async function deployCommand(opts) {
1974
2141
  );
1975
2142
  }
1976
2143
  const ws = cfg.workspaceId;
2144
+ const source = workspaceSource(opts.workspace);
2145
+ const io = opts.io ?? defaultPromptIO();
1977
2146
  let workspaceName;
1978
2147
  try {
1979
2148
  const workspaces2 = await listWorkspaces(cfg.apiUrl);
@@ -1988,6 +2157,19 @@ async function deployCommand(opts) {
1988
2157
  apiUrl: cfg.apiUrl
1989
2158
  })
1990
2159
  );
2160
+ if (source === "saved") {
2161
+ const target = labelWithId(workspaceName, ws);
2162
+ if (!io.isTTY) {
2163
+ throw new Error(savedDefaultRefusal(target));
2164
+ }
2165
+ await confirmAction({
2166
+ summary: [
2167
+ `No -w/--workspace or GROUNDFLOOR_WORKSPACE_ID given: using the saved default workspace ${target}.`
2168
+ ],
2169
+ question: `Deploy to ${target}?`,
2170
+ io
2171
+ });
2172
+ }
1991
2173
  log(
1992
2174
  opts.git ? `Cloning ${opts.git}${opts.ref ? ` (${opts.ref})` : ""}...` : `Packaging ${path5.resolve(opts.path ?? process.cwd())}...`
1993
2175
  );
@@ -2040,13 +2222,77 @@ async function deployCommand(opts) {
2040
2222
  flag: opts.schedule,
2041
2223
  manifestSchedule: manifest.schedule
2042
2224
  });
2225
+ const envVariable = {
2226
+ ...manifest.env ?? {},
2227
+ ...parseEnvPairs(opts.env)
2228
+ };
2229
+ if (!opts.skipDeploy) {
2230
+ const plaintextSecrets = credentialEnvKeys(envVariable);
2231
+ if (plaintextSecrets.length) {
2232
+ throw new Error(credentialEnvMessage(plaintextSecrets));
2233
+ }
2234
+ }
2235
+ const injectSecretKeys = (opts.secret ?? []).map((key) => key.trim()).filter(Boolean);
2043
2236
  let coderunnerId = opts.coderunner;
2237
+ if (coderunnerId && !opts.skipDeploy) {
2238
+ try {
2239
+ const known = (await listCoderunners(cfg.apiUrl, ws)).find(
2240
+ (c) => c.id === coderunnerId
2241
+ );
2242
+ if (known) {
2243
+ for (const line of envRedeployWarning(
2244
+ priorEnvKeys(known),
2245
+ Object.keys(envVariable)
2246
+ )) {
2247
+ log(line);
2248
+ }
2249
+ }
2250
+ } catch {
2251
+ }
2252
+ }
2253
+ if (opts.appId && coderunnerId) {
2254
+ log(
2255
+ `WARNING: --app-id ${opts.appId} is IGNORED with --coderunner: it only applies when a new coderunner is created.`
2256
+ );
2257
+ }
2044
2258
  if (!coderunnerId) {
2045
2259
  const name = opts.name ?? manifest.name ?? (opts.git ? nameFromGitUrl(opts.git) : path5.basename(path5.resolve(opts.path ?? process.cwd())));
2046
2260
  const slug = manifest.slug ?? slugify(name);
2047
2261
  const existing = await listCoderunners(cfg.apiUrl, ws);
2048
2262
  const match = existing.find((c) => c.slug === slug || c.name === name);
2049
2263
  if (match) {
2264
+ const lines = [
2265
+ `A coderunner named "${match.name ?? match.slug ?? name}" already exists and will be UPDATED (a new version replaces the live deployment):`,
2266
+ ` name: ${match.name ?? match.slug ?? name}`,
2267
+ ` id: ${match.id}`,
2268
+ ` environment: ${await environmentLabel(cfg.apiUrl, ws, match.environment_id)}`,
2269
+ ` workspace: ${labelWithId(workspaceName, ws)}`
2270
+ ];
2271
+ if (opts.appId) {
2272
+ lines.push(
2273
+ `WARNING: --app-id ${opts.appId} is IGNORED: it only applies when a new coderunner is created.`
2274
+ );
2275
+ }
2276
+ if (opts.workloadType && match.workload_type && opts.workloadType !== match.workload_type) {
2277
+ lines.push(
2278
+ `WARNING: --workload-type ${opts.workloadType} is ignored; the existing coderunner is ${match.workload_type}.`
2279
+ );
2280
+ }
2281
+ if (!opts.skipDeploy) {
2282
+ lines.push(
2283
+ ...envRedeployWarning(priorEnvKeys(match), Object.keys(envVariable))
2284
+ );
2285
+ }
2286
+ lines.push(
2287
+ " (pass -c <id> to target a specific coderunner, or -n <name> to create a different one)"
2288
+ );
2289
+ await confirmAction({
2290
+ summary: lines,
2291
+ yes: opts.yes,
2292
+ bypassHint: "--yes",
2293
+ question: "Update this coderunner?",
2294
+ io
2295
+ });
2050
2296
  coderunnerId = match.id;
2051
2297
  log(`Using existing coderunner ${slug} (${coderunnerId})`);
2052
2298
  } else {
@@ -2133,26 +2379,26 @@ async function deployCommand(opts) {
2133
2379
  );
2134
2380
  return;
2135
2381
  }
2136
- const envVariable = {
2137
- ...manifest.env ?? {},
2138
- ...parseEnvPairs(opts.env)
2139
- };
2140
- const plaintextSecrets = credentialEnvKeys(envVariable);
2141
- if (plaintextSecrets.length) {
2142
- throw new Error(
2143
- `Do not pass secrets as --env: ${plaintextSecrets.join(", ")}. Store them with \`gf secrets set KEY\` and deploy with \`--secret KEY\`.`
2144
- );
2145
- }
2146
- const injectSecretKeys = (opts.secret ?? []).map((key) => key.trim()).filter(Boolean);
2147
2382
  log("Deploying...");
2148
- const result = await deployCoderunner(cfg.apiUrl, ws, coderunnerId, {
2149
- version_id: uploaded.version_id,
2150
- cpu: opts.cpu ?? manifest.cpu,
2151
- memory: opts.memory ?? manifest.memory,
2152
- envVariable: Object.keys(envVariable).length ? envVariable : void 0,
2153
- schedule,
2154
- inject_secret_keys: injectSecretKeys.length ? injectSecretKeys : void 0
2155
- });
2383
+ let result;
2384
+ try {
2385
+ result = await deployCoderunner(cfg.apiUrl, ws, coderunnerId, {
2386
+ version_id: uploaded.version_id,
2387
+ cpu: opts.cpu ?? manifest.cpu,
2388
+ memory: opts.memory ?? manifest.memory,
2389
+ envVariable: Object.keys(envVariable).length ? envVariable : void 0,
2390
+ schedule,
2391
+ inject_secret_keys: injectSecretKeys.length ? injectSecretKeys : void 0
2392
+ });
2393
+ } catch (err) {
2394
+ if (err instanceof CpError && err.status === 422 && injectSecretKeys.length) {
2395
+ throw new Error(
2396
+ `${err.message}
2397
+ --secret only works where the platform has secret injection enabled. Otherwise read the secret at runtime with an M2M client that has the \`secrets:read\` scope (see \`gf m2m token\`).`
2398
+ );
2399
+ }
2400
+ throw err;
2401
+ }
2156
2402
  log("Waiting for deployment...");
2157
2403
  const deployDeadline = Date.now() + DEPLOY_POLL_TIMEOUT_MS;
2158
2404
  let deployOk = false;
@@ -2249,11 +2495,6 @@ async function runCommand(opts) {
2249
2495
  coderunnerId,
2250
2496
  payload
2251
2497
  );
2252
- if (opts.json) {
2253
- process.stdout.write(`${JSON.stringify(result, null, 2)}
2254
- `);
2255
- return;
2256
- }
2257
2498
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2258
2499
  `);
2259
2500
  }
@@ -2297,903 +2538,887 @@ async function statusCommand(opts) {
2297
2538
  process.stdout.write(`deployment: ${deployment.id ?? "\u2014"} [${deployment.status}]
2298
2539
  `);
2299
2540
  }
2300
- process.stdout.write(`
2301
- ${JSON.stringify(out, null, 2)}
2302
- `);
2303
2541
  }
2304
2542
 
2305
- // src/commands/domains.ts
2306
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2307
- async function resolve(opts) {
2308
- const cfg = await resolveConfig({ workspaceId: opts.workspace });
2309
- if (!cfg.workspaceId) {
2310
- throw new Error(
2311
- "No workspace selected. Run `gf workspaces use <id>` or pass --workspace <id>."
2312
- );
2313
- }
2314
- const ref = opts.coderunner;
2315
- if (!ref) {
2316
- throw new Error(
2317
- "Specify the coderunner with --coderunner <id|slug>."
2318
- );
2543
+ // src/commands/apps.ts
2544
+ import { promises as fs5 } from "fs";
2545
+ import path6 from "path";
2546
+
2547
+ // src/release-zip.ts
2548
+ import AdmZip2 from "adm-zip";
2549
+ var REMOTE_ENTRY = "remoteEntry.js";
2550
+ var FLATTEN_SUFFIXES = [".js", ".mjs", ".cjs", ".css", ".map", ".wasm"];
2551
+ var IMPORT_ASSETS = /(__federation_import|import)\((['"])\.\/assets\//g;
2552
+ function rewriteRemoteEntryAssetPrefix(source) {
2553
+ return source.replace(IMPORT_ASSETS, "$1($2./");
2554
+ }
2555
+ function normalizeZipEntryName(filename) {
2556
+ return filename.replace(/\\/g, "/").replace(/^\/+/, "");
2557
+ }
2558
+ function parentDir(rel) {
2559
+ const i = rel.lastIndexOf("/");
2560
+ return i === -1 ? "" : rel.slice(0, i);
2561
+ }
2562
+ function basename(rel) {
2563
+ const i = rel.lastIndexOf("/");
2564
+ return i === -1 ? rel : rel.slice(i + 1);
2565
+ }
2566
+ function chooseRemoteEntry(relpaths) {
2567
+ const entries = relpaths.filter(
2568
+ (p) => p === REMOTE_ENTRY || p.endsWith(`/${REMOTE_ENTRY}`)
2569
+ );
2570
+ if (entries.length === 0) return void 0;
2571
+ if (entries.includes(REMOTE_ENTRY)) return REMOTE_ENTRY;
2572
+ return [...entries].sort((a, b) => {
2573
+ const score = (p) => {
2574
+ const parts = p.split("/");
2575
+ const inAssets = parts.length >= 2 && parts[parts.length - 2] === "assets";
2576
+ return `${inAssets ? 0 : 1}:${parts.length}:${p}`;
2577
+ };
2578
+ return score(a).localeCompare(score(b));
2579
+ })[0];
2580
+ }
2581
+ function flattenFederatedBundlePlan(relpaths) {
2582
+ const paths2 = relpaths.map(normalizeZipEntryName).filter((name) => name && !name.endsWith("/") && !name.split("/").includes(".."));
2583
+ const pathset = new Set(paths2);
2584
+ const entry = chooseRemoteEntry([...pathset]);
2585
+ if (!entry) return {};
2586
+ const promoteDirs = /* @__PURE__ */ new Set();
2587
+ const entryDir = parentDir(entry);
2588
+ if (entryDir) promoteDirs.add(entryDir);
2589
+ for (const path11 of pathset) {
2590
+ const parent = parentDir(path11);
2591
+ if (parent === "assets" || parent.endsWith("/assets") && parent.split("/").length - 1 < 2) {
2592
+ promoteDirs.add(parent);
2593
+ }
2319
2594
  }
2320
- let coderunnerId = ref;
2321
- if (!UUID_RE.test(ref)) {
2322
- const all = await listCoderunners(cfg.apiUrl, cfg.workspaceId);
2323
- const match = all.find((c) => c.slug === ref || c.name === ref);
2324
- if (!match) {
2325
- throw new Error(`No coderunner matching "${ref}" in this workspace.`);
2595
+ const destToSrc = {};
2596
+ for (const src of paths2) {
2597
+ const parent = parentDir(src);
2598
+ if (!promoteDirs.has(parent)) continue;
2599
+ const dest = basename(src);
2600
+ if (dest === "release.zip") continue;
2601
+ const lower = dest.toLowerCase();
2602
+ if (dest !== REMOTE_ENTRY && !FLATTEN_SUFFIXES.some((ext) => lower.endsWith(ext))) {
2603
+ continue;
2604
+ }
2605
+ if (pathset.has(dest)) continue;
2606
+ const prev = destToSrc[dest];
2607
+ if (prev === void 0 || src.split("/").length < prev.split("/").length) {
2608
+ destToSrc[dest] = src;
2326
2609
  }
2327
- coderunnerId = match.id;
2328
2610
  }
2329
- return {
2330
- apiUrl: cfg.apiUrl,
2331
- workspaceId: cfg.workspaceId,
2332
- coderunnerId
2333
- };
2611
+ return destToSrc;
2334
2612
  }
2335
- function printInstructions(item) {
2336
- const out = process.stdout;
2337
- out.write(`
2338
- Added ${item.domain} (status: ${item.status})
2339
- `);
2340
- out.write("\nCreate these DNS records at your registrar:\n");
2341
- if (item.cname_target) {
2342
- out.write(` CNAME ${item.domain} \u2192 ${item.cname_target}
2343
- `);
2344
- }
2345
- if (item.verification_record) {
2346
- const r = item.verification_record;
2347
- out.write(` ${r.type} ${r.name} = ${r.value}
2348
- `);
2613
+ function flattenFederatedReleaseZip(bytes) {
2614
+ const zip = new AdmZip2(bytes);
2615
+ const rels = [];
2616
+ const byNorm = /* @__PURE__ */ new Map();
2617
+ for (const entry of zip.getEntries()) {
2618
+ if (entry.isDirectory) continue;
2619
+ const name = normalizeZipEntryName(entry.entryName);
2620
+ rels.push(name);
2621
+ byNorm.set(name, entry);
2349
2622
  }
2350
- out.write(`
2351
- Then run: gf domains verify ${item.domain} --coderunner <id|slug>
2352
- `);
2353
- }
2354
- async function domainsAddCommand(domain, opts) {
2355
- const { apiUrl, workspaceId, coderunnerId } = await resolve(opts);
2356
- const item = await addDomain(apiUrl, workspaceId, coderunnerId, domain);
2357
- if (opts.json) {
2358
- process.stdout.write(`${JSON.stringify(item, null, 2)}
2359
- `);
2360
- return;
2623
+ const plan = flattenFederatedBundlePlan(rels);
2624
+ const keys = Object.keys(plan);
2625
+ if (keys.length > 0) {
2626
+ for (const dest of keys) {
2627
+ const src = plan[dest];
2628
+ const source = byNorm.get(src);
2629
+ if (!source) continue;
2630
+ zip.addFile(dest, source.getData());
2631
+ }
2361
2632
  }
2362
- printInstructions(item);
2633
+ rewriteRemoteEntryInZip(zip);
2634
+ return zip.toBuffer();
2363
2635
  }
2364
- async function domainsListCommand(opts) {
2365
- const { apiUrl, workspaceId, coderunnerId } = await resolve(opts);
2366
- const result = await listDomains(apiUrl, workspaceId, coderunnerId);
2367
- if (opts.json) {
2368
- process.stdout.write(`${JSON.stringify(result.domains, null, 2)}
2369
- `);
2370
- return;
2371
- }
2372
- if (result.domains.length === 0) {
2373
- process.stdout.write("No custom domains.\n");
2374
- return;
2636
+ function rewriteRemoteEntryInZip(zip) {
2637
+ const names2 = zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => normalizeZipEntryName(entry.entryName));
2638
+ const entryRel = chooseRemoteEntry(names2);
2639
+ if (!entryRel) return;
2640
+ const zipEntry = zip.getEntries().find((item) => {
2641
+ if (item.isDirectory) return false;
2642
+ return normalizeZipEntryName(item.entryName) === entryRel;
2643
+ });
2644
+ if (!zipEntry) return;
2645
+ const before = zipEntry.getData().toString("utf8");
2646
+ const after = rewriteRemoteEntryAssetPrefix(before);
2647
+ if (after === before) return;
2648
+ zip.updateFile(zipEntry.entryName, Buffer.from(after, "utf8"));
2649
+ }
2650
+ function federatedReleaseZipHint(slug) {
2651
+ return `Run \`npm run release\` in a kit from \`gf apps init\`, then publish that zip (do not zip Vite dist/ or upload only remoteEntry.js). ${initThenPublishHint(slug)}`;
2652
+ }
2653
+ function federationRemoteName(appId) {
2654
+ const sanitized = appId.replace(/[^a-zA-Z0-9_$]/g, "_");
2655
+ return sanitized.length > 0 ? sanitized : "federatedApp";
2656
+ }
2657
+ function initThenPublishHint(slug) {
2658
+ const s = (slug ?? "").trim() || "<portal-slug>";
2659
+ return `gf apps init --slug ${s} && cd ${s} && npm install && npm run release && gf apps publish --path release.zip`;
2660
+ }
2661
+ function extractReferencedSiblings(remoteEntrySource) {
2662
+ const names2 = /* @__PURE__ */ new Set();
2663
+ for (const match of remoteEntrySource.matchAll(
2664
+ /["']\.\/(?:assets\/)?([^"'/]+\.(?:js|mjs|cjs|css))["']/g
2665
+ )) {
2666
+ if (match[1] !== REMOTE_ENTRY) names2.add(match[1]);
2375
2667
  }
2376
- for (const d of result.domains) {
2377
- const target = d.cname_target ? ` \u2192 ${d.cname_target}` : "";
2378
- process.stdout.write(`${d.domain} [${d.status}]${target}
2379
- `);
2668
+ for (const match of remoteEntrySource.matchAll(
2669
+ /["']([A-Za-z0-9._-]+\.css)["']/g
2670
+ )) {
2671
+ names2.add(match[1]);
2380
2672
  }
2673
+ return [...names2];
2381
2674
  }
2382
- async function domainsRemoveCommand(domain, opts) {
2383
- const { apiUrl, workspaceId, coderunnerId } = await resolve(opts);
2384
- await removeDomain(apiUrl, workspaceId, coderunnerId, domain);
2385
- process.stdout.write(`Removed ${domain}
2386
- `);
2675
+ function extractFederationScope(remoteEntrySource) {
2676
+ return remoteEntrySource.match(/css__([A-Za-z0-9_-]+)__/)?.[1];
2387
2677
  }
2388
- async function domainsVerifyCommand(domain, opts) {
2389
- const { apiUrl, workspaceId, coderunnerId } = await resolve(opts);
2390
- const result = await verifyDomain(apiUrl, workspaceId, coderunnerId, domain);
2391
- if (opts.json) {
2392
- process.stdout.write(`${JSON.stringify(result, null, 2)}
2393
- `);
2394
- return;
2678
+ function zipRootNames(bytes) {
2679
+ const zip = new AdmZip2(bytes);
2680
+ return zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => normalizeZipEntryName(entry.entryName));
2681
+ }
2682
+ function readZipFile(bytes, rel) {
2683
+ const zip = new AdmZip2(bytes);
2684
+ const entry = zip.getEntries().find((item) => {
2685
+ if (item.isDirectory) return false;
2686
+ return normalizeZipEntryName(item.entryName) === rel;
2687
+ });
2688
+ return entry ? entry.getData().toString("utf8") : void 0;
2689
+ }
2690
+ function assertFederatedReleaseZip(bytes, slug) {
2691
+ const hint = initThenPublishHint(slug);
2692
+ const names2 = zipRootNames(bytes);
2693
+ const present = new Set(names2.map((name) => basename(name)));
2694
+ const entryRel = chooseRemoteEntry(names2);
2695
+ if (!entryRel) {
2696
+ throw new Error(`Zip has no remoteEntry.js. ${hint}`);
2395
2697
  }
2396
- const mark = result.verified ? "verified" : "not verified";
2397
- process.stdout.write(`${result.domain}: ${mark} [${result.status}]
2398
- `);
2399
- process.stdout.write(`${result.detail}
2400
- `);
2401
- if (!result.verified) {
2402
- process.exitCode = 1;
2698
+ const source = readZipFile(bytes, entryRel);
2699
+ if (!source) {
2700
+ throw new Error(`Zip has no remoteEntry.js. ${hint}`);
2701
+ }
2702
+ const siblings = extractReferencedSiblings(source);
2703
+ const missing = siblings.filter((name) => !present.has(name));
2704
+ const jsOrCssAtRoot = names2.filter((name) => {
2705
+ if (name.includes("/")) return false;
2706
+ const lower = name.toLowerCase();
2707
+ return lower.endsWith(".js") || lower.endsWith(".mjs") || lower.endsWith(".css");
2708
+ });
2709
+ if (missing.length > 0 || siblings.length === 0 && jsOrCssAtRoot.length < 2) {
2710
+ const detail = missing.length > 0 ? `Missing next to remoteEntry.js: ${missing.join(", ")}.` : "Zip is only remoteEntry.js (Vite chunks were never packed).";
2711
+ throw new Error(
2712
+ `${detail} Do not zip dist/ or upload a lone remoteEntry.js. ${hint}`
2713
+ );
2714
+ }
2715
+ const scope = extractFederationScope(source);
2716
+ if (slug && scope) {
2717
+ const expected = federationRemoteName(slug);
2718
+ if (scope !== expected) {
2719
+ throw new Error(
2720
+ `Federation name is "${scope}" but Shell looks up "${expected}" (from slug ${slug}). Use the starter-kit vite.config (federationRemoteName). ${hint}`
2721
+ );
2722
+ }
2403
2723
  }
2404
2724
  }
2405
2725
 
2406
- // src/commands/secrets.ts
2407
- async function readValue(value) {
2408
- if (value === void 0) {
2409
- throw new Error("Pass a value, or `-` to read from stdin.");
2726
+ // src/commands/apps.ts
2727
+ import AdmZip3 from "adm-zip";
2728
+ var MAX_RELEASE_BYTES = 32 * 1024 * 1024;
2729
+ function slugify2(input) {
2730
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2731
+ return slug || "app";
2732
+ }
2733
+ function formatApp(app) {
2734
+ return `${app.id} ${app.name ?? app.slug ?? ""} ${app.app_kind ?? ""} [${app.status ?? ""}]`;
2735
+ }
2736
+ async function readJsonObject(filePath) {
2737
+ const raw = await fs5.readFile(filePath, "utf8");
2738
+ const parsed = JSON.parse(raw);
2739
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2740
+ throw new Error(`${filePath} must contain a JSON object`);
2410
2741
  }
2411
- if (value === "-") {
2412
- const chunks = [];
2413
- for await (const chunk of process.stdin) {
2414
- chunks.push(Buffer.from(chunk));
2742
+ return parsed;
2743
+ }
2744
+ async function maybeReadManifest(filePath) {
2745
+ if (!filePath) return void 0;
2746
+ return readJsonObject(path6.resolve(filePath));
2747
+ }
2748
+ async function findSiblingManifest(bundlePath) {
2749
+ const dir = await isDirectory(bundlePath) ? bundlePath : path6.dirname(bundlePath);
2750
+ const candidate = path6.join(dir, "groundfloor.manifest.json");
2751
+ try {
2752
+ await fs5.access(candidate);
2753
+ } catch {
2754
+ return void 0;
2755
+ }
2756
+ return { path: candidate, manifest: await readJsonObject(candidate) };
2757
+ }
2758
+ function zipHasRemoteEntry(bytes) {
2759
+ const zip = new AdmZip3(bytes);
2760
+ return zip.getEntries().some((entry) => {
2761
+ if (entry.isDirectory) return false;
2762
+ const name = normalizeZipEntryName(entry.entryName);
2763
+ return name === REMOTE_ENTRY || name.endsWith(`/${REMOTE_ENTRY}`);
2764
+ });
2765
+ }
2766
+ function prepareReleaseZip(bytes, slug) {
2767
+ if (!zipHasRemoteEntry(bytes)) {
2768
+ throw new Error(
2769
+ `Zip has no remoteEntry.js. ${initThenPublishHint(slug)}`
2770
+ );
2771
+ }
2772
+ const flattened = flattenFederatedReleaseZip(bytes);
2773
+ const plan = flattenFederatedBundlePlan(
2774
+ new AdmZip3(bytes).getEntries().filter((e) => !e.isDirectory).map((e) => normalizeZipEntryName(e.entryName))
2775
+ );
2776
+ if (Object.keys(plan).length > 0) {
2777
+ process.stderr.write(
2778
+ `Note: flattened Vite dist/assets layout so chunks sit next to remoteEntry.js.
2779
+ Prefer: ${federatedReleaseZipHint(slug)}
2780
+ `
2781
+ );
2782
+ }
2783
+ assertFederatedReleaseZip(flattened, slug);
2784
+ return flattened;
2785
+ }
2786
+ async function isDirectory(filePath) {
2787
+ try {
2788
+ return (await fs5.stat(filePath)).isDirectory();
2789
+ } catch {
2790
+ return false;
2791
+ }
2792
+ }
2793
+ async function resolveAppId(apiUrl, workspaceId, idOrSlug) {
2794
+ const listed = await listApps(apiUrl, workspaceId);
2795
+ const match = listed.apps.find(
2796
+ (app) => app.id === idOrSlug || app.slug === idOrSlug
2797
+ );
2798
+ if (match) return match;
2799
+ try {
2800
+ return await getApp(apiUrl, workspaceId, idOrSlug);
2801
+ } catch (err) {
2802
+ if (err instanceof CpError && err.status === 404) {
2803
+ throw new Error(
2804
+ `App not found: ${idOrSlug}. Run \`gf apps ls\` or \`gf apps create\`.`
2805
+ );
2415
2806
  }
2416
- return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
2807
+ throw err;
2417
2808
  }
2418
- return value;
2419
2809
  }
2420
- async function secretsListCommand(opts) {
2810
+ async function appsListCommand(opts) {
2421
2811
  const cfg = await requireWorkspace(opts.workspace);
2422
- const data = await listSecrets(cfg.apiUrl, cfg.workspaceId, opts.environment);
2812
+ const data = await listApps(cfg.apiUrl, cfg.workspaceId);
2423
2813
  if (opts.json) {
2424
2814
  process.stdout.write(`${JSON.stringify(data, null, 2)}
2425
2815
  `);
2426
2816
  return;
2427
2817
  }
2428
- if (data.secrets.length === 0) {
2429
- process.stdout.write("No secrets in this workspace.\n");
2818
+ if (data.apps.length === 0) {
2819
+ process.stdout.write("No apps in this workspace.\n");
2430
2820
  return;
2431
2821
  }
2432
- for (const secret of data.secrets) {
2433
- process.stdout.write(`${secret.key}
2822
+ for (const app of data.apps) {
2823
+ process.stdout.write(`${formatApp(app)}
2434
2824
  `);
2435
2825
  }
2436
2826
  }
2437
- async function secretsGetCommand(key, opts) {
2827
+ async function appsGetCommand(appId, opts) {
2438
2828
  const cfg = await requireWorkspace(opts.workspace);
2439
- const secret = await getSecret(
2440
- cfg.apiUrl,
2441
- cfg.workspaceId,
2442
- key,
2443
- opts.environment
2444
- );
2829
+ const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, appId);
2445
2830
  if (opts.json) {
2446
- process.stdout.write(`${JSON.stringify(secret, null, 2)}
2831
+ process.stdout.write(`${JSON.stringify(app, null, 2)}
2447
2832
  `);
2448
2833
  return;
2449
2834
  }
2450
- process.stdout.write(`${secret.value}
2451
- `);
2452
- }
2453
- async function secretsSetCommand(key, value, opts) {
2454
- const cfg = await requireWorkspace(opts.workspace);
2455
- const resolved = await readValue(value);
2456
- const saved = await putSecret(
2457
- cfg.apiUrl,
2458
- cfg.workspaceId,
2459
- key,
2460
- { value: resolved, description: opts.description },
2461
- opts.environment
2462
- );
2463
- process.stdout.write(`Saved secret ${saved.key}
2835
+ process.stdout.write(`${formatApp(app)}
2464
2836
  `);
2465
2837
  }
2466
- async function secretsRmCommand(key, opts) {
2838
+ async function appsCreateCommand(opts) {
2467
2839
  const cfg = await requireWorkspace(opts.workspace);
2468
- await deleteSecret(cfg.apiUrl, cfg.workspaceId, key, opts.environment);
2469
- process.stdout.write(`Deleted secret ${key}
2470
- `);
2471
- }
2472
-
2473
- // src/commands/flags.ts
2474
- var VALUE_TYPES = [
2475
- "boolean",
2476
- "string",
2477
- "number",
2478
- "json"
2479
- ];
2480
- var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2481
- function matchEnvironment(environments2, idOrName) {
2482
- const wanted = idOrName.trim();
2483
- const byId = environments2.find((env) => env.id === wanted);
2484
- if (byId) return byId.id;
2485
- const byName = environments2.filter(
2486
- (env) => env.name.toLowerCase() === wanted.toLowerCase()
2487
- );
2488
- if (byName.length === 1) return byName[0].id;
2489
- const names2 = environments2.map((env) => env.name).join(", ") || "none";
2490
- throw new Error(
2491
- `Environment "${wanted}" not found in this workspace (have: ${names2}). Run \`gf environments ls\`.`
2492
- );
2493
- }
2494
- async function resolveEnvironment(apiUrl, workspaceId, idOrName) {
2495
- if (!idOrName) return void 0;
2496
- if (UUID_RE2.test(idOrName.trim())) return idOrName.trim();
2497
- const environments2 = await listWorkspaceEnvironments(apiUrl, workspaceId);
2498
- return matchEnvironment(environments2, idOrName);
2499
- }
2500
- function parseFlagValue(raw, type) {
2501
- if (type !== void 0 && !VALUE_TYPES.includes(type)) {
2502
- throw new Error(`--type must be one of: ${VALUE_TYPES.join(", ")}`);
2840
+ const kindRaw = (opts.kind ?? "shell_federated").trim();
2841
+ if (kindRaw === "coderunner") {
2842
+ throw new Error(
2843
+ "Do not create app_kind=coderunner. Deploy workloads with `gf deploy`. Use --kind shell_federated or --kind standalone."
2844
+ );
2503
2845
  }
2504
- const lower = raw.trim().toLowerCase();
2505
- const valueType = type ?? (lower === "true" || lower === "false" ? "boolean" : raw.trim() !== "" && Number.isFinite(Number(raw)) ? "number" : "string");
2506
- switch (valueType) {
2507
- case "boolean":
2508
- if (lower !== "true" && lower !== "false") {
2509
- throw new Error("boolean flags take true or false");
2510
- }
2511
- return { value: lower === "true", value_type: "boolean" };
2512
- case "number": {
2513
- const n = Number(raw);
2514
- if (raw.trim() === "" || !Number.isFinite(n)) {
2515
- throw new Error(`"${raw}" is not a number`);
2516
- }
2517
- return { value: n, value_type: "number" };
2846
+ if (kindRaw !== "shell_federated" && kindRaw !== "standalone") {
2847
+ throw new Error(
2848
+ `--kind must be shell_federated or standalone (got ${kindRaw})`
2849
+ );
2850
+ }
2851
+ const kind = kindRaw;
2852
+ const localManifest = await maybeReadManifest(opts.manifest);
2853
+ const name = opts.name?.trim() || (typeof localManifest?.name === "string" ? localManifest.name : "");
2854
+ const slug = opts.slug?.trim() || (typeof localManifest?.appId === "string" ? localManifest.appId : name ? slugify2(name) : "");
2855
+ if (!name) {
2856
+ throw new Error("Pass --name, or a manifest with a name field.");
2857
+ }
2858
+ if (!slug) {
2859
+ throw new Error("Pass --slug, or a manifest with appId.");
2860
+ }
2861
+ if (kind === "standalone" && !opts.primaryCoderunner?.trim()) {
2862
+ throw new Error(
2863
+ "Standalone apps require --primary-coderunner <id> (an existing service coderunner)."
2864
+ );
2865
+ }
2866
+ const body = {
2867
+ name,
2868
+ slug,
2869
+ app_kind: kind
2870
+ };
2871
+ if (opts.environment) body.environment_id = opts.environment;
2872
+ if (opts.primaryCoderunner) {
2873
+ body.primary_coderunner_id = opts.primaryCoderunner;
2874
+ }
2875
+ if (localManifest) body.manifest = localManifest;
2876
+ let app;
2877
+ try {
2878
+ app = await createApp(cfg.apiUrl, cfg.workspaceId, body);
2879
+ } catch (err) {
2880
+ if (err instanceof CpError && err.status === 409) {
2881
+ throw new Error(
2882
+ `App slug ${slug} already exists. Init the kit if you do not have it, then publish:
2883
+ ${initThenPublishHint(slug)}`
2884
+ );
2518
2885
  }
2519
- case "json":
2520
- try {
2521
- return { value: JSON.parse(raw), value_type: "json" };
2522
- } catch {
2523
- throw new Error("--type json needs a valid JSON value");
2524
- }
2525
- default:
2526
- return { value: raw, value_type: "string" };
2886
+ throw err;
2527
2887
  }
2528
- }
2529
- function formatValue(value) {
2530
- return typeof value === "string" ? value : JSON.stringify(value);
2531
- }
2532
- async function flagsListCommand(opts) {
2533
- const cfg = await requireWorkspace(opts.workspace);
2534
- const env = await resolveEnvironment(
2535
- cfg.apiUrl,
2536
- cfg.workspaceId,
2537
- opts.environment
2538
- );
2539
- const data = await listFlags(cfg.apiUrl, cfg.workspaceId, env);
2540
2888
  if (opts.json) {
2541
- process.stdout.write(`${JSON.stringify(data, null, 2)}
2889
+ process.stdout.write(`${JSON.stringify(app, null, 2)}
2542
2890
  `);
2543
2891
  return;
2544
2892
  }
2545
- process.stderr.write(`environment ${data.environment_id}
2893
+ process.stdout.write(`Created ${formatApp(app)}
2546
2894
  `);
2547
- if (data.flags.length === 0) {
2548
- process.stdout.write("No flags in this environment.\n");
2549
- return;
2550
- }
2551
- for (const flag of data.flags) {
2895
+ if (kind === "shell_federated") {
2552
2896
  process.stdout.write(
2553
- `${flag.key} ${flag.value_type} ${formatValue(flag.value)}
2897
+ `Next:
2898
+ gf apps init --slug ${slug}
2899
+ cd ${slug} && npm install && npm run release
2900
+ gf apps publish --path release.zip
2901
+ Do not scaffold src/App.tsx from scratch \u2014 BrowserRouter in App.tsx crashes the Shell.
2554
2902
  `
2555
2903
  );
2556
2904
  }
2557
2905
  }
2558
- async function flagsGetCommand(key, opts) {
2906
+ async function appsPublishCommand(appRef, opts) {
2559
2907
  const cfg = await requireWorkspace(opts.workspace);
2560
- const env = await resolveEnvironment(
2561
- cfg.apiUrl,
2562
- cfg.workspaceId,
2563
- opts.environment
2564
- );
2565
- const flag = await getFlag(cfg.apiUrl, cfg.workspaceId, key, env);
2566
- if (opts.json) {
2567
- process.stdout.write(`${JSON.stringify(flag, null, 2)}
2568
- `);
2569
- return;
2908
+ const inputPath = path6.resolve(opts.path ?? "release.zip");
2909
+ const bundlePath = await isDirectory(inputPath) ? path6.join(inputPath, "release.zip") : inputPath;
2910
+ let bytes;
2911
+ try {
2912
+ bytes = await fs5.readFile(bundlePath);
2913
+ } catch {
2914
+ throw new Error(
2915
+ `No bundle at ${bundlePath}. ${initThenPublishHint()}`
2916
+ );
2570
2917
  }
2571
- process.stdout.write(`${formatValue(flag.value)}
2572
- `);
2573
- }
2574
- async function flagsSetCommand(key, value, opts) {
2575
- const cfg = await requireWorkspace(opts.workspace);
2576
- const env = await resolveEnvironment(
2918
+ if (bytes.byteLength === 0) {
2919
+ throw new Error(`Bundle is empty: ${bundlePath}`);
2920
+ }
2921
+ if (bytes.byteLength > MAX_RELEASE_BYTES) {
2922
+ throw new Error(
2923
+ `Bundle exceeds ${MAX_RELEASE_BYTES} bytes (${bytes.byteLength}).`
2924
+ );
2925
+ }
2926
+ const sibling = await findSiblingManifest(bundlePath);
2927
+ const ref = appRef?.trim() || opts.app?.trim() || (typeof sibling?.manifest.appId === "string" ? sibling.manifest.appId : "");
2928
+ if (!ref) {
2929
+ throw new Error(
2930
+ "Pass an app id or slug, or set appId in groundfloor.manifest.json next to the bundle."
2931
+ );
2932
+ }
2933
+ const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, ref);
2934
+ if (app.app_kind && app.app_kind !== "shell_federated") {
2935
+ throw new Error(
2936
+ `Releases are only for shell_federated apps (this app is ${app.app_kind}). Use \`gf deploy\` for coderunner workloads.`
2937
+ );
2938
+ }
2939
+ const ext = path6.extname(bundlePath).toLowerCase();
2940
+ const isZip = ext === ".zip";
2941
+ if (!isZip) {
2942
+ throw new Error(
2943
+ `Publish release.zip from \`npm run release\`, not a lone ${path6.basename(bundlePath)}. ` + initThenPublishHint(app.slug)
2944
+ );
2945
+ }
2946
+ bytes = prepareReleaseZip(bytes, app.slug);
2947
+ const bundle = "zip";
2948
+ const contentType = "application/zip";
2949
+ if (opts.syncManifest !== false && sibling) {
2950
+ await patchApp(cfg.apiUrl, cfg.workspaceId, app.id, {
2951
+ manifest: sibling.manifest
2952
+ });
2953
+ }
2954
+ const init = await initAppReleaseUpload(cfg.apiUrl, cfg.workspaceId, app.id, {
2955
+ bundle,
2956
+ label: opts.label,
2957
+ // Required since wave 5: the server pins this into the presigned PUT and
2958
+ // checks it against the quota before minting a URL. Omitting it is a 422.
2959
+ size_bytes: bytes.byteLength
2960
+ });
2961
+ await uploadAppReleaseContent(
2577
2962
  cfg.apiUrl,
2578
2963
  cfg.workspaceId,
2579
- opts.environment
2964
+ app.id,
2965
+ init.release.id,
2966
+ bytes,
2967
+ contentType
2580
2968
  );
2581
- const parsed = parseFlagValue(value, opts.type);
2582
- const saved = await putFlag(
2969
+ const release = await finalizeAppRelease(
2583
2970
  cfg.apiUrl,
2584
2971
  cfg.workspaceId,
2585
- key,
2586
- { ...parsed, description: opts.description },
2587
- env
2972
+ app.id,
2973
+ init.release.id
2588
2974
  );
2589
2975
  if (opts.json) {
2590
- process.stdout.write(`${JSON.stringify(saved, null, 2)}
2976
+ process.stdout.write(`${JSON.stringify({ app, release }, null, 2)}
2591
2977
  `);
2592
2978
  return;
2593
2979
  }
2594
2980
  process.stdout.write(
2595
- `Set ${saved.key} = ${formatValue(saved.value)} (${saved.value_type}) in environment ${saved.environment_id}
2596
- `
2981
+ `Published ${app.slug ?? app.id} build ${release.build_number}` + (release.remote_url ? ` ${release.remote_url}` : "") + "\n"
2597
2982
  );
2598
2983
  }
2599
- async function flagsRmCommand(key, opts) {
2600
- const cfg = await requireWorkspace(opts.workspace);
2601
- const env = await resolveEnvironment(
2602
- cfg.apiUrl,
2603
- cfg.workspaceId,
2604
- opts.environment
2605
- );
2606
- await deleteFlag(cfg.apiUrl, cfg.workspaceId, key, env);
2607
- process.stdout.write(`Deleted flag ${key}
2984
+
2985
+ // src/commands/domains.ts
2986
+ var DOMAINS_APPS_ONLY = "Custom domains are available on Apps (standalone / service apps), not on standalone coderunners. Wrap the service coderunner with `gf apps create --kind standalone --primary-coderunner <id>`, then use `gf domains ... --app <id|slug>`.";
2987
+ async function resolve(opts) {
2988
+ const cfg = await resolveConfig({ workspaceId: opts.workspace });
2989
+ if (!cfg.workspaceId) {
2990
+ throw new Error(
2991
+ "No workspace selected. Run `gf workspaces use <id>` or pass --workspace <id>."
2992
+ );
2993
+ }
2994
+ if (!opts.app) {
2995
+ throw new Error(
2996
+ opts.coderunner ? `--coderunner is no longer supported for domains. ${DOMAINS_APPS_ONLY}` : "Specify the app with --app <id|slug>. " + DOMAINS_APPS_ONLY
2997
+ );
2998
+ }
2999
+ const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, opts.app);
3000
+ return { apiUrl: cfg.apiUrl, workspaceId: cfg.workspaceId, appId: app.id };
3001
+ }
3002
+ function printInstructions(item) {
3003
+ const out = process.stdout;
3004
+ out.write(`
3005
+ Added ${item.domain} (status: ${item.status})
3006
+ `);
3007
+ out.write("\nCreate these DNS records at your registrar:\n");
3008
+ if (item.cname_target) {
3009
+ out.write(` CNAME ${item.domain} \u2192 ${item.cname_target}
3010
+ `);
3011
+ }
3012
+ if (item.verification_record) {
3013
+ const r = item.verification_record;
3014
+ out.write(` ${r.type} ${r.name} = ${r.value}
3015
+ `);
3016
+ }
3017
+ out.write(`
3018
+ Then run: gf domains verify ${item.domain} --app <id|slug>
2608
3019
  `);
2609
3020
  }
2610
- async function flagsCompareCommand(source, target, opts) {
2611
- const cfg = await requireWorkspace(opts.workspace);
2612
- const [src, tgt] = await Promise.all([
2613
- resolveEnvironment(cfg.apiUrl, cfg.workspaceId, source),
2614
- resolveEnvironment(cfg.apiUrl, cfg.workspaceId, target)
2615
- ]);
2616
- const data = await compareFlags(cfg.apiUrl, cfg.workspaceId, src, tgt);
3021
+ async function domainsAddCommand(domain, opts) {
3022
+ const { apiUrl, workspaceId, appId } = await resolve(opts);
3023
+ const item = await addDomain(apiUrl, workspaceId, appId, domain);
2617
3024
  if (opts.json) {
2618
- process.stdout.write(`${JSON.stringify(data, null, 2)}
3025
+ process.stdout.write(`${JSON.stringify(item, null, 2)}
2619
3026
  `);
2620
3027
  return;
2621
3028
  }
2622
- if (data.keys.length === 0) {
2623
- process.stdout.write("No flags in either environment.\n");
3029
+ printInstructions(item);
3030
+ }
3031
+ async function domainsListCommand(opts) {
3032
+ const { apiUrl, workspaceId, appId } = await resolve(opts);
3033
+ const result = await listDomains(apiUrl, workspaceId, appId);
3034
+ if (opts.json) {
3035
+ process.stdout.write(`${JSON.stringify(result.domains, null, 2)}
3036
+ `);
2624
3037
  return;
2625
3038
  }
2626
- for (const row of data.keys) {
2627
- process.stdout.write(
2628
- `${row.key} ${row.status} ${formatValue(row.source_value ?? null)} \u2192 ${formatValue(row.target_value ?? null)}
2629
- `
2630
- );
3039
+ if (result.domains.length === 0) {
3040
+ process.stdout.write("No custom domains.\n");
3041
+ return;
2631
3042
  }
2632
- }
2633
- async function flagsPromoteCommand(source, target, opts) {
2634
- const cfg = await requireWorkspace(opts.workspace);
2635
- const [src, tgt] = await Promise.all([
2636
- resolveEnvironment(cfg.apiUrl, cfg.workspaceId, source),
2637
- resolveEnvironment(cfg.apiUrl, cfg.workspaceId, target)
2638
- ]);
2639
- const keys = opts.keys?.split(",").map((k) => k.trim()).filter(Boolean);
2640
- const result = await promoteFlags(cfg.apiUrl, cfg.workspaceId, {
2641
- source_environment_id: src,
2642
- target_environment_id: tgt,
2643
- keys: keys && keys.length > 0 ? keys : void 0,
2644
- mode: opts.overwrite ? "overwrite" : "skip_existing",
2645
- dry_run: Boolean(opts.dryRun)
2646
- });
2647
- if (opts.json) {
2648
- process.stdout.write(`${JSON.stringify(result, null, 2)}
2649
- `);
2650
- return;
2651
- }
2652
- const prefix = result.dry_run ? "[dry run] " : "";
2653
- process.stdout.write(
2654
- `${prefix}copied: ${result.copied.join(", ") || "none"}
2655
- ${prefix}skipped (already set; use --overwrite): ${result.skipped.join(", ") || "none"}
2656
- `
2657
- );
2658
- for (const failure of result.failed) {
2659
- process.stdout.write(`${prefix}failed: ${failure.key} \u2014 ${failure.error}
3043
+ for (const d of result.domains) {
3044
+ const target = d.cname_target ? ` \u2192 ${d.cname_target}` : "";
3045
+ process.stdout.write(`${d.domain} [${d.status}]${target}
2660
3046
  `);
2661
3047
  }
2662
- if (result.failed.length > 0) {
2663
- process.exitCode = 1;
2664
- }
2665
3048
  }
2666
-
2667
- // src/commands/files.ts
2668
- import { promises as fs5 } from "fs";
2669
- import path6 from "path";
2670
- function guessContentType(filename) {
2671
- const ext = path6.extname(filename).toLowerCase();
2672
- const types = {
2673
- ".json": "application/json",
2674
- ".txt": "text/plain",
2675
- ".csv": "text/csv",
2676
- ".pdf": "application/pdf",
2677
- ".png": "image/png",
2678
- ".jpg": "image/jpeg",
2679
- ".jpeg": "image/jpeg",
2680
- ".zip": "application/zip"
2681
- };
2682
- return types[ext] ?? "application/octet-stream";
3049
+ async function domainsRemoveCommand(domain, opts) {
3050
+ const { apiUrl, workspaceId, appId } = await resolve(opts);
3051
+ await removeDomain(apiUrl, workspaceId, appId, domain);
3052
+ process.stdout.write(`Removed ${domain}
3053
+ `);
2683
3054
  }
2684
- async function filesListCommand(opts) {
2685
- const cfg = await requireWorkspace(opts.workspace);
2686
- const data = await listFiles(cfg.apiUrl, cfg.workspaceId);
3055
+ async function domainsVerifyCommand(domain, opts) {
3056
+ const { apiUrl, workspaceId, appId } = await resolve(opts);
3057
+ const result = await verifyDomain(apiUrl, workspaceId, appId, domain);
2687
3058
  if (opts.json) {
2688
- process.stdout.write(`${JSON.stringify(data, null, 2)}
3059
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2689
3060
  `);
2690
3061
  return;
2691
3062
  }
2692
- if (data.files.length === 0) {
2693
- process.stdout.write("No files in this workspace.\n");
2694
- return;
2695
- }
2696
- for (const file of data.files) {
2697
- const size = file.size_bytes != null ? `${file.size_bytes}B` : "";
2698
- process.stdout.write(
2699
- `${file.id} ${file.name ?? ""} ${size} [${file.status ?? ""}]
2700
- `
2701
- );
2702
- }
2703
- }
2704
- async function filesUploadCommand(filePath, opts) {
2705
- const cfg = await requireWorkspace(opts.workspace);
2706
- const resolved = path6.resolve(filePath);
2707
- const buf = await fs5.readFile(resolved);
2708
- const name = opts.name ?? path6.basename(resolved);
2709
- const contentType = guessContentType(name);
2710
- const init = await initFileUpload(cfg.apiUrl, cfg.workspaceId, {
2711
- name,
2712
- content_type: contentType,
2713
- size_bytes: buf.byteLength
2714
- });
2715
- const put = await fetch(init.upload_url, {
2716
- method: "PUT",
2717
- headers: { "Content-Type": contentType },
2718
- body: buf
2719
- });
2720
- if (!put.ok) {
2721
- throw new Error(
2722
- `Upload to object storage failed: ${put.status} ${put.statusText}`
2723
- );
2724
- }
2725
- const file = await finalizeFileUpload(
2726
- cfg.apiUrl,
2727
- cfg.workspaceId,
2728
- init.file.id
2729
- );
2730
- process.stdout.write(`Uploaded ${file.id} ${file.name ?? name}
2731
- `);
2732
- }
2733
- async function filesDownloadCommand(fileId, opts) {
2734
- const cfg = await requireWorkspace(opts.workspace);
2735
- const { download_url: url } = await getFileDownloadUrl(
2736
- cfg.apiUrl,
2737
- cfg.workspaceId,
2738
- fileId
2739
- );
2740
- const res = await fetch(url);
2741
- if (!res.ok) {
2742
- throw new Error(`Download failed: ${res.status} ${res.statusText}`);
2743
- }
2744
- const buf = Buffer.from(await res.arrayBuffer());
2745
- const dest = path6.resolve(opts.out ?? fileId);
2746
- await fs5.writeFile(dest, buf);
2747
- process.stdout.write(`Wrote ${dest}
3063
+ const mark = result.verified ? "verified" : "not verified";
3064
+ process.stdout.write(`${result.domain}: ${mark} [${result.status}]
2748
3065
  `);
2749
- }
2750
- async function filesRmCommand(fileId, opts) {
2751
- const cfg = await requireWorkspace(opts.workspace);
2752
- await deleteFile(cfg.apiUrl, cfg.workspaceId, fileId);
2753
- process.stdout.write(`Deleted file ${fileId}
3066
+ process.stdout.write(`${result.detail}
2754
3067
  `);
2755
- }
2756
-
2757
- // src/commands/apps.ts
2758
- import { promises as fs6 } from "fs";
2759
- import path7 from "path";
2760
-
2761
- // src/release-zip.ts
2762
- import AdmZip2 from "adm-zip";
2763
- var REMOTE_ENTRY = "remoteEntry.js";
2764
- var FLATTEN_SUFFIXES = [".js", ".mjs", ".cjs", ".css", ".map", ".wasm"];
2765
- var IMPORT_ASSETS = /(__federation_import|import)\((['"])\.\/assets\//g;
2766
- function rewriteRemoteEntryAssetPrefix(source) {
2767
- return source.replace(IMPORT_ASSETS, "$1($2./");
2768
- }
2769
- function normalizeZipEntryName(filename) {
2770
- return filename.replace(/\\/g, "/").replace(/^\/+/, "");
2771
- }
2772
- function parentDir(rel) {
2773
- const i = rel.lastIndexOf("/");
2774
- return i === -1 ? "" : rel.slice(0, i);
2775
- }
2776
- function basename(rel) {
2777
- const i = rel.lastIndexOf("/");
2778
- return i === -1 ? rel : rel.slice(i + 1);
2779
- }
2780
- function chooseRemoteEntry(relpaths) {
2781
- const entries = relpaths.filter(
2782
- (p) => p === REMOTE_ENTRY || p.endsWith(`/${REMOTE_ENTRY}`)
2783
- );
2784
- if (entries.length === 0) return void 0;
2785
- if (entries.includes(REMOTE_ENTRY)) return REMOTE_ENTRY;
2786
- return [...entries].sort((a, b) => {
2787
- const score = (p) => {
2788
- const parts = p.split("/");
2789
- const inAssets = parts.length >= 2 && parts[parts.length - 2] === "assets";
2790
- return `${inAssets ? 0 : 1}:${parts.length}:${p}`;
2791
- };
2792
- return score(a).localeCompare(score(b));
2793
- })[0];
2794
- }
2795
- function flattenFederatedBundlePlan(relpaths) {
2796
- const paths2 = relpaths.map(normalizeZipEntryName).filter((name) => name && !name.endsWith("/") && !name.split("/").includes(".."));
2797
- const pathset = new Set(paths2);
2798
- const entry = chooseRemoteEntry([...pathset]);
2799
- if (!entry) return {};
2800
- const promoteDirs = /* @__PURE__ */ new Set();
2801
- const entryDir = parentDir(entry);
2802
- if (entryDir) promoteDirs.add(entryDir);
2803
- for (const path11 of pathset) {
2804
- const parent = parentDir(path11);
2805
- if (parent === "assets" || parent.endsWith("/assets") && parent.split("/").length - 1 < 2) {
2806
- promoteDirs.add(parent);
2807
- }
2808
- }
2809
- const destToSrc = {};
2810
- for (const src of paths2) {
2811
- const parent = parentDir(src);
2812
- if (!promoteDirs.has(parent)) continue;
2813
- const dest = basename(src);
2814
- if (dest === "release.zip") continue;
2815
- const lower = dest.toLowerCase();
2816
- if (dest !== REMOTE_ENTRY && !FLATTEN_SUFFIXES.some((ext) => lower.endsWith(ext))) {
2817
- continue;
2818
- }
2819
- if (pathset.has(dest)) continue;
2820
- const prev = destToSrc[dest];
2821
- if (prev === void 0 || src.split("/").length < prev.split("/").length) {
2822
- destToSrc[dest] = src;
2823
- }
3068
+ if (!result.verified) {
3069
+ process.exitCode = 1;
2824
3070
  }
2825
- return destToSrc;
2826
3071
  }
2827
- function flattenFederatedReleaseZip(bytes) {
2828
- const zip = new AdmZip2(bytes);
2829
- const rels = [];
2830
- const byNorm = /* @__PURE__ */ new Map();
2831
- for (const entry of zip.getEntries()) {
2832
- if (entry.isDirectory) continue;
2833
- const name = normalizeZipEntryName(entry.entryName);
2834
- rels.push(name);
2835
- byNorm.set(name, entry);
3072
+
3073
+ // src/commands/secrets.ts
3074
+ async function readValue(value) {
3075
+ if (value === void 0) {
3076
+ throw new Error("Pass a value, or `-` to read from stdin.");
2836
3077
  }
2837
- const plan = flattenFederatedBundlePlan(rels);
2838
- const keys = Object.keys(plan);
2839
- if (keys.length > 0) {
2840
- for (const dest of keys) {
2841
- const src = plan[dest];
2842
- const source = byNorm.get(src);
2843
- if (!source) continue;
2844
- zip.addFile(dest, source.getData());
3078
+ if (value === "-") {
3079
+ const chunks = [];
3080
+ for await (const chunk of process.stdin) {
3081
+ chunks.push(Buffer.from(chunk));
2845
3082
  }
3083
+ return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
2846
3084
  }
2847
- rewriteRemoteEntryInZip(zip);
2848
- return zip.toBuffer();
2849
- }
2850
- function rewriteRemoteEntryInZip(zip) {
2851
- const names2 = zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => normalizeZipEntryName(entry.entryName));
2852
- const entryRel = chooseRemoteEntry(names2);
2853
- if (!entryRel) return;
2854
- const zipEntry = zip.getEntries().find((item) => {
2855
- if (item.isDirectory) return false;
2856
- return normalizeZipEntryName(item.entryName) === entryRel;
2857
- });
2858
- if (!zipEntry) return;
2859
- const before = zipEntry.getData().toString("utf8");
2860
- const after = rewriteRemoteEntryAssetPrefix(before);
2861
- if (after === before) return;
2862
- zip.updateFile(zipEntry.entryName, Buffer.from(after, "utf8"));
2863
- }
2864
- function federatedReleaseZipHint(slug) {
2865
- return `Run \`npm run release\` in a kit from \`gf apps init\`, then publish that zip (do not zip Vite dist/ or upload only remoteEntry.js). ${initThenPublishHint(slug)}`;
2866
- }
2867
- function federationRemoteName(appId) {
2868
- const sanitized = appId.replace(/[^a-zA-Z0-9_$]/g, "_");
2869
- return sanitized.length > 0 ? sanitized : "federatedApp";
2870
- }
2871
- function initThenPublishHint(slug) {
2872
- const s = (slug ?? "").trim() || "<portal-slug>";
2873
- return `gf apps init --slug ${s} && cd ${s} && npm install && npm run release && gf apps publish --path release.zip`;
2874
- }
2875
- function extractReferencedSiblings(remoteEntrySource) {
2876
- const names2 = /* @__PURE__ */ new Set();
2877
- for (const match of remoteEntrySource.matchAll(
2878
- /["']\.\/(?:assets\/)?([^"'/]+\.(?:js|mjs|cjs|css))["']/g
2879
- )) {
2880
- if (match[1] !== REMOTE_ENTRY) names2.add(match[1]);
2881
- }
2882
- for (const match of remoteEntrySource.matchAll(
2883
- /["']([A-Za-z0-9._-]+\.css)["']/g
2884
- )) {
2885
- names2.add(match[1]);
2886
- }
2887
- return [...names2];
2888
- }
2889
- function extractFederationScope(remoteEntrySource) {
2890
- return remoteEntrySource.match(/css__([A-Za-z0-9_-]+)__/)?.[1];
2891
- }
2892
- function zipRootNames(bytes) {
2893
- const zip = new AdmZip2(bytes);
2894
- return zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => normalizeZipEntryName(entry.entryName));
2895
- }
2896
- function readZipFile(bytes, rel) {
2897
- const zip = new AdmZip2(bytes);
2898
- const entry = zip.getEntries().find((item) => {
2899
- if (item.isDirectory) return false;
2900
- return normalizeZipEntryName(item.entryName) === rel;
2901
- });
2902
- return entry ? entry.getData().toString("utf8") : void 0;
3085
+ return value;
2903
3086
  }
2904
- function assertFederatedReleaseZip(bytes, slug) {
2905
- const hint = initThenPublishHint(slug);
2906
- const names2 = zipRootNames(bytes);
2907
- const present = new Set(names2.map((name) => basename(name)));
2908
- const entryRel = chooseRemoteEntry(names2);
2909
- if (!entryRel) {
2910
- throw new Error(`Zip has no remoteEntry.js. ${hint}`);
2911
- }
2912
- const source = readZipFile(bytes, entryRel);
2913
- if (!source) {
2914
- throw new Error(`Zip has no remoteEntry.js. ${hint}`);
3087
+ async function secretsListCommand(opts) {
3088
+ const cfg = await requireWorkspace(opts.workspace);
3089
+ const data = await listSecrets(cfg.apiUrl, cfg.workspaceId, opts.environment);
3090
+ if (opts.json) {
3091
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
3092
+ `);
3093
+ return;
2915
3094
  }
2916
- const siblings = extractReferencedSiblings(source);
2917
- const missing = siblings.filter((name) => !present.has(name));
2918
- const jsOrCssAtRoot = names2.filter((name) => {
2919
- if (name.includes("/")) return false;
2920
- const lower = name.toLowerCase();
2921
- return lower.endsWith(".js") || lower.endsWith(".mjs") || lower.endsWith(".css");
2922
- });
2923
- if (missing.length > 0 || siblings.length === 0 && jsOrCssAtRoot.length < 2) {
2924
- const detail = missing.length > 0 ? `Missing next to remoteEntry.js: ${missing.join(", ")}.` : "Zip is only remoteEntry.js (Vite chunks were never packed).";
2925
- throw new Error(
2926
- `${detail} Do not zip dist/ or upload a lone remoteEntry.js. ${hint}`
2927
- );
3095
+ if (data.secrets.length === 0) {
3096
+ process.stdout.write("No secrets in this workspace.\n");
3097
+ return;
2928
3098
  }
2929
- const scope = extractFederationScope(source);
2930
- if (slug && scope) {
2931
- const expected = federationRemoteName(slug);
2932
- if (scope !== expected) {
2933
- throw new Error(
2934
- `Federation name is "${scope}" but Shell looks up "${expected}" (from slug ${slug}). Use the starter-kit vite.config (federationRemoteName). ${hint}`
2935
- );
2936
- }
3099
+ for (const secret of data.secrets) {
3100
+ process.stdout.write(`${secret.key}
3101
+ `);
2937
3102
  }
2938
3103
  }
2939
-
2940
- // src/commands/apps.ts
2941
- import AdmZip3 from "adm-zip";
2942
- var MAX_RELEASE_BYTES = 32 * 1024 * 1024;
2943
- function slugify2(input) {
2944
- const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2945
- return slug || "app";
3104
+ async function secretsGetCommand(key, opts) {
3105
+ const cfg = await requireWorkspace(opts.workspace);
3106
+ const secret = await getSecret(
3107
+ cfg.apiUrl,
3108
+ cfg.workspaceId,
3109
+ key,
3110
+ opts.environment
3111
+ );
3112
+ if (opts.json) {
3113
+ process.stdout.write(`${JSON.stringify(secret, null, 2)}
3114
+ `);
3115
+ return;
3116
+ }
3117
+ process.stdout.write(`${secret.value}
3118
+ `);
2946
3119
  }
2947
- function formatApp(app) {
2948
- return `${app.id} ${app.name ?? app.slug ?? ""} ${app.app_kind ?? ""} [${app.status ?? ""}]`;
3120
+ async function secretsSetCommand(key, value, opts) {
3121
+ const cfg = await requireWorkspace(opts.workspace);
3122
+ const resolved = await readValue(value);
3123
+ const saved = await putSecret(
3124
+ cfg.apiUrl,
3125
+ cfg.workspaceId,
3126
+ key,
3127
+ { value: resolved, description: opts.description },
3128
+ opts.environment
3129
+ );
3130
+ process.stdout.write(`Saved secret ${saved.key}
3131
+ `);
2949
3132
  }
2950
- async function readJsonObject(filePath) {
2951
- const raw = await fs6.readFile(filePath, "utf8");
2952
- const parsed = JSON.parse(raw);
2953
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2954
- throw new Error(`${filePath} must contain a JSON object`);
2955
- }
2956
- return parsed;
3133
+ async function secretsRmCommand(key, opts) {
3134
+ const cfg = await requireWorkspace(opts.workspace);
3135
+ await deleteSecret(cfg.apiUrl, cfg.workspaceId, key, opts.environment);
3136
+ process.stdout.write(`Deleted secret ${key}
3137
+ `);
2957
3138
  }
2958
- async function maybeReadManifest(filePath) {
2959
- if (!filePath) return void 0;
2960
- return readJsonObject(path7.resolve(filePath));
3139
+
3140
+ // src/commands/flags.ts
3141
+ var VALUE_TYPES = [
3142
+ "boolean",
3143
+ "string",
3144
+ "number",
3145
+ "json"
3146
+ ];
3147
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3148
+ function matchEnvironment(environments2, idOrName) {
3149
+ const wanted = idOrName.trim();
3150
+ const byId = environments2.find((env) => env.id === wanted);
3151
+ if (byId) return byId.id;
3152
+ const byName = environments2.filter(
3153
+ (env) => env.name.toLowerCase() === wanted.toLowerCase()
3154
+ );
3155
+ if (byName.length === 1) return byName[0].id;
3156
+ const names2 = environments2.map((env) => env.name).join(", ") || "none";
3157
+ throw new Error(
3158
+ `Environment "${wanted}" not found in this workspace (have: ${names2}). Run \`gf environments ls\`.`
3159
+ );
2961
3160
  }
2962
- async function findSiblingManifest(bundlePath) {
2963
- const dir = await isDirectory(bundlePath) ? bundlePath : path7.dirname(bundlePath);
2964
- const candidate = path7.join(dir, "groundfloor.manifest.json");
2965
- try {
2966
- await fs6.access(candidate);
2967
- } catch {
2968
- return void 0;
3161
+ async function resolveEnvironment(apiUrl, workspaceId, idOrName) {
3162
+ if (!idOrName) return void 0;
3163
+ if (UUID_RE.test(idOrName.trim())) return idOrName.trim();
3164
+ const environments2 = await listWorkspaceEnvironments(apiUrl, workspaceId);
3165
+ return matchEnvironment(environments2, idOrName);
3166
+ }
3167
+ function parseFlagValue(raw, type) {
3168
+ if (type !== void 0 && !VALUE_TYPES.includes(type)) {
3169
+ throw new Error(`--type must be one of: ${VALUE_TYPES.join(", ")}`);
3170
+ }
3171
+ const lower = raw.trim().toLowerCase();
3172
+ const valueType = type ?? (lower === "true" || lower === "false" ? "boolean" : raw.trim() !== "" && Number.isFinite(Number(raw)) ? "number" : "string");
3173
+ switch (valueType) {
3174
+ case "boolean":
3175
+ if (lower !== "true" && lower !== "false") {
3176
+ throw new Error("boolean flags take true or false");
3177
+ }
3178
+ return { value: lower === "true", value_type: "boolean" };
3179
+ case "number": {
3180
+ const n = Number(raw);
3181
+ if (raw.trim() === "" || !Number.isFinite(n)) {
3182
+ throw new Error(`"${raw}" is not a number`);
3183
+ }
3184
+ return { value: n, value_type: "number" };
3185
+ }
3186
+ case "json":
3187
+ try {
3188
+ return { value: JSON.parse(raw), value_type: "json" };
3189
+ } catch {
3190
+ throw new Error("--type json needs a valid JSON value");
3191
+ }
3192
+ default:
3193
+ return { value: raw, value_type: "string" };
2969
3194
  }
2970
- return { path: candidate, manifest: await readJsonObject(candidate) };
2971
3195
  }
2972
- function zipHasRemoteEntry(bytes) {
2973
- const zip = new AdmZip3(bytes);
2974
- return zip.getEntries().some((entry) => {
2975
- if (entry.isDirectory) return false;
2976
- const name = normalizeZipEntryName(entry.entryName);
2977
- return name === REMOTE_ENTRY || name.endsWith(`/${REMOTE_ENTRY}`);
2978
- });
3196
+ function formatValue(value) {
3197
+ return typeof value === "string" ? value : JSON.stringify(value);
2979
3198
  }
2980
- function prepareReleaseZip(bytes, slug) {
2981
- if (!zipHasRemoteEntry(bytes)) {
2982
- throw new Error(
2983
- `Zip has no remoteEntry.js. ${initThenPublishHint(slug)}`
2984
- );
2985
- }
2986
- const flattened = flattenFederatedReleaseZip(bytes);
2987
- const plan = flattenFederatedBundlePlan(
2988
- new AdmZip3(bytes).getEntries().filter((e) => !e.isDirectory).map((e) => normalizeZipEntryName(e.entryName))
3199
+ async function flagsListCommand(opts) {
3200
+ const cfg = await requireWorkspace(opts.workspace);
3201
+ const env = await resolveEnvironment(
3202
+ cfg.apiUrl,
3203
+ cfg.workspaceId,
3204
+ opts.environment
2989
3205
  );
2990
- if (Object.keys(plan).length > 0) {
2991
- process.stderr.write(
2992
- `Note: flattened Vite dist/assets layout so chunks sit next to remoteEntry.js.
2993
- Prefer: ${federatedReleaseZipHint(slug)}
3206
+ const data = await listFlags(cfg.apiUrl, cfg.workspaceId, env);
3207
+ if (opts.json) {
3208
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
3209
+ `);
3210
+ return;
3211
+ }
3212
+ process.stderr.write(`environment ${data.environment_id}
3213
+ `);
3214
+ if (data.flags.length === 0) {
3215
+ process.stdout.write("No flags in this environment.\n");
3216
+ return;
3217
+ }
3218
+ for (const flag of data.flags) {
3219
+ process.stdout.write(
3220
+ `${flag.key} ${flag.value_type} ${formatValue(flag.value)}
2994
3221
  `
2995
3222
  );
2996
3223
  }
2997
- assertFederatedReleaseZip(flattened, slug);
2998
- return flattened;
2999
3224
  }
3000
- async function isDirectory(filePath) {
3001
- try {
3002
- return (await fs6.stat(filePath)).isDirectory();
3003
- } catch {
3004
- return false;
3225
+ async function flagsGetCommand(key, opts) {
3226
+ const cfg = await requireWorkspace(opts.workspace);
3227
+ const env = await resolveEnvironment(
3228
+ cfg.apiUrl,
3229
+ cfg.workspaceId,
3230
+ opts.environment
3231
+ );
3232
+ const flag = await getFlag(cfg.apiUrl, cfg.workspaceId, key, env);
3233
+ if (opts.json) {
3234
+ process.stdout.write(`${JSON.stringify(flag, null, 2)}
3235
+ `);
3236
+ return;
3005
3237
  }
3238
+ process.stdout.write(`${formatValue(flag.value)}
3239
+ `);
3006
3240
  }
3007
- async function resolveAppId(apiUrl, workspaceId, idOrSlug) {
3008
- const listed = await listApps(apiUrl, workspaceId);
3009
- const match = listed.apps.find(
3010
- (app) => app.id === idOrSlug || app.slug === idOrSlug
3241
+ async function flagsSetCommand(key, value, opts) {
3242
+ const cfg = await requireWorkspace(opts.workspace);
3243
+ const env = await resolveEnvironment(
3244
+ cfg.apiUrl,
3245
+ cfg.workspaceId,
3246
+ opts.environment
3011
3247
  );
3012
- if (match) return match;
3013
- try {
3014
- return await getApp(apiUrl, workspaceId, idOrSlug);
3015
- } catch (err) {
3016
- if (err instanceof CpError && err.status === 404) {
3017
- throw new Error(
3018
- `App not found: ${idOrSlug}. Run \`gf apps ls\` or \`gf apps create\`.`
3019
- );
3020
- }
3021
- throw err;
3248
+ const parsed = parseFlagValue(value, opts.type);
3249
+ const saved = await putFlag(
3250
+ cfg.apiUrl,
3251
+ cfg.workspaceId,
3252
+ key,
3253
+ { ...parsed, description: opts.description },
3254
+ env
3255
+ );
3256
+ if (opts.json) {
3257
+ process.stdout.write(`${JSON.stringify(saved, null, 2)}
3258
+ `);
3259
+ return;
3022
3260
  }
3261
+ process.stdout.write(
3262
+ `Set ${saved.key} = ${formatValue(saved.value)} (${saved.value_type}) in environment ${saved.environment_id}
3263
+ `
3264
+ );
3023
3265
  }
3024
- async function appsListCommand(opts) {
3266
+ async function flagsRmCommand(key, opts) {
3025
3267
  const cfg = await requireWorkspace(opts.workspace);
3026
- const data = await listApps(cfg.apiUrl, cfg.workspaceId);
3268
+ const env = await resolveEnvironment(
3269
+ cfg.apiUrl,
3270
+ cfg.workspaceId,
3271
+ opts.environment
3272
+ );
3273
+ await deleteFlag(cfg.apiUrl, cfg.workspaceId, key, env);
3274
+ process.stdout.write(`Deleted flag ${key}
3275
+ `);
3276
+ }
3277
+ async function flagsCompareCommand(source, target, opts) {
3278
+ const cfg = await requireWorkspace(opts.workspace);
3279
+ const [src, tgt] = await Promise.all([
3280
+ resolveEnvironment(cfg.apiUrl, cfg.workspaceId, source),
3281
+ resolveEnvironment(cfg.apiUrl, cfg.workspaceId, target)
3282
+ ]);
3283
+ const data = await compareFlags(cfg.apiUrl, cfg.workspaceId, src, tgt);
3027
3284
  if (opts.json) {
3028
3285
  process.stdout.write(`${JSON.stringify(data, null, 2)}
3029
3286
  `);
3030
3287
  return;
3031
3288
  }
3032
- if (data.apps.length === 0) {
3033
- process.stdout.write("No apps in this workspace.\n");
3289
+ if (data.keys.length === 0) {
3290
+ process.stdout.write("No flags in either environment.\n");
3034
3291
  return;
3035
3292
  }
3036
- for (const app of data.apps) {
3037
- process.stdout.write(`${formatApp(app)}
3038
- `);
3293
+ for (const row of data.keys) {
3294
+ process.stdout.write(
3295
+ `${row.key} ${row.status} ${formatValue(row.source_value ?? null)} \u2192 ${formatValue(row.target_value ?? null)}
3296
+ `
3297
+ );
3039
3298
  }
3040
3299
  }
3041
- async function appsGetCommand(appId, opts) {
3300
+ async function flagsPromoteCommand(source, target, opts) {
3042
3301
  const cfg = await requireWorkspace(opts.workspace);
3043
- const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, appId);
3302
+ const [src, tgt] = await Promise.all([
3303
+ resolveEnvironment(cfg.apiUrl, cfg.workspaceId, source),
3304
+ resolveEnvironment(cfg.apiUrl, cfg.workspaceId, target)
3305
+ ]);
3306
+ const keys = opts.keys?.split(",").map((k) => k.trim()).filter(Boolean);
3307
+ const result = await promoteFlags(cfg.apiUrl, cfg.workspaceId, {
3308
+ source_environment_id: src,
3309
+ target_environment_id: tgt,
3310
+ keys: keys && keys.length > 0 ? keys : void 0,
3311
+ mode: opts.overwrite ? "overwrite" : "skip_existing",
3312
+ dry_run: Boolean(opts.dryRun)
3313
+ });
3044
3314
  if (opts.json) {
3045
- process.stdout.write(`${JSON.stringify(app, null, 2)}
3315
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
3046
3316
  `);
3047
3317
  return;
3048
3318
  }
3049
- process.stdout.write(`${formatApp(app)}
3319
+ const prefix = result.dry_run ? "[dry run] " : "";
3320
+ process.stdout.write(
3321
+ `${prefix}copied: ${result.copied.join(", ") || "none"}
3322
+ ${prefix}skipped (already set; use --overwrite): ${result.skipped.join(", ") || "none"}
3323
+ `
3324
+ );
3325
+ for (const failure of result.failed) {
3326
+ process.stdout.write(`${prefix}failed: ${failure.key} \u2014 ${failure.error}
3050
3327
  `);
3051
- }
3052
- async function appsCreateCommand(opts) {
3053
- const cfg = await requireWorkspace(opts.workspace);
3054
- const kindRaw = (opts.kind ?? "shell_federated").trim();
3055
- if (kindRaw === "coderunner") {
3056
- throw new Error(
3057
- "Do not create app_kind=coderunner. Deploy workloads with `gf deploy`. Use --kind shell_federated or --kind standalone."
3058
- );
3059
- }
3060
- if (kindRaw !== "shell_federated" && kindRaw !== "standalone") {
3061
- throw new Error(
3062
- `--kind must be shell_federated or standalone (got ${kindRaw})`
3063
- );
3064
- }
3065
- const kind = kindRaw;
3066
- const localManifest = await maybeReadManifest(opts.manifest);
3067
- const name = opts.name?.trim() || (typeof localManifest?.name === "string" ? localManifest.name : "");
3068
- const slug = opts.slug?.trim() || (typeof localManifest?.appId === "string" ? localManifest.appId : name ? slugify2(name) : "");
3069
- if (!name) {
3070
- throw new Error("Pass --name, or a manifest with a name field.");
3071
- }
3072
- if (!slug) {
3073
- throw new Error("Pass --slug, or a manifest with appId.");
3074
3328
  }
3075
- if (kind === "standalone" && !opts.primaryCoderunner?.trim()) {
3076
- throw new Error(
3077
- "Standalone apps require --primary-coderunner <id> (an existing service coderunner)."
3078
- );
3329
+ if (result.failed.length > 0) {
3330
+ process.exitCode = 1;
3079
3331
  }
3080
- const body = {
3081
- name,
3082
- slug,
3083
- app_kind: kind
3332
+ }
3333
+
3334
+ // src/commands/files.ts
3335
+ import { promises as fs6 } from "fs";
3336
+ import path7 from "path";
3337
+ function guessContentType(filename) {
3338
+ const ext = path7.extname(filename).toLowerCase();
3339
+ const types = {
3340
+ ".json": "application/json",
3341
+ ".txt": "text/plain",
3342
+ ".csv": "text/csv",
3343
+ ".pdf": "application/pdf",
3344
+ ".png": "image/png",
3345
+ ".jpg": "image/jpeg",
3346
+ ".jpeg": "image/jpeg",
3347
+ ".zip": "application/zip"
3084
3348
  };
3085
- if (opts.environment) body.environment_id = opts.environment;
3086
- if (opts.primaryCoderunner) {
3087
- body.primary_coderunner_id = opts.primaryCoderunner;
3088
- }
3089
- if (localManifest) body.manifest = localManifest;
3090
- let app;
3091
- try {
3092
- app = await createApp(cfg.apiUrl, cfg.workspaceId, body);
3093
- } catch (err) {
3094
- if (err instanceof CpError && err.status === 409) {
3095
- throw new Error(
3096
- `App slug ${slug} already exists. Init the kit if you do not have it, then publish:
3097
- ${initThenPublishHint(slug)}`
3098
- );
3099
- }
3100
- throw err;
3101
- }
3349
+ return types[ext] ?? "application/octet-stream";
3350
+ }
3351
+ async function filesListCommand(opts) {
3352
+ const cfg = await requireWorkspace(opts.workspace);
3353
+ const data = await listFiles(cfg.apiUrl, cfg.workspaceId);
3102
3354
  if (opts.json) {
3103
- process.stdout.write(`${JSON.stringify(app, null, 2)}
3355
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
3104
3356
  `);
3105
3357
  return;
3106
3358
  }
3107
- process.stdout.write(`Created ${formatApp(app)}
3108
- `);
3109
- if (kind === "shell_federated") {
3359
+ if (data.files.length === 0) {
3360
+ process.stdout.write("No files in this workspace.\n");
3361
+ return;
3362
+ }
3363
+ for (const file of data.files) {
3364
+ const size = file.size_bytes != null ? `${file.size_bytes}B` : "";
3110
3365
  process.stdout.write(
3111
- `Next:
3112
- gf apps init --slug ${slug}
3113
- cd ${slug} && npm install && npm run release
3114
- gf apps publish --path release.zip
3115
- Do not scaffold src/App.tsx from scratch \u2014 BrowserRouter in App.tsx crashes the Shell.
3366
+ `${file.id} ${file.name ?? ""} ${size} [${file.status ?? ""}]
3116
3367
  `
3117
3368
  );
3118
3369
  }
3119
3370
  }
3120
- async function appsPublishCommand(appRef, opts) {
3371
+ async function filesUploadCommand(filePath, opts) {
3121
3372
  const cfg = await requireWorkspace(opts.workspace);
3122
- const inputPath = path7.resolve(opts.path ?? "release.zip");
3123
- const bundlePath = await isDirectory(inputPath) ? path7.join(inputPath, "release.zip") : inputPath;
3124
- let bytes;
3125
- try {
3126
- bytes = await fs6.readFile(bundlePath);
3127
- } catch {
3128
- throw new Error(
3129
- `No bundle at ${bundlePath}. ${initThenPublishHint()}`
3130
- );
3131
- }
3132
- if (bytes.byteLength === 0) {
3133
- throw new Error(`Bundle is empty: ${bundlePath}`);
3134
- }
3135
- if (bytes.byteLength > MAX_RELEASE_BYTES) {
3136
- throw new Error(
3137
- `Bundle exceeds ${MAX_RELEASE_BYTES} bytes (${bytes.byteLength}).`
3138
- );
3139
- }
3140
- const sibling = await findSiblingManifest(bundlePath);
3141
- const ref = appRef?.trim() || opts.app?.trim() || (typeof sibling?.manifest.appId === "string" ? sibling.manifest.appId : "");
3142
- if (!ref) {
3143
- throw new Error(
3144
- "Pass an app id or slug, or set appId in groundfloor.manifest.json next to the bundle."
3145
- );
3146
- }
3147
- const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, ref);
3148
- if (app.app_kind && app.app_kind !== "shell_federated") {
3149
- throw new Error(
3150
- `Releases are only for shell_federated apps (this app is ${app.app_kind}). Use \`gf deploy\` for coderunner workloads.`
3151
- );
3152
- }
3153
- const ext = path7.extname(bundlePath).toLowerCase();
3154
- const isZip = ext === ".zip";
3155
- if (!isZip) {
3373
+ const resolved = path7.resolve(filePath);
3374
+ const buf = await fs6.readFile(resolved);
3375
+ const name = opts.name ?? path7.basename(resolved);
3376
+ const contentType = guessContentType(name);
3377
+ const init = await initFileUpload(cfg.apiUrl, cfg.workspaceId, {
3378
+ name,
3379
+ content_type: contentType,
3380
+ size_bytes: buf.byteLength
3381
+ });
3382
+ const put = await fetch(init.upload_url, {
3383
+ method: "PUT",
3384
+ headers: { "Content-Type": contentType },
3385
+ body: buf
3386
+ });
3387
+ if (!put.ok) {
3156
3388
  throw new Error(
3157
- `Publish release.zip from \`npm run release\`, not a lone ${path7.basename(bundlePath)}. ` + initThenPublishHint(app.slug)
3389
+ `Upload to object storage failed: ${put.status} ${put.statusText}`
3158
3390
  );
3159
3391
  }
3160
- bytes = prepareReleaseZip(bytes, app.slug);
3161
- const bundle = "zip";
3162
- const contentType = "application/zip";
3163
- if (opts.syncManifest !== false && sibling) {
3164
- await patchApp(cfg.apiUrl, cfg.workspaceId, app.id, {
3165
- manifest: sibling.manifest
3166
- });
3167
- }
3168
- const init = await initAppReleaseUpload(cfg.apiUrl, cfg.workspaceId, app.id, {
3169
- bundle,
3170
- label: opts.label,
3171
- // Required since wave 5: the server pins this into the presigned PUT and
3172
- // checks it against the quota before minting a URL. Omitting it is a 422.
3173
- size_bytes: bytes.byteLength
3174
- });
3175
- await uploadAppReleaseContent(
3392
+ const file = await finalizeFileUpload(
3176
3393
  cfg.apiUrl,
3177
3394
  cfg.workspaceId,
3178
- app.id,
3179
- init.release.id,
3180
- bytes,
3181
- contentType
3395
+ init.file.id
3182
3396
  );
3183
- const release = await finalizeAppRelease(
3397
+ process.stdout.write(`Uploaded ${file.id} ${file.name ?? name}
3398
+ `);
3399
+ }
3400
+ async function filesDownloadCommand(fileId, opts) {
3401
+ const cfg = await requireWorkspace(opts.workspace);
3402
+ const { download_url: url } = await getFileDownloadUrl(
3184
3403
  cfg.apiUrl,
3185
3404
  cfg.workspaceId,
3186
- app.id,
3187
- init.release.id
3405
+ fileId
3188
3406
  );
3189
- if (opts.json) {
3190
- process.stdout.write(`${JSON.stringify({ app, release }, null, 2)}
3191
- `);
3192
- return;
3407
+ const res = await fetch(url);
3408
+ if (!res.ok) {
3409
+ throw new Error(`Download failed: ${res.status} ${res.statusText}`);
3193
3410
  }
3194
- process.stdout.write(
3195
- `Published ${app.slug ?? app.id} build ${release.build_number}` + (release.remote_url ? ` ${release.remote_url}` : "") + "\n"
3196
- );
3411
+ const buf = Buffer.from(await res.arrayBuffer());
3412
+ const dest = path7.resolve(opts.out ?? fileId);
3413
+ await fs6.writeFile(dest, buf);
3414
+ process.stdout.write(`Wrote ${dest}
3415
+ `);
3416
+ }
3417
+ async function filesRmCommand(fileId, opts) {
3418
+ const cfg = await requireWorkspace(opts.workspace);
3419
+ await deleteFile(cfg.apiUrl, cfg.workspaceId, fileId);
3420
+ process.stdout.write(`Deleted file ${fileId}
3421
+ `);
3197
3422
  }
3198
3423
 
3199
3424
  // src/commands/apps-init.ts
@@ -3553,6 +3778,107 @@ async function apiCommand(opts) {
3553
3778
  `);
3554
3779
  }
3555
3780
 
3781
+ // src/commands/usage.ts
3782
+ var DAY_S = 86400;
3783
+ async function usageCommand(opts) {
3784
+ const cfg = await resolveConfig({ workspaceId: opts.workspace });
3785
+ let sinceSeconds;
3786
+ if (opts.sinceDays !== void 0) {
3787
+ const days = Number(opts.sinceDays);
3788
+ if (!Number.isFinite(days) || days < 1 / 24 || days > 400) {
3789
+ throw new Error("--since-days must be a number between 0.05 and 400.");
3790
+ }
3791
+ sinceSeconds = Math.round(days * DAY_S);
3792
+ }
3793
+ let accountId = opts.account?.trim();
3794
+ if (!accountId) {
3795
+ if (!cfg.workspaceId) {
3796
+ throw new Error(
3797
+ "No workspace selected. Pass --account <account-id>, --workspace <id>, or run `gf workspaces use <id>`."
3798
+ );
3799
+ }
3800
+ const workspaces2 = await listWorkspaces(cfg.apiUrl);
3801
+ const ws = workspaces2.find((item) => item.id === cfg.workspaceId);
3802
+ const resolved = ws?.account_id ?? ws?.organization_id;
3803
+ if (!resolved) {
3804
+ throw new Error(
3805
+ `Could not find the account for workspace ${cfg.workspaceId}. Pass --account <account-id>.`
3806
+ );
3807
+ }
3808
+ accountId = resolved;
3809
+ }
3810
+ const [quota, billing] = await Promise.allSettled([
3811
+ getAccountQuota(cfg.apiUrl, accountId),
3812
+ getBillingSummary(cfg.apiUrl, accountId, sinceSeconds)
3813
+ ]);
3814
+ if (opts.json) {
3815
+ const part = (r) => r.status === "fulfilled" ? r.value : { error: errorText(r.reason) };
3816
+ process.stdout.write(
3817
+ `${JSON.stringify({ account_id: accountId, quota: part(quota), billing: part(billing) }, null, 2)}
3818
+ `
3819
+ );
3820
+ if (quota.status === "rejected" && billing.status === "rejected") {
3821
+ process.exitCode = 1;
3822
+ }
3823
+ return;
3824
+ }
3825
+ process.stdout.write(`Account: ${accountId}
3826
+ `);
3827
+ if (quota.status === "fulfilled") {
3828
+ printQuota(quota.value);
3829
+ } else {
3830
+ process.stdout.write(`
3831
+ Quota: unavailable (${errorText(quota.reason)})
3832
+ `);
3833
+ }
3834
+ if (billing.status === "fulfilled") {
3835
+ printBilling(billing.value);
3836
+ } else {
3837
+ process.stdout.write(`
3838
+ Billing: unavailable (${errorText(billing.reason)})
3839
+ `);
3840
+ }
3841
+ if (quota.status === "rejected" && billing.status === "rejected") {
3842
+ process.exitCode = 1;
3843
+ }
3844
+ }
3845
+ function errorText(reason) {
3846
+ if (reason instanceof CpError && reason.status === 403) {
3847
+ return `${reason.message}; your user lacks permission on this account`;
3848
+ }
3849
+ return reason instanceof Error ? reason.message : String(reason);
3850
+ }
3851
+ function printQuota(q) {
3852
+ const out = process.stdout;
3853
+ out.write(`
3854
+ Quota (plan: ${q.plan_tier})
3855
+ `);
3856
+ for (const l of q.limits) {
3857
+ const limit = l.limit == null ? "unlimited" : String(l.limit);
3858
+ out.write(` ${l.label}: ${l.current} / ${limit} ${l.unit}
3859
+ `);
3860
+ }
3861
+ if (q.note) out.write(` note: ${q.note}
3862
+ `);
3863
+ }
3864
+ function printBilling(b) {
3865
+ const out = process.stdout;
3866
+ const label = b.is_estimate === false ? "Billing" : "Billing ESTIMATE (not a final bill)";
3867
+ out.write(`
3868
+ ${label}: ${b.period_start} to ${b.period_end}
3869
+ `);
3870
+ out.write(
3871
+ ` CU: compute ${b.breakdown.compute}, storage ${b.breakdown.storage}, network ${b.breakdown.network}, ai ${b.breakdown.ai} (adjusted ${b.adjusted_cu})
3872
+ `
3873
+ );
3874
+ out.write(` indicative amount: $${b.final_bill_usd.toFixed(2)} USD
3875
+ `);
3876
+ if (b.pricing_final === false) out.write(" pricing is not final (placeholder rates)\n");
3877
+ if (b.ai_usage_truncated) out.write(" AI usage may be understated (log window truncated)\n");
3878
+ if (b.note) out.write(` note: ${b.note}
3879
+ `);
3880
+ }
3881
+
3556
3882
  // src/commands/identities.ts
3557
3883
  async function identitiesListCommand(opts) {
3558
3884
  const cfg = await resolveConfig({ workspaceId: opts.workspace });
@@ -3648,7 +3974,7 @@ m2m.command("token").description(
3648
3974
  "Exchange GROUNDFLOOR_M2M_CLIENT_ID + SECRET for a short CP JWT (not gf token)"
3649
3975
  ).option("-w, --workspace <id>", "Workspace id override").option("--client-id <id>", "M2M client_id (else GROUNDFLOOR_M2M_CLIENT_ID)").option(
3650
3976
  "--client-secret <secret>",
3651
- "M2M secret (else GROUNDFLOOR_M2M_CLIENT_SECRET)"
3977
+ "M2M secret (prefer GROUNDFLOOR_M2M_CLIENT_SECRET: argv is visible in `ps` and shell history)"
3652
3978
  ).option("--json", "Output the full token response as JSON").action(
3653
3979
  (opts) => run(
3654
3980
  () => m2mTokenCommand({
@@ -3669,17 +3995,23 @@ workspaces.command("use <workspaceId>").description("Set the default workspace")
3669
3995
  );
3670
3996
  var coderunner = program.command("coderunner").alias("cr").description("Coderunner commands");
3671
3997
  coderunner.command("ls").alias("list").description("List coderunners in the default (or --workspace) workspace").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => coderunnerListCommand(opts)));
3672
- coderunner.command("status").description("Show coderunner status + current deployment JSON").requiredOption("-c, --coderunner <id|slug>", "Coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => statusCommand(opts)));
3673
- coderunner.command("cancel-deployment").description("Delete the current (or --deployment-id) deployment").requiredOption("-c, --coderunner <id|slug>", "Coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--deployment-id <id>", "Deployment id (default: current deployment)").action(
3998
+ coderunner.command("status").description("Show coderunner status and current deployment (use --json for the full payload)").requiredOption("-c, --coderunner <id|slug>", "Coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => statusCommand(opts)));
3999
+ coderunner.command("cancel-deployment").alias("delete-deployment").description(
4000
+ "DELETE the current (or --deployment-id) deployment. This is not an in-flight cancel: it takes the live service down"
4001
+ ).requiredOption("-c, --coderunner <id|slug>", "Coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--deployment-id <id>", "Deployment id (default: the current live deployment)").option(
4002
+ "-y, --yes",
4003
+ "Skip the confirmation (required when there is no interactive terminal)"
4004
+ ).action(
3674
4005
  (opts) => run(
3675
4006
  () => cancelDeploymentCommand({
3676
4007
  workspace: opts.workspace,
3677
4008
  coderunner: opts.coderunner,
3678
- deploymentId: opts.deploymentId
4009
+ deploymentId: opts.deploymentId,
4010
+ yes: opts.yes
3679
4011
  })
3680
4012
  )
3681
4013
  );
3682
- coderunner.command("logs").description("Show Process Log lines for a coderunner (stdout/stderr)").requiredOption("-c, --coderunner <id|slug>", "Coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--limit <n>", "Max lines (default 50)").option("--since-seconds <n>", "Lookback window in seconds (default 3600)").option("--search <text>", "Substring filter").option("--json", "Output raw JSON").action(
4014
+ coderunner.command("logs").description("Show Process Log lines for a coderunner (stdout/stderr)").requiredOption("-c, --coderunner <id|slug>", "Coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--limit <n>", "Max lines (default 50, max 1000)").option("--since-seconds <n>", "Lookback window in seconds (default 3600, max 604800 = 7 days)").option("--search <text>", "Substring filter").option("--json", "Output raw JSON").action(
3683
4015
  (opts) => run(
3684
4016
  () => logsCommand({
3685
4017
  workspace: opts.workspace,
@@ -3712,13 +4044,15 @@ coderunner.command("vault-token").description(
3712
4044
  })
3713
4045
  )
3714
4046
  );
3715
- var domains = program.command("domains").description("Manage custom domains (CNAME) for a coderunner service");
3716
- domains.command("add <domain>").description("Add a custom domain and print the DNS records to create").requiredOption("-c, --coderunner <id|slug>", "Target coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((domain, opts) => run(() => domainsAddCommand(domain, opts)));
3717
- domains.command("ls").alias("list").description("List custom domains for a coderunner service").requiredOption("-c, --coderunner <id|slug>", "Target coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => domainsListCommand(opts)));
3718
- domains.command("rm <domain>").alias("remove").description("Remove a custom domain from a coderunner service").requiredOption("-c, --coderunner <id|slug>", "Target coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").action(
4047
+ var domains = program.command("domains").description(
4048
+ "Manage custom domains (CNAME) for an App (standalone / service apps). Not available on standalone coderunners"
4049
+ );
4050
+ domains.command("add <domain>").description("Add a custom domain and print the DNS records to create").option("--app <id|slug>", "Target App id or slug (standalone / service app)").option("-c, --coderunner <id|slug>", "Removed: custom domains are not available on standalone coderunners").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((domain, opts) => run(() => domainsAddCommand(domain, opts)));
4051
+ domains.command("ls").alias("list").description("List custom domains for an App").option("--app <id|slug>", "Target App id or slug (standalone / service app)").option("-c, --coderunner <id|slug>", "Removed: custom domains are not available on standalone coderunners").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => domainsListCommand(opts)));
4052
+ domains.command("rm <domain>").alias("remove").description("Remove a custom domain from an App").option("--app <id|slug>", "Target App id or slug (standalone / service app)").option("-c, --coderunner <id|slug>", "Removed: custom domains are not available on standalone coderunners").option("-w, --workspace <id>", "Workspace id override").action(
3719
4053
  (domain, opts) => run(() => domainsRemoveCommand(domain, opts))
3720
4054
  );
3721
- domains.command("verify <domain>").description("Re-check the DNS records for a custom domain").requiredOption("-c, --coderunner <id|slug>", "Target coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action(
4055
+ domains.command("verify <domain>").description("Re-check the DNS records for a custom domain").option("--app <id|slug>", "Target App id or slug (standalone / service app)").option("-c, --coderunner <id|slug>", "Removed: custom domains are not available on standalone coderunners").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action(
3722
4056
  (domain, opts) => run(() => domainsVerifyCommand(domain, opts))
3723
4057
  );
3724
4058
  var secrets = program.command("secrets").description("Manage workspace secrets");
@@ -3807,8 +4141,11 @@ var dataplane = program.command("dataplane").description("Workspace Dataplane se
3807
4141
  dataplane.command("status").description("Show Dataplane setup status").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => dataplaneStatusCommand(opts)));
3808
4142
  dataplane.command("provision").description("Provision Dataplane for this workspace (idempotent)").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => dataplaneProvisionCommand(opts)));
3809
4143
  program.command("deploy").description(
3810
- "Package code and deploy as a coderunner (not an App)"
3811
- ).option("-w, --workspace <id>", "Workspace id override").option("--api-url <url>", "Control Plane base URL").option(
4144
+ "Package code and deploy as a coderunner (not an App). Env var VALUES are not stored: resend them with -e on every deploy"
4145
+ ).option(
4146
+ "-w, --workspace <id>",
4147
+ "Target workspace id. Required with no interactive terminal unless GROUNDFLOOR_WORKSPACE_ID is set; the saved `gf workspaces use` default is only used after an interactive confirm"
4148
+ ).option("--api-url <url>", "Control Plane base URL").option(
3812
4149
  "-c, --coderunner <id>",
3813
4150
  "Deploy into an existing coderunner id (skip find/create)"
3814
4151
  ).option("-n, --name <name>", "Coderunner name (default: folder or repo name)").option("-p, --path <dir>", "Local directory to package (default: cwd)").option("--git <url>", "Clone and deploy a remote git/bitbucket repo").option("--ref <ref>", "Branch/tag/commit for --git").option("--subdir <dir>", "Subdirectory within the source to package").option("--runtime <runtime>", "Language slug from Code Runner (node, python, go, \u2026)").option(
@@ -3819,13 +4156,19 @@ program.command("deploy").description(
3819
4156
  'Cron for --workload-type schedule (or groundfloor.json "schedule")'
3820
4157
  ).option(
3821
4158
  "--app-id <id>",
3822
- "Bind newly created coderunner as a helper of this App (optional)"
4159
+ "Bind a NEWLY CREATED coderunner as a helper of this App (ignored, with a warning, when an existing coderunner is reused)"
3823
4160
  ).option("--cpu <cpu>", "CPU request, e.g. 250m").option("--memory <memory>", "Memory request, e.g. 256Mi").option("-e, --env <KEY=VALUE>", "Environment variable (repeatable)", collect, []).option(
3824
4161
  "--secret <KEY>",
3825
- "Inject this workspace secret by reference (repeatable). Use for *_SECRET keys.",
4162
+ "Inject this workspace secret by reference (repeatable). Only works where the platform has secret injection enabled (off by default); otherwise read secrets at runtime via an M2M client with secrets:read",
3826
4163
  collect,
3827
4164
  []
4165
+ ).option(
4166
+ "-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"
3828
4168
  ).option("-m, --message <text>", "Version description").option("--skip-deploy", "Upload a new version but do not deploy it").action((opts) => run(() => deployCommand(opts)));
4169
+ program.command("usage").description(
4170
+ "Show account quota/usage and the billing estimate (read-only). The account is the billing parent of the workspace, not the workspace itself"
4171
+ ).option("-w, --workspace <id>", "Workspace whose account to show").option("--account <id>", "Account id (overrides the workspace's account)").option("--since-days <n>", "Billing lookback in days (default 30, max 400)").option("--json", "Output raw JSON").action((opts) => run(() => usageCommand(opts)));
3829
4172
  program.command("api").description("Call a Control Plane path (passthrough for gaps in gf commands)").argument("<path>", "Path beginning with /v1/\u2026 Use {workspace_id} for the current workspace").option("-X, --method <method>", "HTTP method", "GET").option("--body <json>", "JSON body for POST/PUT/PATCH").option("-w, --workspace <id>", "Workspace id override").action(
3830
4173
  (path11, opts) => run(
3831
4174
  () => apiCommand({