wawesome 0.14.2 → 0.15.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 +127 -59
  2. package/dist/index.mjs +1224 -949
  3. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -802,17 +802,40 @@ function withBinDirectories(projectDir, existing) {
802
802
  }
803
803
  //#endregion
804
804
  //#region src/build.ts
805
+ const DEFAULT_ENTRY = "src/index.ts";
806
+ /**
807
+ * Read after the project's own build has run: for a **Produced entry point**,
808
+ * that run is what puts the file there.
809
+ */
810
+ function hasHandler(entryInput, config) {
811
+ return Boolean(entryInput || config?.entry) || fs.existsSync("src/index.ts");
812
+ }
805
813
  /**
806
814
  * Bundles user TS/JS entry point into a single optimized ESM JavaScript file using esbuild.
807
815
  */
808
816
  async function buildJs(entryInput, options) {
817
+ const built = await buildHandler(entryInput, readFunctionConfig(), options);
818
+ if (built === null) {
819
+ console.error(`[wawesome] Error: Entry file '${DEFAULT_ENTRY}' not found.`);
820
+ process.exit(1);
821
+ }
822
+ return built;
823
+ }
824
+ /**
825
+ * The bundle this project's handler is built from, or `null` where it declares
826
+ * none at all — nothing named an entry and the default file is not there, which
827
+ * is how a project with only **Document**s says so (ADR-0032). A *declared*
828
+ * entry that is missing is the hard error it always was.
829
+ */
830
+ async function buildHandler(entryInput, config, options) {
809
831
  const startTime = Date.now();
810
- const config = readFunctionConfig();
811
- const entry = entryInput || config?.entry || "src/index.ts";
832
+ const declaredEntry = entryInput || config?.entry;
833
+ const entry = declaredEntry || "src/index.ts";
812
834
  const outPath = path.resolve(options.out);
813
835
  const isVerbose = Boolean(options.verbose);
814
836
  if (config?.build) runProjectBuild(config.build, process.cwd(), isVerbose);
815
837
  if (!fs.existsSync(entry)) {
838
+ if (!declaredEntry) return null;
816
839
  console.error(`[wawesome] Error: Entry file '${entry}' not found.`);
817
840
  process.exit(1);
818
841
  }
@@ -883,7 +906,7 @@ async function buildJs(entryInput, options) {
883
906
  * that has to name this version — `--version`, the dependency a scaffolded
884
907
  * project pins — reads it here, so a release bumps one file.
885
908
  */
886
- const CLI_VERSION = "0.14.2";
909
+ const CLI_VERSION = "0.15.0";
887
910
  //#endregion
888
911
  //#region src/prompt.ts
889
912
  /**
@@ -1504,6 +1527,25 @@ function planLimitAdvice(reason) {
1504
1527
  return `Where to resolve it: ${billingPageUrl()}`;
1505
1528
  }
1506
1529
  //#endregion
1530
+ //#region src/report.ts
1531
+ /** Where a value sits in the deploy's summary block, so every line under one label agrees. */
1532
+ const VALUE_COLUMN = " ".repeat(14);
1533
+ /** The platform's own prose, kept inside the block it is printed in. */
1534
+ function wrapped(said) {
1535
+ const width = 64;
1536
+ const lines = [];
1537
+ let line = "";
1538
+ for (const word of said.split(/\s+/).filter(Boolean)) if (line && `${line} ${word}`.length > width) {
1539
+ lines.push(line);
1540
+ line = word;
1541
+ } else line = line ? `${line} ${word}` : word;
1542
+ if (line) lines.push(line);
1543
+ return lines;
1544
+ }
1545
+ function indented(said) {
1546
+ return wrapped(said).map((line) => `${VALUE_COLUMN}${line}`);
1547
+ }
1548
+ //#endregion
1507
1549
  //#region src/domains.ts
1508
1550
  /**
1509
1551
  * The domain this project declares, refused here rather than at the gateway so
@@ -1685,7 +1727,7 @@ async function detachDomainCommand(hostname, options = {}) {
1685
1727
  * The one place a Tenant can ask. The records used to exist only in the output
1686
1728
  * of a deploy that changed something, so a deploy that outran the vendor left
1687
1729
  * nothing to run but a second deploy, which is refused for changing nothing
1688
- * (#617). Open to a deploy credential carrying `deploy`, since a pipeline asks
1730
+ * (#617). Open to a deploy credential carrying `read:domains`, since a pipeline asks
1689
1731
  * this too.
1690
1732
  */
1691
1733
  async function showDomainCommand(options = {}) {
@@ -1751,7 +1793,6 @@ function whereItGotTo(domain) {
1751
1793
  function whatItIsFor(purpose) {
1752
1794
  return purpose === "certificate" ? "Add this first. It proves you own the name and issues the certificate" : "Add this once the certificate is active. It points traffic here";
1753
1795
  }
1754
- const VALUE_COLUMN = " ".repeat(14);
1755
1796
  /** What the dashboard's empty state shows. Kept the same by hand, not by anything. */
1756
1797
  const DOMAIN_CONFIG_LINE = "\"domain\": \"shop.client.com\"";
1757
1798
  /**
@@ -1829,21 +1870,6 @@ function closingLines(certificateRecordPending, printedBy) {
1829
1870
  if (printedBy === "domains") return indented(`${asking} Run this again in a minute.`);
1830
1871
  return [...indented(`${asking} The deploy is finished either way. To read the record when it lands, run`), `${VALUE_COLUMN} \x1b[36mwawesome domains\x1b[0m`];
1831
1872
  }
1832
- function indented(said) {
1833
- return wrapped(said).map((line) => `${VALUE_COLUMN}${line}`);
1834
- }
1835
- /** The platform's own prose, kept inside the block it is printed in. */
1836
- function wrapped(said) {
1837
- const width = 64;
1838
- const lines = [];
1839
- let line = "";
1840
- for (const word of said.split(/\s+/).filter(Boolean)) if (line && `${line} ${word}`.length > width) {
1841
- lines.push(line);
1842
- line = word;
1843
- } else line = line ? `${line} ${word}` : word;
1844
- if (line) lines.push(line);
1845
- return lines;
1846
- }
1847
1873
  //#endregion
1848
1874
  //#region src/usage.ts
1849
1875
  const USAGE_TIMEOUT_MS = 2e3;
@@ -2031,6 +2057,17 @@ function collectAssets(dir) {
2031
2057
  walk(dir, "");
2032
2058
  return assets;
2033
2059
  }
2060
+ /**
2061
+ * A **Static asset** a browser renders as a page. Read off the extension, as the
2062
+ * content type the platform records is — mirrors `content_type_for_path` in
2063
+ * `server/crates/core/src/features/assets/content_type.rs`.
2064
+ */
2065
+ function isDocument(assetPath) {
2066
+ const name = assetPath.split("/").pop() ?? "";
2067
+ const dot = name.lastIndexOf(".");
2068
+ const extension = dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
2069
+ return extension === "html" || extension === "htm";
2070
+ }
2034
2071
  function hashFile(file) {
2035
2072
  return sha256(fs.readFileSync(file));
2036
2073
  }
@@ -2068,6 +2105,7 @@ const INVOCATION_PREFIX = "/x";
2068
2105
  const SLUG_SEPARATOR = "--";
2069
2106
  const SURFACE_ROUTE = "/v1/invocation-surface";
2070
2107
  const SUBTREE_NOTE = "Every path beneath this address reaches the Function.";
2108
+ const ROOT_FUNCTION_SLUG = "root";
2071
2109
  /**
2072
2110
  * A gateway that serves no form of the address — and the value a client uses for
2073
2111
  * one that did not answer at all, deliberately the same: both name no address,
@@ -2094,6 +2132,19 @@ function publicAddress(surface, tenantSlug, appSlug, functionSlug) {
2094
2132
  if (functionSlug === "root") return base;
2095
2133
  return `${base}/${encodeURIComponent(functionSlug)}`;
2096
2134
  }
2135
+ /**
2136
+ * Where one of a Function's static files answers, `null` where none of them do.
2137
+ *
2138
+ * A **Static asset** is served on the App's own hostname and on no other
2139
+ * address, so a gateway serving only the path form has no address to give for
2140
+ * one — see **Static route** in `CONTEXT.md`.
2141
+ */
2142
+ function assetAddress(surface, tenantSlug, appSlug, functionSlug, assetPath) {
2143
+ if (!surface.contentOrigin) return null;
2144
+ const mount = publicAddress(surface, tenantSlug, appSlug, functionSlug);
2145
+ if (mount === null) return null;
2146
+ return `${mount}/${assetPath.split("/").map(encodeURIComponent).join("/")}`;
2147
+ }
2097
2148
  /** The same address with the app and function still to be chosen. */
2098
2149
  function publicAddressTemplate(surface, tenantSlug) {
2099
2150
  if (surface.contentOrigin) {
@@ -2237,107 +2288,629 @@ async function addressesFor(creds, previous, target) {
2237
2288
  }
2238
2289
  }
2239
2290
  //#endregion
2240
- //#region src/deploy.ts
2241
- function declaredFields(declared) {
2242
- return {
2243
- ...declared.visibility ? { visibility: declared.visibility } : {},
2244
- ...declared.confirmPublish ? { confirm_publish: true } : {}
2245
- };
2291
+ //#region src/cron.ts
2292
+ /** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
2293
+ function asUtcWithSeconds(iso) {
2294
+ const at = new Date(iso);
2295
+ if (Number.isNaN(at.getTime())) return iso;
2296
+ const pad = (n) => String(n).padStart(2, "0");
2297
+ return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())} UTC`;
2298
+ }
2299
+ /** Format execution delay in ms or s. */
2300
+ function formatDelay(delayMs) {
2301
+ if (delayMs === void 0 || delayMs === null || isNaN(delayMs) || delayMs < 0) return "-";
2302
+ if (delayMs < 1e3) return `${delayMs}ms`;
2303
+ return `${(delayMs / 1e3).toFixed(2)}s`;
2304
+ }
2305
+ /** Colorize state names for terminal output. */
2306
+ function colorizeRunState(state, text) {
2307
+ const display = text ?? state;
2308
+ switch (state.toLowerCase()) {
2309
+ case "dispatched":
2310
+ case "success": return `\x1b[32m${display}\x1b[0m`;
2311
+ case "running": return `\x1b[36m${display}\x1b[0m`;
2312
+ case "pending":
2313
+ case "queued": return `\x1b[33m${display}\x1b[0m`;
2314
+ case "missed": return `\x1b[33m${display}\x1b[0m`;
2315
+ case "cancelled":
2316
+ case "skipped": return `\x1b[90m${display}\x1b[0m`;
2317
+ case "failed":
2318
+ case "lost":
2319
+ case "error": return `\x1b[31m${display}\x1b[0m`;
2320
+ default: return display;
2321
+ }
2246
2322
  }
2247
2323
  /**
2248
- * The visibility this project declares, refused here rather than at the gateway
2249
- * so a typo is a message at the keyboard.
2324
+ * Format a list of schedules for a single function.
2250
2325
  */
2251
- function declaredVisibility(value) {
2252
- if (value === void 0 || value === null) return void 0;
2253
- if (value === "public" || value === "private") return value;
2254
- console.error(`[wawesome] Error: 'visibility' in wawesome-function.json is '${String(value)}'.`);
2255
- console.error("[wawesome] It must be \"public\" or \"private\".");
2256
- process.exit(1);
2257
- }
2258
- function reportPublishRefusal(message) {
2259
- console.error(`\n[wawesome] \x1b[31mError: ${message}\x1b[0m`);
2260
- console.error("[wawesome] Nothing was deployed. This Function is private, so its address");
2261
- console.error("[wawesome] resolves for nobody. Publishing it puts it back on the open");
2262
- console.error("[wawesome] internet.");
2263
- console.error("[wawesome] Re-run with \x1B[36mwawesome deploy --publish\x1B[0m to publish it, or put");
2264
- console.error("[wawesome] \x1B[36m\"visibility\": \"private\"\x1B[0m back in wawesome-function.json.\n");
2326
+ function formatFunctionSchedules(funcName, appSlug, schedules) {
2327
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
2328
+ if (schedules.length === 0) return [`[wawesome] No schedules found for '${displayTarget}'.`];
2329
+ const nameWidth = Math.max(8, ...schedules.map((s) => s.name.length));
2330
+ const exprWidth = Math.max(10, ...schedules.map((s) => s.expression.length));
2331
+ return [
2332
+ `\n🗓 \x1b[1mSchedules for '${displayTarget}'\x1b[0m\n`,
2333
+ ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(exprWidth)} ${whenItRuns(schedule)}`),
2334
+ ""
2335
+ ];
2265
2336
  }
2266
2337
  /**
2267
- * Deploy a function: build → upload JS to gateway → promote.
2338
+ * Format schedules across all functions in an App.
2268
2339
  */
2269
- async function deploy(entryInput, options) {
2270
- const isVerbose = Boolean(options.verbose);
2271
- const creds = readCredentials();
2272
- if (!creds) {
2273
- console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
2274
- process.exit(1);
2275
- }
2276
- const config = readFunctionConfig();
2277
- if (!config) {
2278
- console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
2279
- console.error("[wawesome] Run 'wawesome init' to create one, or create it manually.");
2280
- process.exit(1);
2281
- }
2282
- const { app, function: funcName } = config;
2283
- const declared = {
2284
- visibility: declaredVisibility(config.visibility),
2285
- confirmPublish: Boolean(options.publish)
2286
- };
2287
- const domainName = declaredDomain(config.domain);
2288
- if (isVerbose) {
2289
- console.log(`[wawesome:verbose] Deploying to app=${app}, function=${funcName}`);
2290
- console.log(`[wawesome:verbose] Gateway: ${creds.gateway_url}`);
2291
- }
2292
- if (!await confirmDeployTarget(creds, {
2293
- app,
2294
- function: funcName
2295
- })) {
2296
- console.log("[wawesome] Nothing was deployed.");
2297
- process.exit(0);
2340
+ function formatAppSchedules(appSlug, functionSchedules) {
2341
+ if (functionSchedules.reduce((acc, f) => acc + f.schedules.length, 0) === 0) return [`[wawesome] No schedules found for app '${appSlug}'.`];
2342
+ const allSchedules = functionSchedules.flatMap((f) => f.schedules);
2343
+ const nameWidth = Math.max(8, ...allSchedules.map((s) => s.name.length));
2344
+ const exprWidth = Math.max(10, ...allSchedules.map((s) => s.expression.length));
2345
+ const out = [`\n🗓 \x1b[1mSchedules for app '${appSlug}'\x1b[0m\n`];
2346
+ for (const fn of functionSchedules) {
2347
+ out.push(` Function: \x1b[1m${fn.functionName}\x1b[0m`);
2348
+ if (fn.schedules.length === 0) out.push(" (no schedules)");
2349
+ else for (const s of [...fn.schedules].sort((a, b) => a.name.localeCompare(b.name))) out.push(` ${s.name.padEnd(nameWidth)} ${s.expression.padEnd(exprWidth)} ${whenItRuns(s)}`);
2350
+ out.push("");
2298
2351
  }
2299
- let bundlePath;
2300
- if (options.skipBuild) {
2301
- bundlePath = path.resolve(options.out);
2302
- if (!fs.existsSync(bundlePath)) {
2303
- console.error(`[wawesome] Error: Built bundle not found at '${bundlePath}'. Run 'wawesome build' first or remove --skip-build.`);
2304
- process.exit(1);
2305
- }
2306
- console.log(`[wawesome] Skipping build, using existing bundle: ${bundlePath}`);
2307
- } else {
2308
- const buildOpts = {
2309
- out: options.out,
2310
- verbose: options.verbose
2352
+ return out;
2353
+ }
2354
+ /**
2355
+ * Format background run history table.
2356
+ */
2357
+ function formatRunHistoryTable(runs) {
2358
+ if (runs.length === 0) return [];
2359
+ const scheduleColHeader = "SCHEDULE";
2360
+ const stateColHeader = "STATE";
2361
+ const dueColHeader = "DUE AT (UTC)";
2362
+ const startedColHeader = "STARTED AT (UTC)";
2363
+ const delayColHeader = "DELAY";
2364
+ const invColHeader = "INVOCATION ID";
2365
+ const rows = runs.map((run) => {
2366
+ const schedName = run.schedule_name || "manual";
2367
+ const stateDisplay = run.failure_reason ? `${run.state} (${run.failure_reason})` : run.state;
2368
+ const dueAt = asUtcWithSeconds(run.fire_at);
2369
+ const startedAt = run.claimed_at ? asUtcWithSeconds(run.claimed_at) : "-";
2370
+ const delay = formatDelay(run.delay_ms);
2371
+ const invId = run.invocation_id;
2372
+ return {
2373
+ schedName,
2374
+ stateDisplay,
2375
+ rawState: run.state,
2376
+ dueAt,
2377
+ startedAt,
2378
+ delay,
2379
+ invId
2311
2380
  };
2312
- bundlePath = await buildJs(entryInput || config.entry, buildOpts);
2313
- }
2314
- const jsCode = fs.readFileSync(bundlePath, "utf-8");
2315
- if (!jsCode.trim()) {
2316
- console.error("[wawesome] Error: Built bundle is empty.");
2317
- process.exit(1);
2318
- }
2319
- if (isVerbose) console.log(`[wawesome:verbose] Bundle size: ${(Buffer.byteLength(jsCode) / 1024).toFixed(2)} KB`);
2320
- if (options.skipBuild) {
2321
- const findings = scanBundle(jsCode, { bundlePath });
2322
- const refused = refusesDeploy(findings);
2323
- for (const line of surfaceReportLines(findings)) (refused ? console.error : console.warn)(line);
2324
- if (refused) process.exit(1);
2381
+ });
2382
+ const schedWidth = Math.max(8, ...rows.map((r) => r.schedName.length));
2383
+ const stateWidth = Math.max(5, ...rows.map((r) => r.stateDisplay.length));
2384
+ const dueWidth = Math.max(12, ...rows.map((r) => r.dueAt.length));
2385
+ const startedWidth = Math.max(16, ...rows.map((r) => r.startedAt.length));
2386
+ const delayWidth = Math.max(5, ...rows.map((r) => r.delay.length));
2387
+ const invWidth = Math.max(13, ...rows.map((r) => r.invId.length));
2388
+ return [
2389
+ `${scheduleColHeader.padEnd(schedWidth)} | ${stateColHeader.padEnd(stateWidth)} | ${dueColHeader.padEnd(dueWidth)} | ${startedColHeader.padEnd(startedWidth)} | ${delayColHeader.padEnd(delayWidth)} | ${invColHeader.padEnd(invWidth)}`,
2390
+ `${"-".repeat(schedWidth)}-|-${"-".repeat(stateWidth)}-|-${"-".repeat(dueWidth)}-|-${"-".repeat(startedWidth)}-|-${"-".repeat(delayWidth)}-|-${"-".repeat(invWidth)}`,
2391
+ ...rows.map((r) => {
2392
+ const schedPad = r.schedName.padEnd(schedWidth);
2393
+ const coloredState = colorizeRunState(r.rawState, r.stateDisplay);
2394
+ return `${schedPad} | ${r.stateDisplay.length < stateWidth ? coloredState + " ".repeat(stateWidth - r.stateDisplay.length) : coloredState} | ${r.dueAt.padEnd(dueWidth)} | ${r.startedAt.padEnd(startedWidth)} | ${r.delay.padEnd(delayWidth)} | ${r.invId.padEnd(invWidth)}`;
2395
+ })
2396
+ ];
2397
+ }
2398
+ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug, isVerbose) {
2399
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
2400
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
2401
+ let res = await authorizedFetch(scopedUrl, {
2402
+ method: "GET",
2403
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2404
+ });
2405
+ if (res.status === 404 && appSlug) {
2406
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
2407
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
2408
+ try {
2409
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
2410
+ method: "GET",
2411
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2412
+ });
2413
+ if (fallbackRes.ok) res = fallbackRes;
2414
+ } catch {}
2325
2415
  }
2326
- const declaresSchedules = config.schedules !== void 0;
2327
- const assets = config.assets ? collectAssets(path.resolve(config.assets)) : [];
2328
- if (assets.length > 0) await uploadAssets(creds, app, funcName, jsCode, assets, declared, isVerbose, declaresSchedules);
2329
- console.log(`[wawesome] Uploading code for ${app}/${funcName}...`);
2330
- const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
2331
- if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
2332
- const declaredOnTheWire = declaredFields(declared);
2333
- const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await authorizedFetch(uploadUrl, {
2416
+ if (!res.ok) return null;
2417
+ const data = await res.json();
2418
+ return Array.isArray(data.schedules) ? data.schedules : [];
2419
+ }
2420
+ async function fetchAppFunctions(gatewayUrl, tenantJwt, appSlug, isVerbose) {
2421
+ const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions`;
2422
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
2423
+ const res = await authorizedFetch(url, {
2424
+ method: "GET",
2425
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2426
+ });
2427
+ if (!res.ok) return null;
2428
+ return await res.json();
2429
+ }
2430
+ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, reason, isVerbose) {
2431
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
2432
+ const body = reason?.trim() ? JSON.stringify({ reason: reason.trim() }) : void 0;
2433
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
2434
+ let res = await authorizedFetch(scopedUrl, {
2334
2435
  method: "POST",
2335
2436
  headers: {
2336
- Authorization: `Bearer ${creds.tenant_jwt}`,
2337
- "Content-Type": "application/json"
2437
+ Authorization: `Bearer ${tenantJwt}`,
2438
+ ...body ? { "Content-Type": "application/json" } : {}
2439
+ },
2440
+ body
2441
+ });
2442
+ if (res.status === 404 && appSlug) {
2443
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
2444
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
2445
+ try {
2446
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
2447
+ method: "POST",
2448
+ headers: {
2449
+ Authorization: `Bearer ${tenantJwt}`,
2450
+ ...body ? { "Content-Type": "application/json" } : {}
2451
+ },
2452
+ body
2453
+ });
2454
+ if (fallbackRes) res = fallbackRes;
2455
+ } catch {}
2456
+ }
2457
+ return res;
2458
+ }
2459
+ async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, isVerbose) {
2460
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
2461
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
2462
+ let res = await authorizedFetch(scopedUrl, {
2463
+ method: "POST",
2464
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2465
+ });
2466
+ if (res.status === 404 && appSlug) {
2467
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
2468
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
2469
+ try {
2470
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
2471
+ method: "POST",
2472
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2473
+ });
2474
+ if (fallbackRes) res = fallbackRes;
2475
+ } catch {}
2476
+ }
2477
+ return res;
2478
+ }
2479
+ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug, queryParams, isVerbose) {
2480
+ const params = new URLSearchParams();
2481
+ if (queryParams?.limit) params.set("limit", String(queryParams.limit));
2482
+ if (queryParams?.page) params.set("page", String(queryParams.page));
2483
+ if (queryParams?.state && queryParams.state.toLowerCase() !== "all") params.set("state", queryParams.state.toLowerCase());
2484
+ const qs = params.toString() ? `?${params.toString()}` : "";
2485
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs${qs}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
2486
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
2487
+ let res = await authorizedFetch(scopedUrl, {
2488
+ method: "GET",
2489
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2490
+ });
2491
+ if (res.status === 404 && appSlug) {
2492
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
2493
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
2494
+ try {
2495
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
2496
+ method: "GET",
2497
+ headers: { Authorization: `Bearer ${tenantJwt}` }
2498
+ });
2499
+ if (fallbackRes) res = fallbackRes;
2500
+ } catch {}
2501
+ }
2502
+ return res;
2503
+ }
2504
+ function requireCredentials$2() {
2505
+ const creds = readCredentials();
2506
+ if (!creds) {
2507
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
2508
+ process.exit(1);
2509
+ }
2510
+ return creds;
2511
+ }
2512
+ async function listSchedulesCommand(target, options = {}) {
2513
+ const isVerbose = Boolean(options.verbose);
2514
+ const creds = requireCredentials$2();
2515
+ const config = readFunctionConfig();
2516
+ const explicitApp = options.app;
2517
+ const appSlug = explicitApp || config?.app;
2518
+ const funcName = target || options.function || (explicitApp ? void 0 : config?.function);
2519
+ if (funcName) {
2520
+ const schedules = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, isVerbose);
2521
+ if (schedules === null) {
2522
+ if (target && !options.app) {
2523
+ const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, target, isVerbose);
2524
+ if (appFuncs !== null) {
2525
+ const functionSchedules = [];
2526
+ for (const item of appFuncs) {
2527
+ const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, target, isVerbose);
2528
+ functionSchedules.push({
2529
+ functionName: item.function.name,
2530
+ schedules: scheds || []
2531
+ });
2532
+ }
2533
+ const lines = formatAppSchedules(target, functionSchedules);
2534
+ for (const line of lines) console.log(line);
2535
+ return;
2536
+ }
2537
+ }
2538
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
2539
+ process.exit(1);
2540
+ }
2541
+ const lines = formatFunctionSchedules(funcName, appSlug, schedules);
2542
+ for (const line of lines) console.log(line);
2543
+ return;
2544
+ }
2545
+ if (appSlug) {
2546
+ const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, appSlug, isVerbose);
2547
+ if (appFuncs === null) {
2548
+ console.error(`[wawesome] Error: App '${appSlug}' not found.`);
2549
+ process.exit(1);
2550
+ }
2551
+ const functionSchedules = [];
2552
+ for (const item of appFuncs) {
2553
+ const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, appSlug, isVerbose);
2554
+ functionSchedules.push({
2555
+ functionName: item.function.name,
2556
+ schedules: scheds || []
2557
+ });
2558
+ }
2559
+ const lines = formatAppSchedules(appSlug, functionSchedules);
2560
+ for (const line of lines) console.log(line);
2561
+ return;
2562
+ }
2563
+ console.error("[wawesome] Error: Missing function or app name.");
2564
+ console.error("[wawesome] Usage: wawesome cron list [function-or-app] or run inside a function directory with wawesome-function.json.");
2565
+ process.exit(1);
2566
+ }
2567
+ async function pauseScheduleCommand(target, subtarget, options = {}) {
2568
+ const isVerbose = Boolean(options.verbose);
2569
+ const creds = requireCredentials$2();
2570
+ const config = readFunctionConfig();
2571
+ let funcName;
2572
+ let scheduleName;
2573
+ if (target && subtarget) {
2574
+ funcName = target;
2575
+ scheduleName = subtarget;
2576
+ } else if (target) {
2577
+ scheduleName = target;
2578
+ funcName = options.function || config?.function;
2579
+ }
2580
+ if (options.function) funcName = options.function;
2581
+ const appSlug = options.app || config?.app;
2582
+ if (!scheduleName) {
2583
+ console.error("[wawesome] Error: Missing schedule name.");
2584
+ console.error("[wawesome] Usage: wawesome cron pause <schedule-name> [--reason <reason>] or wawesome cron pause <function> <schedule-name>");
2585
+ process.exit(1);
2586
+ }
2587
+ if (!funcName) {
2588
+ console.error("[wawesome] Error: Missing function name.");
2589
+ console.error("[wawesome] Usage: wawesome cron pause <function> <schedule-name> or specify -f/--function <name>.");
2590
+ process.exit(1);
2591
+ }
2592
+ const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
2593
+ if (!res.ok) {
2594
+ const errorBody = await res.text();
2595
+ if (res.status === 401 || res.status === 403) {
2596
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
2597
+ process.exit(1);
2598
+ }
2599
+ if (res.status === 404) {
2600
+ console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
2601
+ process.exit(1);
2602
+ }
2603
+ const refusal = rejectionOf(errorBody, res.status, `Failed to pause schedule (HTTP ${res.status}).`);
2604
+ console.error(`[wawesome] Error: ${refusal.message}`);
2605
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
2606
+ if (advice) console.error(`[wawesome] ${advice}`);
2607
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
2608
+ process.exit(1);
2609
+ }
2610
+ const data = await res.json();
2611
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
2612
+ console.log(`[wawesome] ✔ Paused schedule '${data.schedule.name}' on '${displayTarget}'.`);
2613
+ console.log(` State: ${whenItRuns(data.schedule)} (survives future deploys)`);
2614
+ if (data.schedule.pause_reason) console.log(` Reason: ${data.schedule.pause_reason}`);
2615
+ if (data.cancelled_runs && data.cancelled_runs.length > 0) {
2616
+ const count = data.cancelled_runs.length;
2617
+ console.log(` Cancelled ${count} queued run${count === 1 ? "" : "s"} (never started).`);
2618
+ }
2619
+ if (data.running_runs && data.running_runs.length > 0) {
2620
+ const count = data.running_runs.length;
2621
+ console.log(` Left ${count} in-flight run${count === 1 ? "" : "s"} to complete.`);
2622
+ }
2623
+ }
2624
+ async function resumeScheduleCommand(target, subtarget, options = {}) {
2625
+ const isVerbose = Boolean(options.verbose);
2626
+ const creds = requireCredentials$2();
2627
+ const config = readFunctionConfig();
2628
+ let funcName;
2629
+ let scheduleName;
2630
+ if (target && subtarget) {
2631
+ funcName = target;
2632
+ scheduleName = subtarget;
2633
+ } else if (target) {
2634
+ scheduleName = target;
2635
+ funcName = options.function || config?.function;
2636
+ }
2637
+ if (options.function) funcName = options.function;
2638
+ const appSlug = options.app || config?.app;
2639
+ if (!scheduleName) {
2640
+ console.error("[wawesome] Error: Missing schedule name.");
2641
+ console.error("[wawesome] Usage: wawesome cron resume <schedule-name> or wawesome cron resume <function> <schedule-name>");
2642
+ process.exit(1);
2643
+ }
2644
+ if (!funcName) {
2645
+ console.error("[wawesome] Error: Missing function name.");
2646
+ console.error("[wawesome] Usage: wawesome cron resume <function> <schedule-name> or specify -f/--function <name>.");
2647
+ process.exit(1);
2648
+ }
2649
+ const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
2650
+ if (!res.ok) {
2651
+ const errorBody = await res.text();
2652
+ if (res.status === 401 || res.status === 403) {
2653
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
2654
+ process.exit(1);
2655
+ }
2656
+ if (res.status === 404) {
2657
+ console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
2658
+ process.exit(1);
2659
+ }
2660
+ const refusal = rejectionOf(errorBody, res.status, `Failed to resume schedule (HTTP ${res.status}).`);
2661
+ console.error(`[wawesome] Error: ${refusal.message}`);
2662
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
2663
+ if (advice) console.error(`[wawesome] ${advice}`);
2664
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
2665
+ process.exit(1);
2666
+ }
2667
+ const data = await res.json();
2668
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
2669
+ console.log(`[wawesome] ✔ Resumed schedule '${data.schedule.name}' on '${displayTarget}'.`);
2670
+ console.log(` ${whenItRuns(data.schedule)} (no backfill)`);
2671
+ }
2672
+ async function runHistoryCommand(target, options = {}) {
2673
+ const isVerbose = Boolean(options.verbose);
2674
+ const creds = requireCredentials$2();
2675
+ const config = readFunctionConfig();
2676
+ const funcName = target || options.function || config?.function;
2677
+ const appSlug = options.app || config?.app;
2678
+ if (!funcName) {
2679
+ console.error("[wawesome] Error: Missing function name.");
2680
+ console.error("[wawesome] Usage: wawesome cron history [function-name] or run inside a function directory with wawesome-function.json.");
2681
+ process.exit(1);
2682
+ }
2683
+ const limit = options.limit ? Number(options.limit) : 50;
2684
+ const stateFilter = options.state || options.status;
2685
+ const res = await fetchBackgroundRunsApi(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, {
2686
+ state: stateFilter,
2687
+ limit
2688
+ }, isVerbose);
2689
+ if (!res.ok) {
2690
+ const errorBody = await res.text();
2691
+ if (res.status === 401 || res.status === 403) {
2692
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
2693
+ process.exit(1);
2694
+ }
2695
+ if (res.status === 404) {
2696
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
2697
+ process.exit(1);
2698
+ }
2699
+ const refusal = rejectionOf(errorBody, res.status, `Failed to fetch run history (HTTP ${res.status}).`);
2700
+ console.error(`[wawesome] Error: ${refusal.message}`);
2701
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
2702
+ if (advice) console.error(`[wawesome] ${advice}`);
2703
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
2704
+ process.exit(1);
2705
+ }
2706
+ const data = await res.json();
2707
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
2708
+ if (!data.runs || data.runs.length === 0) {
2709
+ console.log(`[wawesome] No run history found for '${displayTarget}'.`);
2710
+ return;
2711
+ }
2712
+ const total = data.total ?? data.runs.length;
2713
+ console.log(`\n📜 \x1b[1mRun history for '${displayTarget}' (Showing ${data.runs.length} of ${total} records)\x1b[0m\n`);
2714
+ const tableLines = formatRunHistoryTable(data.runs);
2715
+ for (const line of tableLines) console.log(line);
2716
+ console.log("");
2717
+ }
2718
+ async function cronCommand(action, target, subtarget, options = {}) {
2719
+ const normAction = (action || "").toLowerCase().trim();
2720
+ if (!normAction || normAction === "list" || normAction === "ls") return listSchedulesCommand(target, options);
2721
+ if (normAction === "pause") return pauseScheduleCommand(target, subtarget, options);
2722
+ if (normAction === "resume") return resumeScheduleCommand(target, subtarget, options);
2723
+ if (normAction === "history" || normAction === "runs" || normAction === "log" || normAction === "logs") return runHistoryCommand(target, options);
2724
+ return listSchedulesCommand(action, options);
2725
+ }
2726
+ //#endregion
2727
+ //#region src/mount-shadow.ts
2728
+ /**
2729
+ * The address a **Document** answers at, relative to its Function's mount: a
2730
+ * request path whose last segment carries no extension has `/index.html`
2731
+ * appended, so the Document at `about/index.html` is the address `about`.
2732
+ */
2733
+ function documentAddressPath(assetPath) {
2734
+ return assetPath.replace(/(^|\/)index\.html$/, "");
2735
+ }
2736
+ /** See **Mount** in `CONTEXT.md` for why only the root Function's can be. */
2737
+ function mountsCanShadow(functionSlug) {
2738
+ return functionSlug === ROOT_FUNCTION_SLUG;
2739
+ }
2740
+ /**
2741
+ * The Documents this deploy carries that a sibling Function's **Mount** owns.
2742
+ *
2743
+ * A private sibling is not one of them: it registers no mount, so an address
2744
+ * reaching for it falls through to the root Function and the Document is
2745
+ * served after all.
2746
+ */
2747
+ function shadowedDocuments(functionSlug, documentPaths, siblings) {
2748
+ if (!mountsCanShadow(functionSlug)) return [];
2749
+ const owners = new Set(siblings.filter((sibling) => sibling.slug !== functionSlug && sibling.visibility !== "private").map((sibling) => sibling.slug));
2750
+ return documentPaths.flatMap((assetPath) => {
2751
+ const owner = documentAddressPath(assetPath).split("/")[0];
2752
+ return owner && owners.has(owner) ? [{
2753
+ path: assetPath,
2754
+ owner
2755
+ }] : [];
2756
+ });
2757
+ }
2758
+ /**
2759
+ * The same, with both addresses on them.
2760
+ *
2761
+ * Best-effort like the address the deploy prints: this is asked after the
2762
+ * deploy has landed, so a gateway that will not say which Functions the App
2763
+ * holds costs a warning and never the deploy.
2764
+ */
2765
+ async function shadowedAddresses(creds, addresses, functionSlug, documentPaths, isVerbose) {
2766
+ const { app, surface, tenantSlug } = addresses;
2767
+ if (documentPaths.length === 0 || !mountsCanShadow(functionSlug)) return [];
2768
+ let siblings;
2769
+ try {
2770
+ siblings = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, app, isVerbose);
2771
+ } catch (err) {
2772
+ if (isVerbose) console.log(`[wawesome:verbose] Could not read the App's other Functions: ${errorText(err)}`);
2773
+ return [];
2774
+ }
2775
+ if (!siblings) return [];
2776
+ return shadowedDocuments(functionSlug, documentPaths, siblings.map((item) => ({
2777
+ slug: item.function.slug,
2778
+ visibility: item.function.visibility
2779
+ }))).map((document) => {
2780
+ const answered = documentAddressPath(document.path);
2781
+ const page = surface && tenantSlug ? assetAddress(surface, tenantSlug, app, functionSlug, answered) : null;
2782
+ const mount = surface && tenantSlug ? publicAddress(surface, tenantSlug, app, document.owner) : null;
2783
+ return {
2784
+ ...document,
2785
+ page: page ?? `/${answered}`,
2786
+ mount: mount ?? `/${document.owner}`
2787
+ };
2788
+ });
2789
+ }
2790
+ /** What the deploy says about a page it landed that nothing will reach. */
2791
+ function shadowingLines(shadowed) {
2792
+ if (shadowed.length === 0) return [];
2793
+ const lines = [];
2794
+ for (const [index, document] of shadowed.entries()) lines.push("", ` ${index === 0 ? "Shadowed: " : " "} \x1b[33m${document.path}\x1b[0m`, ...indented(`This page would answer at ${document.page}, and that address belongs to the '${document.owner}' Function: it answers ${document.mount} and everything beneath it, so the page is not served there.`));
2795
+ return [
2796
+ ...lines,
2797
+ `${VALUE_COLUMN}Nothing was refused — the two Functions are versioned`,
2798
+ `${VALUE_COLUMN}independently, and either one may deploy first.`
2799
+ ];
2800
+ }
2801
+ //#endregion
2802
+ //#region src/deploy.ts
2803
+ function declaredFields(declared) {
2804
+ return {
2805
+ ...declared.visibility ? { visibility: declared.visibility } : {},
2806
+ ...declared.confirmPublish ? { confirm_publish: true } : {}
2807
+ };
2808
+ }
2809
+ /**
2810
+ * The visibility this project declares, refused here rather than at the gateway
2811
+ * so a typo is a message at the keyboard.
2812
+ */
2813
+ function declaredVisibility(value) {
2814
+ if (value === void 0 || value === null) return void 0;
2815
+ if (value === "public" || value === "private") return value;
2816
+ console.error(`[wawesome] Error: 'visibility' in wawesome-function.json is '${String(value)}'.`);
2817
+ console.error("[wawesome] It must be \"public\" or \"private\".");
2818
+ process.exit(1);
2819
+ }
2820
+ function reportPublishRefusal(message) {
2821
+ console.error(`\n[wawesome] \x1b[31mError: ${message}\x1b[0m`);
2822
+ console.error("[wawesome] Nothing was deployed. This Function is private, so its address");
2823
+ console.error("[wawesome] resolves for nobody. Publishing it puts it back on the open");
2824
+ console.error("[wawesome] internet.");
2825
+ console.error("[wawesome] Re-run with \x1B[36mwawesome deploy --publish\x1B[0m to publish it, or put");
2826
+ console.error("[wawesome] \x1B[36m\"visibility\": \"private\"\x1B[0m back in wawesome-function.json.\n");
2827
+ }
2828
+ /**
2829
+ * Deploy a function: build → upload JS to gateway → promote.
2830
+ */
2831
+ async function deploy(entryInput, options) {
2832
+ const isVerbose = Boolean(options.verbose);
2833
+ const creds = readCredentials();
2834
+ if (!creds) {
2835
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
2836
+ process.exit(1);
2837
+ }
2838
+ const config = readFunctionConfig();
2839
+ if (!config) {
2840
+ console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
2841
+ console.error("[wawesome] Run 'wawesome init' to create one, or create it manually.");
2842
+ process.exit(1);
2843
+ }
2844
+ const { app, function: funcName } = config;
2845
+ const declared = {
2846
+ visibility: declaredVisibility(config.visibility),
2847
+ confirmPublish: Boolean(options.publish)
2848
+ };
2849
+ const domainName = declaredDomain(config.domain);
2850
+ if (isVerbose) {
2851
+ console.log(`[wawesome:verbose] Deploying to app=${app}, function=${funcName}`);
2852
+ console.log(`[wawesome:verbose] Gateway: ${creds.gateway_url}`);
2853
+ }
2854
+ if (!await confirmDeployTarget(creds, {
2855
+ app,
2856
+ function: funcName
2857
+ })) {
2858
+ console.log("[wawesome] Nothing was deployed.");
2859
+ process.exit(0);
2860
+ }
2861
+ let bundlePath;
2862
+ if (options.skipBuild) {
2863
+ const built = path.resolve(options.out);
2864
+ if (!hasHandler(entryInput, config)) bundlePath = null;
2865
+ else if (fs.existsSync(built)) {
2866
+ bundlePath = built;
2867
+ console.log(`[wawesome] Skipping build, using existing bundle: ${built}`);
2868
+ } else {
2869
+ console.error(`[wawesome] Error: Built bundle not found at '${built}'. Run 'wawesome build' first or remove --skip-build.`);
2870
+ process.exit(1);
2871
+ }
2872
+ } else bundlePath = await buildHandler(entryInput, config, {
2873
+ out: options.out,
2874
+ verbose: options.verbose
2875
+ });
2876
+ let jsCode = null;
2877
+ if (bundlePath !== null) {
2878
+ jsCode = fs.readFileSync(bundlePath, "utf-8");
2879
+ if (!jsCode.trim()) {
2880
+ console.error("[wawesome] Error: Built bundle is empty.");
2881
+ process.exit(1);
2882
+ }
2883
+ if (isVerbose) console.log(`[wawesome:verbose] Bundle size: ${(Buffer.byteLength(jsCode) / 1024).toFixed(2)} KB`);
2884
+ if (options.skipBuild) {
2885
+ const findings = scanBundle(jsCode, { bundlePath });
2886
+ const refused = refusesDeploy(findings);
2887
+ for (const line of surfaceReportLines(findings)) (refused ? console.error : console.warn)(line);
2888
+ if (refused) process.exit(1);
2889
+ }
2890
+ }
2891
+ const declaresSchedules = config.schedules !== void 0;
2892
+ const assets = config.assets ? collectAssets(path.resolve(config.assets)) : [];
2893
+ const documents = assets.filter((asset) => isDocument(asset.path));
2894
+ if (jsCode === null && documents.length === 0) {
2895
+ console.error(`[wawesome] Error: Entry file '${DEFAULT_ENTRY}' not found, and this project carries no pages.`);
2896
+ console.error("[wawesome] A deploy carries a handler, pages, or both. Write that file, name");
2897
+ console.error("[wawesome] another one with \x1B[36m\"entry\"\x1B[0m in wawesome-function.json, or point");
2898
+ console.error("[wawesome] \x1B[36m\"assets\"\x1B[0m at a directory holding an index.html.");
2899
+ process.exit(1);
2900
+ }
2901
+ if (assets.length > 0) await uploadAssets(creds, app, funcName, jsCode ?? "", assets, declared, isVerbose, declaresSchedules);
2902
+ console.log(`[wawesome] Uploading code for ${app}/${funcName}...`);
2903
+ const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
2904
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
2905
+ const declaredOnTheWire = declaredFields(declared);
2906
+ const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 || jsCode === null ? await authorizedFetch(uploadUrl, {
2907
+ method: "POST",
2908
+ headers: {
2909
+ Authorization: `Bearer ${creds.tenant_jwt}`,
2910
+ "Content-Type": "application/json"
2338
2911
  },
2339
2912
  body: JSON.stringify({
2340
- code: jsCode,
2913
+ ...jsCode !== null ? { code: jsCode } : {},
2341
2914
  ...assets.length > 0 ? { assets: manifestOf(assets) } : {},
2342
2915
  ...declaresSchedules ? { schedules: config.schedules } : {},
2343
2916
  ...declaredOnTheWire
@@ -2412,14 +2985,21 @@ async function deploy(entryInput, options) {
2412
2985
  });
2413
2986
  let address = null;
2414
2987
  let surface = null;
2988
+ let tenantSlug = null;
2415
2989
  if (visibility === "public") try {
2416
2990
  const { slug } = await resolveWorkspace(creds);
2991
+ tenantSlug = slug;
2417
2992
  surface = await fetchInvocationSurface(creds.gateway_url);
2418
2993
  address = publicAddress(surface, slug, app, funcName);
2419
2994
  } catch (err) {
2420
2995
  if (isVerbose) console.log(`[wawesome:verbose] Could not resolve the workspace address: ${err instanceof Error ? err.message : err}`);
2421
2996
  }
2422
2997
  const domain = domainName ? await applyDeclaredDomain(creds, app, domainName) : await findUndeclaredDomain(creds, app);
2998
+ const shadowed = visibility === "public" ? await shadowedAddresses(creds, {
2999
+ app,
3000
+ surface,
3001
+ tenantSlug
3002
+ }, funcName, documents.map((document) => document.path), isVerbose) : [];
2423
3003
  let headroom = null;
2424
3004
  try {
2425
3005
  headroom = headroomLines(await fetchTenantUsage(creds));
@@ -2432,7 +3012,8 @@ async function deploy(entryInput, options) {
2432
3012
  console.log(`\n App: ${app}`);
2433
3013
  console.log(` Function: ${funcName}`);
2434
3014
  if (version !== void 0) console.log(` Version: ${version}${nothingBuilt ? " (already live, nothing promoted)" : ""}`);
2435
- if (assets.length > 0) console.log(` Assets: ${assets.length}`);
3015
+ for (const line of handlerLines(bundlePath)) console.log(line);
3016
+ if (assets.length > 0) console.log(` Assets: ${assets.length}${pageCount(documents.length)}`);
2436
3017
  console.log(` Visibility: ${visibility}`);
2437
3018
  if (schedules.length > 0) {
2438
3019
  console.log("");
@@ -2450,6 +3031,7 @@ async function deploy(entryInput, options) {
2450
3031
  console.log(" for local development.");
2451
3032
  }
2452
3033
  if (domain) for (const line of domainLines(domain)) console.log(line);
3034
+ for (const line of shadowingLines(shadowed)) console.log(line);
2453
3035
  if (headroom) {
2454
3036
  console.log("");
2455
3037
  for (const line of headroom) console.log(line);
@@ -2464,6 +3046,15 @@ async function deploy(entryInput, options) {
2464
3046
  domain
2465
3047
  };
2466
3048
  }
3049
+ /** What this deploy landed of the two things a **Version** carries. */
3050
+ function handlerLines(bundlePath) {
3051
+ if (bundlePath !== null) return [` Handler: ${path.relative(process.cwd(), bundlePath) || bundlePath}`];
3052
+ return [" Handler: \x1B[33mnone\x1B[0m", ...indented("This Version carries no handler at all. It serves the pages it carries, and no code runs.")];
3053
+ }
3054
+ function pageCount(pages) {
3055
+ if (pages === 0) return "";
3056
+ return ` (${pages} page${pages === 1 ? "" : "s"})`;
3057
+ }
2467
3058
  /**
2468
3059
  * Ask the gateway which of this deploy's files it does not hold, and send only
2469
3060
  * those.
@@ -4565,920 +5156,600 @@ async function invokeCommand(target, options = {}) {
4565
5156
  const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4566
5157
  if (advice) console.error(`[wawesome] ${advice}`);
4567
5158
  if (isVerbose && res.status !== 409 && res.status !== 402) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4568
- process.exit(1);
4569
- }
4570
- const data = await res.json();
4571
- const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4572
- console.log(`[wawesome] ⏳ Run ${data.invocation_id} queued for '${displayTarget}'...`);
4573
- if (options.follow === false) return;
4574
- await followRun(creds.gateway_url, creds.tenant_jwt, data.invocation_id, isVerbose);
4575
- }
4576
- //#endregion
4577
- //#region src/workspace.ts
4578
- function requireCredentials$2() {
4579
- const creds = readCredentials();
4580
- if (!creds) {
4581
- console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
4582
- process.exit(1);
4583
- }
4584
- return creds;
4585
- }
4586
- /**
4587
- * Show the workspace's name, address, and whether that address is still free to
4588
- * change — the rename is offered here rather than discovered by attempting it.
4589
- */
4590
- async function showWorkspace(options) {
4591
- const creds = requireCredentials$2();
4592
- let tenant;
4593
- try {
4594
- tenant = await fetchTenantDetails(creds);
4595
- } catch (err) {
4596
- console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
4597
- process.exit(1);
4598
- }
4599
- console.log("\n[wawesome] Workspace:");
4600
- console.log(` Name: ${tenant.name}`);
4601
- console.log(` Address: ${tenant.tenant_slug}`);
4602
- console.log(` Tenant: ${tenant.id}`);
4603
- if (options.verbose) {
4604
- const template = publicAddressTemplate(await invocationSurfaceOrNone(creds.gateway_url), tenant.tenant_slug);
4605
- if (template) {
4606
- console.log(` URLs: ${template}`);
4607
- console.log(` ${SUBTREE_NOTE}`);
4608
- }
4609
- }
4610
- if (tenant.slug_locked) console.log("\n 🔒 The address is fixed. A Function version has been promoted, and live URLs carry it.");
4611
- else {
4612
- console.log("\n The address can still be changed: \x1B[36mwawesome workspace rename <name>\x1B[0m");
4613
- console.log(" It locks for good the first time you deploy.");
4614
- }
4615
- console.log("");
4616
- }
4617
- /**
4618
- * Change the workspace's public address.
4619
- *
4620
- * The lock is read from the gateway before asking, so a user who cannot rename
4621
- * is told why instead of being walked into a rejection. The rejection is still
4622
- * handled: a promotion landing between the two calls is the one case where the
4623
- * gateway knows something this command could not.
4624
- */
4625
- async function renameWorkspace(slug, options) {
4626
- const creds = requireCredentials$2();
4627
- if (!slug || !slug.trim()) {
4628
- console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
4629
- process.exit(1);
4630
- return;
4631
- }
4632
- try {
4633
- const tenant = await fetchTenantDetails(creds);
4634
- if (tenant.slug_locked) {
4635
- console.error("\n[wawesome] \x1B[31mThe workspace address can no longer be changed.\x1B[0m");
4636
- console.error(`[wawesome] '${tenant.tenant_slug}' is fixed: a Function version has been promoted, and live URLs already carry it.`);
4637
- process.exit(1);
4638
- return;
4639
- }
4640
- if (options.verbose) console.log(`[wawesome:verbose] Renaming '${tenant.tenant_slug}' → '${slug}'`);
4641
- const result = await renameTenantSlug(creds, slug.trim());
4642
- console.log("\n======================================================");
4643
- console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
4644
- console.log("======================================================");
4645
- console.log(`\n Address: ${result.tenant_slug}`);
4646
- console.log(` Previous: ${result.previous_slug} (still resolves)`);
4647
- console.log("======================================================\n");
4648
- } catch (err) {
4649
- if (err instanceof GatewayError) {
4650
- console.error(`\n[wawesome] \x1b[31m${err.message}\x1b[0m`);
4651
- const { text } = renameAdvice(err.reason);
4652
- if (text) console.error(`[wawesome] ${text}`);
4653
- console.error("");
4654
- process.exit(1);
4655
- return;
4656
- }
4657
- console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
4658
- process.exit(1);
4659
- }
4660
- }
4661
- /** Dispatch for `wawesome workspace [action]`. */
4662
- async function workspaceCommand(action, target, options) {
4663
- if (!action || action === "show" || action === "info") return showWorkspace(options);
4664
- if (action === "rename") return renameWorkspace(target, options);
4665
- console.error(`[wawesome] Error: Unknown workspace action '${action}'.`);
4666
- console.error("[wawesome] Usage: wawesome workspace [show|rename <name>]");
4667
- process.exit(1);
4668
- }
4669
- //#endregion
4670
- //#region src/deploy-credentials.ts
4671
- const SECRET_SHOWN_ONCE = "Copy it now. This is the only time it will be shown. If you lose it, revoke this credential and mint another.";
4672
- const REGENERATED_SECRET_SHOWN_ONCE = "Copy it now. This is the only time it will be shown. If you lose it, regenerate this credential again.";
4673
- function requireCredentials$1() {
4674
- const creds = readCredentials();
4675
- if (!creds) {
4676
- console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
4677
- process.exit(1);
4678
- }
4679
- return creds;
4680
- }
4681
- function fail(message, ...rest) {
4682
- console.error(`[wawesome] Error: ${message}`);
4683
- for (const line of rest) console.error(`[wawesome] ${line}`);
4684
- process.exit(1);
4685
- }
4686
- /** A repeated flag and a comma-separated one, read as the same list. */
4687
- function asList(raw) {
4688
- if (raw === void 0) return void 0;
4689
- const words = (Array.isArray(raw) ? raw : [raw]).flatMap((word) => String(word).split(",")).map((word) => word.trim()).filter((word) => word.length > 0);
4690
- return words.length > 0 ? words : void 0;
4691
- }
4692
- function parseExpiry(raw) {
4693
- if (raw === void 0) return void 0;
4694
- const asked = String(raw).trim().toLowerCase();
4695
- if (asked === "never") return "never";
4696
- const days = /^(\d+)\s*d(ays?)?$/.exec(asked) ?? /^(\d+)$/.exec(asked);
4697
- if (!days) fail(`'${raw}' is not an expiry. Name a number of days (--expires 30) or 'never' (--expires never).`);
4698
- return { days: Number(days[1]) };
4699
- }
4700
- /**
4701
- * What a target may be repeated back as.
4702
- *
4703
- * Somebody revoking a leak pastes the whole secret as often as the prefix, and
4704
- * a refusal that echoed it would write it into a shell history and a CI log —
4705
- * for the one feature built on the secret being shown exactly once.
4706
- */
4707
- function asShown(target) {
4708
- if (!target.startsWith("wawe_")) return target;
4709
- const shown = credentialPrefix(target);
4710
- return shown === target ? target : `${shown}…`;
4711
- }
4712
- /** The Apps a credential names, or `null` where it names none and reaches all. */
4713
- function namedApps(apps) {
4714
- return apps && apps.scope === "only" ? apps.apps.join(", ") : null;
4715
- }
4716
- /** `2026-11-30 12:00 UTC`. Minutes, because nothing here is read to the second. */
4717
- function asUtc(iso) {
4718
- if (!iso) return "never";
4719
- const at = new Date(iso);
4720
- if (Number.isNaN(at.getTime())) return iso;
4721
- return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
4722
- }
4723
- function isExpired(credential, now) {
4724
- if (!credential.expires_at) return false;
4725
- const at = new Date(credential.expires_at).getTime();
4726
- return !Number.isNaN(at) && at <= now;
4727
- }
4728
- function statusOf(credential, now) {
4729
- if (credential.revoked_at) return "revoked";
4730
- if (isExpired(credential, now)) return "expired";
4731
- return "active";
4732
- }
4733
- async function refusal(res, fallback) {
4734
- if (res.status === 401 || res.status === 403) return new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
4735
- return asGatewayError(res, fallback);
4736
- }
4737
- async function mintCredential(creds, payload) {
4738
- const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, {
4739
- method: "POST",
4740
- headers: {
4741
- Authorization: `Bearer ${creds.tenant_jwt}`,
4742
- "Content-Type": "application/json"
4743
- },
4744
- body: JSON.stringify(payload)
4745
- });
4746
- if (!res.ok) throw await refusal(res, `Failed to mint the credential (HTTP ${res.status}).`);
4747
- return await res.json();
4748
- }
4749
- async function fetchCredentials(creds) {
4750
- const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
4751
- if (!res.ok) throw await refusal(res, `Failed to read the credentials (HTTP ${res.status}).`);
4752
- return (await res.json()).credentials ?? [];
4753
- }
4754
- async function regenerateCredential(creds, credentialId, payload) {
4755
- const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}/regenerate`, {
4756
- method: "POST",
4757
- headers: {
4758
- Authorization: `Bearer ${creds.tenant_jwt}`,
4759
- "Content-Type": "application/json"
4760
- },
4761
- body: JSON.stringify(payload)
4762
- });
4763
- if (!res.ok) throw await refusal(res, `Failed to regenerate the credential (HTTP ${res.status}).`);
4764
- return await res.json();
4765
- }
4766
- async function revokeCredential(creds, credentialId) {
4767
- const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}/revoke`, {
4768
- method: "POST",
4769
- headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
4770
- });
4771
- if (!res.ok) throw await refusal(res, `Failed to revoke the credential (HTTP ${res.status}).`);
4772
- return await res.json();
4773
- }
4774
- async function deleteCredential(creds, credentialId) {
4775
- const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}`, {
4776
- method: "DELETE",
4777
- headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
4778
- });
4779
- if (!res.ok) throw await refusal(res, `Failed to delete the credential (HTTP ${res.status}).`);
4780
- }
4781
- /**
4782
- * Mint one credential.
4783
- *
4784
- * The secret goes to stdout on a line of its own and everything a person reads
4785
- * goes to stderr, so `wawesome credentials mint ci > secret` writes the secret
4786
- * and not one character besides.
4787
- */
4788
- async function mintCredentialCommand(name, options) {
4789
- if (!name || !name.trim()) fail("No name given. Usage: wawesome credentials mint <name> [--capability deploy] [--app <app>] [--expires 90|never]", "The name is what tells the CI one from the agent one a year from now.");
4790
- const creds = requireCredentials$1();
4791
- const payload = { name: name.trim() };
4792
- const capabilities = asList(options.capability);
4793
- if (capabilities) payload.capabilities = capabilities;
4794
- const apps = asList(options.app);
4795
- if (apps) payload.apps = apps;
4796
- const expiry = parseExpiry(options.expires);
4797
- if (expiry !== void 0) payload.expiry = expiry;
4798
- if (options.verbose) {
4799
- console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials`);
4800
- console.error(`[wawesome:verbose] Payload: ${JSON.stringify({ ...payload })}`);
5159
+ process.exit(1);
4801
5160
  }
4802
- let credential;
4803
- try {
4804
- credential = await mintCredential(creds, payload);
4805
- } catch (err) {
4806
- fail(errorText(err));
5161
+ const data = await res.json();
5162
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
5163
+ console.log(`[wawesome] ⏳ Run ${data.invocation_id} queued for '${displayTarget}'...`);
5164
+ if (options.follow === false) return;
5165
+ await followRun(creds.gateway_url, creds.tenant_jwt, data.invocation_id, isVerbose);
5166
+ }
5167
+ //#endregion
5168
+ //#region src/workspace.ts
5169
+ function requireCredentials$1() {
5170
+ const creds = readCredentials();
5171
+ if (!creds) {
5172
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
5173
+ process.exit(1);
4807
5174
  }
4808
- reveal(`Minted deploy credential '${credential.name}'.`, credential, SECRET_SHOWN_ONCE);
5175
+ return creds;
4809
5176
  }
4810
5177
  /**
4811
- * Hand the secret over, once.
4812
- *
4813
- * The secret goes to stdout on a line of its own and everything a person reads
4814
- * goes to stderr, so `wawesome credentials mint ci > secret` writes the secret
4815
- * and not one character besides.
5178
+ * Show the workspace's name, address, and whether that address is still free to
5179
+ * change — the rename is offered here rather than discovered by attempting it.
4816
5180
  */
4817
- function reveal(headline, credential, shownOnce) {
4818
- console.error(`\n[wawesome] ✔ ${headline}\n`);
4819
- console.error(` Prefix: ${credential.prefix}`);
4820
- console.error(` Capabilities: ${credential.capabilities.join(", ")}`);
4821
- console.error(` Apps: ${namedApps(credential.apps) ?? "every App in the workspace"}`);
4822
- console.error(` Expires: ${asUtc(credential.expires_at)}`);
4823
- console.error(`\n ${shownOnce}\n`);
4824
- console.log(credential.secret);
4825
- console.error("");
4826
- }
4827
- async function listCredentialsCommand(options) {
5181
+ async function showWorkspace(options) {
4828
5182
  const creds = requireCredentials$1();
4829
- if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
4830
- let credentials;
5183
+ let tenant;
4831
5184
  try {
4832
- credentials = await fetchCredentials(creds);
5185
+ tenant = await fetchTenantDetails(creds);
4833
5186
  } catch (err) {
4834
- fail(errorText(err));
4835
- }
4836
- if (credentials.length === 0) {
4837
- console.log("\n[wawesome] This workspace has no deploy credentials.");
4838
- console.log("[wawesome] Mint one: \x1B[36mwawesome credentials mint <name>\x1B[0m\n");
4839
- return;
5187
+ console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
5188
+ process.exit(1);
4840
5189
  }
4841
- console.log("\n🔑 \x1B[1mDeploy credentials\x1B[0m\n");
4842
- for (const line of formatCredentials(credentials, Date.now())) console.log(line);
4843
- console.log("");
4844
- }
4845
- /** Never the secret: no read after minting carries one. */
4846
- function formatCredentials(credentials, now) {
4847
- const rows = credentials.map((credential) => ({
4848
- name: credential.name,
4849
- prefix: credential.prefix,
4850
- capabilities: credential.capabilities.join(", "),
4851
- apps: namedApps(credential.apps) ?? "every App",
4852
- lastUsed: asUtc(credential.last_used_at),
4853
- expires: asUtc(credential.expires_at),
4854
- status: statusOf(credential, now)
4855
- }));
4856
- const columns = [
4857
- {
4858
- header: "NAME",
4859
- of: (row) => row.name
4860
- },
4861
- {
4862
- header: "PREFIX",
4863
- of: (row) => row.prefix
4864
- },
4865
- {
4866
- header: "CAPABILITIES",
4867
- of: (row) => row.capabilities
4868
- },
4869
- {
4870
- header: "APPS",
4871
- of: (row) => row.apps
4872
- },
4873
- {
4874
- header: "LAST USED",
4875
- of: (row) => row.lastUsed
4876
- },
4877
- {
4878
- header: "EXPIRES",
4879
- of: (row) => row.expires
4880
- },
4881
- {
4882
- header: "STATUS",
4883
- of: (row) => row.status
5190
+ console.log("\n[wawesome] Workspace:");
5191
+ console.log(` Name: ${tenant.name}`);
5192
+ console.log(` Address: ${tenant.tenant_slug}`);
5193
+ console.log(` Tenant: ${tenant.id}`);
5194
+ if (options.verbose) {
5195
+ const template = publicAddressTemplate(await invocationSurfaceOrNone(creds.gateway_url), tenant.tenant_slug);
5196
+ if (template) {
5197
+ console.log(` URLs: ${template}`);
5198
+ console.log(` ${SUBTREE_NOTE}`);
4884
5199
  }
4885
- ];
4886
- const widths = columns.map((column) => Math.max(column.header.length, ...rows.map((row) => column.of(row).length)));
4887
- const line = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i])).join(" | ")}`.trimEnd();
4888
- return [
4889
- line(columns.map((column) => column.header)),
4890
- ` ${widths.map((width) => "-".repeat(width)).join("-|-")}`,
4891
- ...rows.map((row) => line(columns.map((column) => column.of(row))))
4892
- ];
4893
- }
4894
- /**
4895
- * Which credential a user meant by a name or a prefix.
4896
- *
4897
- * A partial prefix matches, because the point of the displayable prefix is
4898
- * matching one against a CI settings page by eye — and a partial that names
4899
- * more than one is refused rather than guessed at, since the guess would be a
4900
- * revocation.
4901
- */
4902
- function matchCredentials(credentials, target) {
4903
- const typed = target.trim();
4904
- const named = credentials.filter((credential) => credential.name === typed);
4905
- if (named.length > 0) return named;
4906
- const wanted = typed.startsWith("wawe_") ? credentialPrefix(typed) : typed;
4907
- return credentials.filter((credential) => credential.prefix.startsWith(wanted));
4908
- }
4909
- /** The one credential a typed name or prefix means, or an exit saying why none. */
4910
- async function resolveCredential(creds, target, options) {
4911
- if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
4912
- let credentials;
4913
- try {
4914
- credentials = await fetchCredentials(creds);
4915
- } catch (err) {
4916
- fail(errorText(err));
4917
5200
  }
4918
- const matched = matchCredentials(credentials, target);
4919
- if (matched.length === 0) fail(`No deploy credential named or prefixed '${asShown(target)}' in this workspace.`, "Run 'wawesome credentials list' to see what this workspace has.");
4920
- if (matched.length > 1) fail(`'${asShown(target)}' names more than one credential:`, ...matched.map((credential) => ` ${credential.name} (${credential.prefix})`), "Name one of them, or give more of its prefix.");
4921
- return matched[0];
5201
+ if (tenant.slug_locked) console.log("\n 🔒 The address is fixed. A Function version has been promoted, and live URLs carry it.");
5202
+ else {
5203
+ console.log("\n The address can still be changed: \x1B[36mwawesome workspace rename <name>\x1B[0m");
5204
+ console.log(" It locks for good the first time you deploy.");
5205
+ }
5206
+ console.log("");
4922
5207
  }
4923
5208
  /**
4924
- * Replace a credential's secret where it stands, keeping the name a pipeline's
4925
- * settings already refer to.
5209
+ * Change the workspace's public address.
4926
5210
  *
4927
- * Confirmed like a revoke, because it is one for whatever holds the old secret:
4928
- * the pipeline stops on its very next request until somebody gives it the new
4929
- * one.
5211
+ * The lock is read from the gateway before asking, so a user who cannot rename
5212
+ * is told why instead of being walked into a rejection. The rejection is still
5213
+ * handled: a promotion landing between the two calls is the one case where the
5214
+ * gateway knows something this command could not.
4930
5215
  */
4931
- async function regenerateCredentialCommand(target, options) {
4932
- if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials regenerate <name-or-prefix>");
4933
- const creds = requireCredentials$1();
4934
- const credential = await resolveCredential(creds, target, options);
4935
- if (credential.revoked_at) fail(`'${credential.name}' (${credential.prefix}) was revoked at ${asUtc(credential.revoked_at)}, and nothing un-revokes one.`, `Mint a replacement, or delete this one first to free its name for it.`);
4936
- const payload = {};
4937
- const expiry = parseExpiry(options.expires);
4938
- if (expiry !== void 0) payload.expiry = expiry;
4939
- if (!options.yes) {
4940
- if (!isInteractive()) fail(`Regenerating asks for confirmation, and there is nobody to ask.`, `Pass --yes to regenerate '${credential.name}' (${credential.prefix}) without being asked.`);
4941
- let confirmed;
4942
- try {
4943
- confirmed = await confirm({
4944
- message: `Regenerate '${credential.name}' (${credential.prefix})? Anything holding the old secret stops working at once and has to be given the new one.`,
4945
- default: false
4946
- });
4947
- } catch {
4948
- confirmed = false;
4949
- }
4950
- if (!confirmed) {
4951
- console.error(`[wawesome] Cancelled. '${credential.name}' still works.`);
4952
- return;
4953
- }
4954
- }
4955
- if (options.verbose) {
4956
- console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials/${credential.id}/regenerate`);
4957
- console.error(`[wawesome:verbose] Payload: ${JSON.stringify(payload)}`);
4958
- }
4959
- let regenerated;
4960
- try {
4961
- regenerated = await regenerateCredential(creds, credential.id, payload);
4962
- } catch (err) {
4963
- if (err instanceof GatewayError && err.status === 404) fail(`'${credential.name}' (${credential.prefix}) no longer exists in this workspace.`);
4964
- fail(errorText(err));
4965
- }
4966
- reveal(`Regenerated '${regenerated.name}'. Anything still presenting the old secret is refused from its very next request.`, regenerated, REGENERATED_SECRET_SHOWN_ONCE);
4967
- }
4968
- async function revokeCredentialCommand(target, options) {
4969
- if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials revoke <name-or-prefix>");
5216
+ async function renameWorkspace(slug, options) {
4970
5217
  const creds = requireCredentials$1();
4971
- const credential = await resolveCredential(creds, target, options);
4972
- if (credential.revoked_at) {
4973
- console.log(`[wawesome] '${credential.name}' (${credential.prefix}) was already revoked at ${asUtc(credential.revoked_at)}. Nothing to do.`);
5218
+ if (!slug || !slug.trim()) {
5219
+ console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
5220
+ process.exit(1);
4974
5221
  return;
4975
5222
  }
4976
- if (!options.yes) {
4977
- if (!isInteractive()) fail(`Revoking asks for confirmation, and there is nobody to ask.`, `Pass --yes to revoke '${credential.name}' (${credential.prefix}) without being asked.`);
4978
- let confirmed;
4979
- try {
4980
- confirmed = await confirm({
4981
- message: `Revoke '${credential.name}' (${credential.prefix})? Nothing un-revokes it.`,
4982
- default: false
4983
- });
4984
- } catch {
4985
- confirmed = false;
4986
- }
4987
- if (!confirmed) {
4988
- console.log(`[wawesome] Cancelled. '${credential.name}' still works.`);
4989
- return;
4990
- }
4991
- }
4992
- if (options.verbose) console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials/${credential.id}/revoke`);
4993
- let revoked;
4994
5223
  try {
4995
- revoked = await revokeCredential(creds, credential.id);
4996
- } catch (err) {
4997
- if (err instanceof GatewayError && err.status === 404) fail(`'${credential.name}' (${credential.prefix}) no longer exists in this workspace.`);
4998
- fail(errorText(err));
4999
- }
5000
- console.log(`\n[wawesome] ✔ Revoked '${revoked.name}' (${revoked.prefix}).`);
5001
- console.log("[wawesome] Anything still presenting it is refused from its very next request.\n");
5002
- }
5003
- async function deleteCredentialCommand(target, options) {
5004
- if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials delete <name-or-prefix>");
5005
- const creds = requireCredentials$1();
5006
- const credential = await resolveCredential(creds, target, options);
5007
- const named = `'${credential.name}' (${credential.prefix})`;
5008
- if (statusOf(credential, Date.now()) === "active") fail(`${named} still works, and only a credential that has stopped working is deleted.`, `Revoke it first: wawesome credentials revoke ${credential.name}`);
5009
- if (!options.yes) {
5010
- if (!isInteractive()) fail("Deleting asks for confirmation, and there is nobody to ask.", `Pass --yes to delete ${named} without being asked.`);
5011
- console.log(`\n[wawesome] Deleting ${named} forgets it.`);
5012
- console.log(`[wawesome] It leaves the listing, and the name '${credential.name}' comes free.`);
5013
- console.log("[wawesome] Anything still presenting its secret is told the secret is unknown, rather");
5014
- console.log("[wawesome] than that somebody here revoked it.\n");
5015
- let confirmed;
5016
- try {
5017
- confirmed = await confirm({
5018
- message: `Delete ${named}?`,
5019
- default: false
5020
- });
5021
- } catch {
5022
- confirmed = false;
5023
- }
5024
- if (!confirmed) {
5025
- console.log(`[wawesome] Cancelled. ${named} is still listed.`);
5224
+ const tenant = await fetchTenantDetails(creds);
5225
+ if (tenant.slug_locked) {
5226
+ console.error("\n[wawesome] \x1B[31mThe workspace address can no longer be changed.\x1B[0m");
5227
+ console.error(`[wawesome] '${tenant.tenant_slug}' is fixed: a Function version has been promoted, and live URLs already carry it.`);
5228
+ process.exit(1);
5026
5229
  return;
5027
5230
  }
5028
- }
5029
- if (options.verbose) console.error(`[wawesome:verbose] DELETE ${creds.gateway_url}/v1/tenant/credentials/${credential.id}`);
5030
- try {
5031
- await deleteCredential(creds, credential.id);
5231
+ if (options.verbose) console.log(`[wawesome:verbose] Renaming '${tenant.tenant_slug}' → '${slug}'`);
5232
+ const result = await renameTenantSlug(creds, slug.trim());
5233
+ console.log("\n======================================================");
5234
+ console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
5235
+ console.log("======================================================");
5236
+ console.log(`\n Address: ${result.tenant_slug}`);
5237
+ console.log(` Previous: ${result.previous_slug} (still resolves)`);
5238
+ console.log("======================================================\n");
5032
5239
  } catch (err) {
5033
- if (err instanceof GatewayError && err.status === 404) fail(`${named} no longer exists in this workspace.`);
5034
- fail(errorText(err));
5240
+ if (err instanceof GatewayError) {
5241
+ console.error(`\n[wawesome] \x1b[31m${err.message}\x1b[0m`);
5242
+ const { text } = renameAdvice(err.reason);
5243
+ if (text) console.error(`[wawesome] ${text}`);
5244
+ console.error("");
5245
+ process.exit(1);
5246
+ return;
5247
+ }
5248
+ console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
5249
+ process.exit(1);
5035
5250
  }
5036
- console.log(`\n[wawesome] ✔ Deleted ${named}. The name '${credential.name}' is free again.`);
5037
- console.log("[wawesome] Anything still presenting its secret is told the secret is unknown.\n");
5038
5251
  }
5039
- async function credentialsCommand(action, target, options) {
5040
- if (!action || action === "list" || action === "ls") return listCredentialsCommand(options);
5041
- if (action === "mint") return mintCredentialCommand(target, options);
5042
- if (action === "regenerate") return regenerateCredentialCommand(target, options);
5043
- if (action === "revoke") return revokeCredentialCommand(target, options);
5044
- if (action === "delete") return deleteCredentialCommand(target, options);
5045
- fail(`Unknown credentials action '${action}'.`, "Usage: wawesome credentials [list|mint <name>|regenerate <name-or-prefix>|revoke <name-or-prefix>|delete <name-or-prefix>]");
5252
+ /** Dispatch for `wawesome workspace [action]`. */
5253
+ async function workspaceCommand(action, target, options) {
5254
+ if (!action || action === "show" || action === "info") return showWorkspace(options);
5255
+ if (action === "rename") return renameWorkspace(target, options);
5256
+ console.error(`[wawesome] Error: Unknown workspace action '${action}'.`);
5257
+ console.error("[wawesome] Usage: wawesome workspace [show|rename <name>]");
5258
+ process.exit(1);
5046
5259
  }
5047
5260
  //#endregion
5048
- //#region src/cron.ts
5049
- /** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
5050
- function asUtcWithSeconds(iso) {
5051
- const at = new Date(iso);
5052
- if (Number.isNaN(at.getTime())) return iso;
5053
- const pad = (n) => String(n).padStart(2, "0");
5054
- return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())} UTC`;
5055
- }
5056
- /** Format execution delay in ms or s. */
5057
- function formatDelay(delayMs) {
5058
- if (delayMs === void 0 || delayMs === null || isNaN(delayMs) || delayMs < 0) return "-";
5059
- if (delayMs < 1e3) return `${delayMs}ms`;
5060
- return `${(delayMs / 1e3).toFixed(2)}s`;
5261
+ //#region ../shared/mint-surface.generated.ts
5262
+ const MINT_PRESETS = [
5263
+ {
5264
+ name: "viewer",
5265
+ job: "Read and investigate",
5266
+ capabilities: [
5267
+ "read:tenant",
5268
+ "read:apps",
5269
+ "read:functions",
5270
+ "read:env",
5271
+ "read:domains",
5272
+ "read:invocations",
5273
+ "read:runs",
5274
+ "read:schedules",
5275
+ "read:previews",
5276
+ "read:egress"
5277
+ ],
5278
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist. It writes nothing."
5279
+ },
5280
+ {
5281
+ name: "deployer",
5282
+ job: "Ship and read back",
5283
+ capabilities: [
5284
+ "read:tenant",
5285
+ "read:apps",
5286
+ "read:functions",
5287
+ "read:env",
5288
+ "read:domains",
5289
+ "read:invocations",
5290
+ "read:runs",
5291
+ "read:schedules",
5292
+ "read:previews",
5293
+ "read:egress",
5294
+ "write:apps",
5295
+ "write:functions",
5296
+ "write:env",
5297
+ "write:runs"
5298
+ ],
5299
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables and run Functions. It does not attach domains, pause and resume schedules or create previews."
5300
+ },
5301
+ {
5302
+ name: "member",
5303
+ job: "Build in the workspace",
5304
+ capabilities: [
5305
+ "read:tenant",
5306
+ "read:apps",
5307
+ "read:functions",
5308
+ "read:env",
5309
+ "read:domains",
5310
+ "read:invocations",
5311
+ "read:runs",
5312
+ "read:schedules",
5313
+ "read:previews",
5314
+ "read:egress",
5315
+ "write:apps",
5316
+ "write:functions",
5317
+ "write:env",
5318
+ "write:runs",
5319
+ "write:schedules",
5320
+ "write:previews"
5321
+ ],
5322
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, run Functions, pause and resume schedules and create previews. It does not attach domains."
5323
+ }
5324
+ ];
5325
+ function mintPreset(name) {
5326
+ return MINT_PRESETS.find((preset) => preset.name === name);
5061
5327
  }
5062
- /** Colorize state names for terminal output. */
5063
- function colorizeRunState(state, text) {
5064
- const display = text ?? state;
5065
- switch (state.toLowerCase()) {
5066
- case "dispatched":
5067
- case "success": return `\x1b[32m${display}\x1b[0m`;
5068
- case "running": return `\x1b[36m${display}\x1b[0m`;
5069
- case "pending":
5070
- case "queued": return `\x1b[33m${display}\x1b[0m`;
5071
- case "missed": return `\x1b[33m${display}\x1b[0m`;
5072
- case "cancelled":
5073
- case "skipped": return `\x1b[90m${display}\x1b[0m`;
5074
- case "failed":
5075
- case "lost":
5076
- case "error": return `\x1b[31m${display}\x1b[0m`;
5077
- default: return display;
5328
+ //#endregion
5329
+ //#region src/deploy-credentials.ts
5330
+ const SECRET_SHOWN_ONCE = "Copy it now. This is the only time it will be shown. If you lose it, revoke this credential and mint another.";
5331
+ const REGENERATED_SECRET_SHOWN_ONCE = "Copy it now. This is the only time it will be shown. If you lose it, regenerate this credential again.";
5332
+ function requireCredentials() {
5333
+ const creds = readCredentials();
5334
+ if (!creds) {
5335
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
5336
+ process.exit(1);
5078
5337
  }
5338
+ return creds;
5339
+ }
5340
+ function fail(message, ...rest) {
5341
+ console.error(`[wawesome] Error: ${message}`);
5342
+ for (const line of rest) console.error(`[wawesome] ${line}`);
5343
+ process.exit(1);
5344
+ }
5345
+ /** A repeated flag and a comma-separated one, read as the same list. */
5346
+ function asList(raw) {
5347
+ if (raw === void 0) return void 0;
5348
+ const words = (Array.isArray(raw) ? raw : [raw]).flatMap((word) => String(word).split(",")).map((word) => word.trim()).filter((word) => word.length > 0);
5349
+ return words.length > 0 ? words : void 0;
5350
+ }
5351
+ /** What the help block is written to fit. A narrower terminal wraps it again;
5352
+ * a wider one leaves the words where they are put. */
5353
+ const HELP_WIDTH = 88;
5354
+ function wordsUnder(words, gutter) {
5355
+ const lines = [];
5356
+ let line = "";
5357
+ words.forEach((word, i) => {
5358
+ const piece = i < words.length - 1 ? `${word},` : word;
5359
+ const joined = line ? `${line} ${piece}` : piece;
5360
+ if (line && gutter.length + joined.length > HELP_WIDTH) {
5361
+ lines.push(gutter + line);
5362
+ line = piece;
5363
+ } else line = joined;
5364
+ });
5365
+ return line ? [...lines, gutter + line] : lines;
5079
5366
  }
5080
5367
  /**
5081
- * Format a list of schedules for a single function.
5368
+ * Every preset with the job on one line and the words it grants under it,
5369
+ * wherever one is offered or refused.
5370
+ *
5371
+ * The words are printed rather than stood for: a name on its own would be a
5372
+ * label an owner has to trust, free to drift from the grant it makes. They are
5373
+ * under the name rather than beside it because a grid word names one resource
5374
+ * and one verb, so a job takes ten of them or more.
5082
5375
  */
5083
- function formatFunctionSchedules(funcName, appSlug, schedules) {
5084
- const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
5085
- if (schedules.length === 0) return [`[wawesome] No schedules found for '${displayTarget}'.`];
5086
- const nameWidth = Math.max(8, ...schedules.map((s) => s.name.length));
5087
- const exprWidth = Math.max(10, ...schedules.map((s) => s.expression.length));
5088
- return [
5089
- `\n🗓 \x1b[1mSchedules for '${displayTarget}'\x1b[0m\n`,
5090
- ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(exprWidth)} ${whenItRuns(schedule)}`),
5091
- ""
5092
- ];
5376
+ function presetsOffered(indent = " ", pad = 12) {
5377
+ return MINT_PRESETS.flatMap((preset) => [`${indent}${preset.name.padEnd(pad)}${preset.job}`, ...wordsUnder(preset.capabilities, `${indent}${" ".repeat(pad)}`)]);
5093
5378
  }
5379
+ /** The `--preset` flag as `--help` states it: the job each one is for, the
5380
+ * words being too many to fit on a flag's line and printed by the command's
5381
+ * own help. */
5382
+ const PRESET_FLAG_HELP = `Grant the words a job needs: ${MINT_PRESETS.map((preset) => `${preset.name} (${preset.job.toLowerCase()})`).join(", ")}. 'wawesome credentials --help' prints what each one grants. Not with --capability`;
5094
5383
  /**
5095
- * Format schedules across all functions in an App.
5384
+ * The words to mint with: the ones named, or the ones a preset names.
5385
+ *
5386
+ * A preset expands here rather than travelling as a name, so what the command
5387
+ * sends and prints is the vocabulary itself: a label the platform never sees
5388
+ * would be free to drift from the words it stands for.
5096
5389
  */
5097
- function formatAppSchedules(appSlug, functionSchedules) {
5098
- if (functionSchedules.reduce((acc, f) => acc + f.schedules.length, 0) === 0) return [`[wawesome] No schedules found for app '${appSlug}'.`];
5099
- const allSchedules = functionSchedules.flatMap((f) => f.schedules);
5100
- const nameWidth = Math.max(8, ...allSchedules.map((s) => s.name.length));
5101
- const exprWidth = Math.max(10, ...allSchedules.map((s) => s.expression.length));
5102
- const out = [`\n🗓 \x1b[1mSchedules for app '${appSlug}'\x1b[0m\n`];
5103
- for (const fn of functionSchedules) {
5104
- out.push(` Function: \x1b[1m${fn.functionName}\x1b[0m`);
5105
- if (fn.schedules.length === 0) out.push(" (no schedules)");
5106
- else for (const s of [...fn.schedules].sort((a, b) => a.name.localeCompare(b.name))) out.push(` ${s.name.padEnd(nameWidth)} ${s.expression.padEnd(exprWidth)} ${whenItRuns(s)}`);
5107
- out.push("");
5108
- }
5109
- return out;
5390
+ function capabilitiesAsked(options) {
5391
+ const named = asList(options.capability);
5392
+ const preset = options.preset?.trim();
5393
+ if (!preset) return named;
5394
+ if (named) fail(`--preset ${preset} and --capability ${named.join(",")} both name what this credential carries.`, "Name one of them. A preset is a fill, so pass its words to --capability if you mean to edit them:", ...presetsOffered());
5395
+ const found = mintPreset(preset);
5396
+ if (!found) fail(`'${preset}' is not a preset. The ones offered are:`, ...presetsOffered());
5397
+ return [...found.capabilities];
5398
+ }
5399
+ function parseExpiry(raw) {
5400
+ if (raw === void 0) return void 0;
5401
+ const asked = String(raw).trim().toLowerCase();
5402
+ if (asked === "never") return "never";
5403
+ const days = /^(\d+)\s*d(ays?)?$/.exec(asked) ?? /^(\d+)$/.exec(asked);
5404
+ if (!days) fail(`'${raw}' is not an expiry. Name a number of days (--expires 30) or 'never' (--expires never).`);
5405
+ return { days: Number(days[1]) };
5110
5406
  }
5111
5407
  /**
5112
- * Format background run history table.
5408
+ * What a target may be repeated back as.
5409
+ *
5410
+ * Somebody revoking a leak pastes the whole secret as often as the prefix, and
5411
+ * a refusal that echoed it would write it into a shell history and a CI log —
5412
+ * for the one feature built on the secret being shown exactly once.
5113
5413
  */
5114
- function formatRunHistoryTable(runs) {
5115
- if (runs.length === 0) return [];
5116
- const scheduleColHeader = "SCHEDULE";
5117
- const stateColHeader = "STATE";
5118
- const dueColHeader = "DUE AT (UTC)";
5119
- const startedColHeader = "STARTED AT (UTC)";
5120
- const delayColHeader = "DELAY";
5121
- const invColHeader = "INVOCATION ID";
5122
- const rows = runs.map((run) => {
5123
- const schedName = run.schedule_name || "manual";
5124
- const stateDisplay = run.failure_reason ? `${run.state} (${run.failure_reason})` : run.state;
5125
- const dueAt = asUtcWithSeconds(run.fire_at);
5126
- const startedAt = run.claimed_at ? asUtcWithSeconds(run.claimed_at) : "-";
5127
- const delay = formatDelay(run.delay_ms);
5128
- const invId = run.invocation_id;
5129
- return {
5130
- schedName,
5131
- stateDisplay,
5132
- rawState: run.state,
5133
- dueAt,
5134
- startedAt,
5135
- delay,
5136
- invId
5137
- };
5138
- });
5139
- const schedWidth = Math.max(8, ...rows.map((r) => r.schedName.length));
5140
- const stateWidth = Math.max(5, ...rows.map((r) => r.stateDisplay.length));
5141
- const dueWidth = Math.max(12, ...rows.map((r) => r.dueAt.length));
5142
- const startedWidth = Math.max(16, ...rows.map((r) => r.startedAt.length));
5143
- const delayWidth = Math.max(5, ...rows.map((r) => r.delay.length));
5144
- const invWidth = Math.max(13, ...rows.map((r) => r.invId.length));
5145
- return [
5146
- `${scheduleColHeader.padEnd(schedWidth)} | ${stateColHeader.padEnd(stateWidth)} | ${dueColHeader.padEnd(dueWidth)} | ${startedColHeader.padEnd(startedWidth)} | ${delayColHeader.padEnd(delayWidth)} | ${invColHeader.padEnd(invWidth)}`,
5147
- `${"-".repeat(schedWidth)}-|-${"-".repeat(stateWidth)}-|-${"-".repeat(dueWidth)}-|-${"-".repeat(startedWidth)}-|-${"-".repeat(delayWidth)}-|-${"-".repeat(invWidth)}`,
5148
- ...rows.map((r) => {
5149
- const schedPad = r.schedName.padEnd(schedWidth);
5150
- const coloredState = colorizeRunState(r.rawState, r.stateDisplay);
5151
- return `${schedPad} | ${r.stateDisplay.length < stateWidth ? coloredState + " ".repeat(stateWidth - r.stateDisplay.length) : coloredState} | ${r.dueAt.padEnd(dueWidth)} | ${r.startedAt.padEnd(startedWidth)} | ${r.delay.padEnd(delayWidth)} | ${r.invId.padEnd(invWidth)}`;
5152
- })
5153
- ];
5414
+ function asShown(target) {
5415
+ if (!target.startsWith("wawe_")) return target;
5416
+ const shown = credentialPrefix(target);
5417
+ return shown === target ? target : `${shown}…`;
5154
5418
  }
5155
- async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug, isVerbose) {
5156
- const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
5157
- if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
5158
- let res = await authorizedFetch(scopedUrl, {
5159
- method: "GET",
5160
- headers: { Authorization: `Bearer ${tenantJwt}` }
5161
- });
5162
- if (res.status === 404 && appSlug) {
5163
- const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
5164
- if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
5165
- try {
5166
- const fallbackRes = await authorizedFetch(fallbackUrl, {
5167
- method: "GET",
5168
- headers: { Authorization: `Bearer ${tenantJwt}` }
5169
- });
5170
- if (fallbackRes.ok) res = fallbackRes;
5171
- } catch {}
5172
- }
5173
- if (!res.ok) return null;
5174
- const data = await res.json();
5175
- return Array.isArray(data.schedules) ? data.schedules : [];
5419
+ /** The Apps a credential names, or `null` where it names none and reaches all. */
5420
+ function namedApps(apps) {
5421
+ return apps && apps.scope === "only" ? apps.apps.join(", ") : null;
5176
5422
  }
5177
- async function fetchAppFunctions(gatewayUrl, tenantJwt, appSlug, isVerbose) {
5178
- const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions`;
5179
- if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
5180
- const res = await authorizedFetch(url, {
5181
- method: "GET",
5182
- headers: { Authorization: `Bearer ${tenantJwt}` }
5423
+ /** `2026-11-30 12:00 UTC`. Minutes, because nothing here is read to the second. */
5424
+ function asUtc(iso) {
5425
+ if (!iso) return "never";
5426
+ const at = new Date(iso);
5427
+ if (Number.isNaN(at.getTime())) return iso;
5428
+ return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
5429
+ }
5430
+ function isExpired(credential, now) {
5431
+ if (!credential.expires_at) return false;
5432
+ const at = new Date(credential.expires_at).getTime();
5433
+ return !Number.isNaN(at) && at <= now;
5434
+ }
5435
+ function statusOf(credential, now) {
5436
+ if (credential.revoked_at) return "revoked";
5437
+ if (isExpired(credential, now)) return "expired";
5438
+ return "active";
5439
+ }
5440
+ async function refusal(res, fallback) {
5441
+ if (res.status === 401 || res.status === 403) return new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
5442
+ return asGatewayError(res, fallback);
5443
+ }
5444
+ async function mintCredential(creds, payload) {
5445
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, {
5446
+ method: "POST",
5447
+ headers: {
5448
+ Authorization: `Bearer ${creds.tenant_jwt}`,
5449
+ "Content-Type": "application/json"
5450
+ },
5451
+ body: JSON.stringify(payload)
5183
5452
  });
5184
- if (!res.ok) return null;
5453
+ if (!res.ok) throw await refusal(res, `Failed to mint the credential (HTTP ${res.status}).`);
5185
5454
  return await res.json();
5186
5455
  }
5187
- async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, reason, isVerbose) {
5188
- const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
5189
- const body = reason?.trim() ? JSON.stringify({ reason: reason.trim() }) : void 0;
5190
- if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
5191
- let res = await authorizedFetch(scopedUrl, {
5456
+ async function fetchCredentials(creds) {
5457
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
5458
+ if (!res.ok) throw await refusal(res, `Failed to read the credentials (HTTP ${res.status}).`);
5459
+ return (await res.json()).credentials ?? [];
5460
+ }
5461
+ async function regenerateCredential(creds, credentialId, payload) {
5462
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}/regenerate`, {
5192
5463
  method: "POST",
5193
5464
  headers: {
5194
- Authorization: `Bearer ${tenantJwt}`,
5195
- ...body ? { "Content-Type": "application/json" } : {}
5465
+ Authorization: `Bearer ${creds.tenant_jwt}`,
5466
+ "Content-Type": "application/json"
5196
5467
  },
5197
- body
5468
+ body: JSON.stringify(payload)
5198
5469
  });
5199
- if (res.status === 404 && appSlug) {
5200
- const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
5201
- if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
5202
- try {
5203
- const fallbackRes = await authorizedFetch(fallbackUrl, {
5204
- method: "POST",
5205
- headers: {
5206
- Authorization: `Bearer ${tenantJwt}`,
5207
- ...body ? { "Content-Type": "application/json" } : {}
5208
- },
5209
- body
5210
- });
5211
- if (fallbackRes) res = fallbackRes;
5212
- } catch {}
5213
- }
5214
- return res;
5470
+ if (!res.ok) throw await refusal(res, `Failed to regenerate the credential (HTTP ${res.status}).`);
5471
+ return await res.json();
5215
5472
  }
5216
- async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, isVerbose) {
5217
- const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
5218
- if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
5219
- let res = await authorizedFetch(scopedUrl, {
5473
+ async function revokeCredential(creds, credentialId) {
5474
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}/revoke`, {
5220
5475
  method: "POST",
5221
- headers: { Authorization: `Bearer ${tenantJwt}` }
5476
+ headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
5222
5477
  });
5223
- if (res.status === 404 && appSlug) {
5224
- const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
5225
- if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
5226
- try {
5227
- const fallbackRes = await authorizedFetch(fallbackUrl, {
5228
- method: "POST",
5229
- headers: { Authorization: `Bearer ${tenantJwt}` }
5230
- });
5231
- if (fallbackRes) res = fallbackRes;
5232
- } catch {}
5233
- }
5234
- return res;
5478
+ if (!res.ok) throw await refusal(res, `Failed to revoke the credential (HTTP ${res.status}).`);
5479
+ return await res.json();
5235
5480
  }
5236
- async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug, queryParams, isVerbose) {
5237
- const params = new URLSearchParams();
5238
- if (queryParams?.limit) params.set("limit", String(queryParams.limit));
5239
- if (queryParams?.page) params.set("page", String(queryParams.page));
5240
- if (queryParams?.state && queryParams.state.toLowerCase() !== "all") params.set("state", queryParams.state.toLowerCase());
5241
- const qs = params.toString() ? `?${params.toString()}` : "";
5242
- const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs${qs}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
5243
- if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
5244
- let res = await authorizedFetch(scopedUrl, {
5245
- method: "GET",
5246
- headers: { Authorization: `Bearer ${tenantJwt}` }
5481
+ async function deleteCredential(creds, credentialId) {
5482
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}`, {
5483
+ method: "DELETE",
5484
+ headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
5247
5485
  });
5248
- if (res.status === 404 && appSlug) {
5249
- const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
5250
- if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
5251
- try {
5252
- const fallbackRes = await authorizedFetch(fallbackUrl, {
5253
- method: "GET",
5254
- headers: { Authorization: `Bearer ${tenantJwt}` }
5255
- });
5256
- if (fallbackRes) res = fallbackRes;
5257
- } catch {}
5258
- }
5259
- return res;
5486
+ if (!res.ok) throw await refusal(res, `Failed to delete the credential (HTTP ${res.status}).`);
5260
5487
  }
5261
- function requireCredentials() {
5262
- const creds = readCredentials();
5263
- if (!creds) {
5264
- console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
5265
- process.exit(1);
5488
+ /**
5489
+ * Mint one credential.
5490
+ *
5491
+ * The secret goes to stdout on a line of its own and everything a person reads
5492
+ * goes to stderr, so `wawesome credentials mint ci > secret` writes the secret
5493
+ * and not one character besides.
5494
+ */
5495
+ async function mintCredentialCommand(name, options) {
5496
+ if (!name || !name.trim()) fail("No name given. Usage: wawesome credentials mint <name> [--capability write:functions] [--app <app>] [--expires 90|never]", "The name is what tells the CI one from the agent one a year from now.");
5497
+ const capabilities = capabilitiesAsked(options);
5498
+ const creds = requireCredentials();
5499
+ const payload = { name: name.trim() };
5500
+ if (capabilities) payload.capabilities = capabilities;
5501
+ const apps = asList(options.app);
5502
+ if (apps) payload.apps = apps;
5503
+ const expiry = parseExpiry(options.expires);
5504
+ if (expiry !== void 0) payload.expiry = expiry;
5505
+ if (options.verbose) {
5506
+ console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials`);
5507
+ console.error(`[wawesome:verbose] Payload: ${JSON.stringify({ ...payload })}`);
5266
5508
  }
5267
- return creds;
5509
+ let credential;
5510
+ try {
5511
+ credential = await mintCredential(creds, payload);
5512
+ } catch (err) {
5513
+ fail(errorText(err));
5514
+ }
5515
+ reveal(`Minted deploy credential '${credential.name}'.`, credential, SECRET_SHOWN_ONCE);
5268
5516
  }
5269
- async function listSchedulesCommand(target, options = {}) {
5270
- const isVerbose = Boolean(options.verbose);
5517
+ /**
5518
+ * Hand the secret over, once.
5519
+ *
5520
+ * The secret goes to stdout on a line of its own and everything a person reads
5521
+ * goes to stderr, so `wawesome credentials mint ci > secret` writes the secret
5522
+ * and not one character besides.
5523
+ */
5524
+ function reveal(headline, credential, shownOnce) {
5525
+ console.error(`\n[wawesome] ✔ ${headline}\n`);
5526
+ console.error(` Prefix: ${credential.prefix}`);
5527
+ console.error(` Capabilities: ${credential.capabilities.join(", ")}`);
5528
+ console.error(` Apps: ${namedApps(credential.apps) ?? "every App in the workspace"}`);
5529
+ console.error(` Expires: ${asUtc(credential.expires_at)}`);
5530
+ console.error(`\n ${shownOnce}\n`);
5531
+ console.log(credential.secret);
5532
+ console.error("");
5533
+ }
5534
+ async function listCredentialsCommand(options) {
5271
5535
  const creds = requireCredentials();
5272
- const config = readFunctionConfig();
5273
- const explicitApp = options.app;
5274
- const appSlug = explicitApp || config?.app;
5275
- const funcName = target || options.function || (explicitApp ? void 0 : config?.function);
5276
- if (funcName) {
5277
- const schedules = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, isVerbose);
5278
- if (schedules === null) {
5279
- if (target && !options.app) {
5280
- const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, target, isVerbose);
5281
- if (appFuncs !== null) {
5282
- const functionSchedules = [];
5283
- for (const item of appFuncs) {
5284
- const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, target, isVerbose);
5285
- functionSchedules.push({
5286
- functionName: item.function.name,
5287
- schedules: scheds || []
5288
- });
5289
- }
5290
- const lines = formatAppSchedules(target, functionSchedules);
5291
- for (const line of lines) console.log(line);
5292
- return;
5293
- }
5294
- }
5295
- console.error(`[wawesome] Error: Function '${funcName}' not found.`);
5296
- process.exit(1);
5297
- }
5298
- const lines = formatFunctionSchedules(funcName, appSlug, schedules);
5299
- for (const line of lines) console.log(line);
5536
+ if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
5537
+ let credentials;
5538
+ try {
5539
+ credentials = await fetchCredentials(creds);
5540
+ } catch (err) {
5541
+ fail(errorText(err));
5542
+ }
5543
+ if (credentials.length === 0) {
5544
+ console.log("\n[wawesome] This workspace has no deploy credentials.");
5545
+ console.log("[wawesome] Mint one: \x1B[36mwawesome credentials mint <name>\x1B[0m\n");
5300
5546
  return;
5301
5547
  }
5302
- if (appSlug) {
5303
- const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, appSlug, isVerbose);
5304
- if (appFuncs === null) {
5305
- console.error(`[wawesome] Error: App '${appSlug}' not found.`);
5306
- process.exit(1);
5307
- }
5308
- const functionSchedules = [];
5309
- for (const item of appFuncs) {
5310
- const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, appSlug, isVerbose);
5311
- functionSchedules.push({
5312
- functionName: item.function.name,
5313
- schedules: scheds || []
5314
- });
5548
+ console.log("\n🔑 \x1B[1mDeploy credentials\x1B[0m\n");
5549
+ for (const line of formatCredentials(credentials, Date.now())) console.log(line);
5550
+ console.log("");
5551
+ }
5552
+ /** Never the secret: no read after minting carries one. */
5553
+ function formatCredentials(credentials, now) {
5554
+ const rows = credentials.map((credential) => ({
5555
+ name: credential.name,
5556
+ prefix: credential.prefix,
5557
+ capabilities: credential.capabilities.join(", "),
5558
+ apps: namedApps(credential.apps) ?? "every App",
5559
+ lastUsed: asUtc(credential.last_used_at),
5560
+ expires: asUtc(credential.expires_at),
5561
+ status: statusOf(credential, now)
5562
+ }));
5563
+ const columns = [
5564
+ {
5565
+ header: "NAME",
5566
+ of: (row) => row.name
5567
+ },
5568
+ {
5569
+ header: "PREFIX",
5570
+ of: (row) => row.prefix
5571
+ },
5572
+ {
5573
+ header: "CAPABILITIES",
5574
+ of: (row) => row.capabilities
5575
+ },
5576
+ {
5577
+ header: "APPS",
5578
+ of: (row) => row.apps
5579
+ },
5580
+ {
5581
+ header: "LAST USED",
5582
+ of: (row) => row.lastUsed
5583
+ },
5584
+ {
5585
+ header: "EXPIRES",
5586
+ of: (row) => row.expires
5587
+ },
5588
+ {
5589
+ header: "STATUS",
5590
+ of: (row) => row.status
5315
5591
  }
5316
- const lines = formatAppSchedules(appSlug, functionSchedules);
5317
- for (const line of lines) console.log(line);
5318
- return;
5592
+ ];
5593
+ const widths = columns.map((column) => Math.max(column.header.length, ...rows.map((row) => column.of(row).length)));
5594
+ const line = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i])).join(" | ")}`.trimEnd();
5595
+ return [
5596
+ line(columns.map((column) => column.header)),
5597
+ ` ${widths.map((width) => "-".repeat(width)).join("-|-")}`,
5598
+ ...rows.map((row) => line(columns.map((column) => column.of(row))))
5599
+ ];
5600
+ }
5601
+ /**
5602
+ * Which credential a user meant by a name or a prefix.
5603
+ *
5604
+ * A partial prefix matches, because the point of the displayable prefix is
5605
+ * matching one against a CI settings page by eye — and a partial that names
5606
+ * more than one is refused rather than guessed at, since the guess would be a
5607
+ * revocation.
5608
+ */
5609
+ function matchCredentials(credentials, target) {
5610
+ const typed = target.trim();
5611
+ const named = credentials.filter((credential) => credential.name === typed);
5612
+ if (named.length > 0) return named;
5613
+ const wanted = typed.startsWith("wawe_") ? credentialPrefix(typed) : typed;
5614
+ return credentials.filter((credential) => credential.prefix.startsWith(wanted));
5615
+ }
5616
+ /** The one credential a typed name or prefix means, or an exit saying why none. */
5617
+ async function resolveCredential(creds, target, options) {
5618
+ if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
5619
+ let credentials;
5620
+ try {
5621
+ credentials = await fetchCredentials(creds);
5622
+ } catch (err) {
5623
+ fail(errorText(err));
5319
5624
  }
5320
- console.error("[wawesome] Error: Missing function or app name.");
5321
- console.error("[wawesome] Usage: wawesome cron list [function-or-app] or run inside a function directory with wawesome-function.json.");
5322
- process.exit(1);
5625
+ const matched = matchCredentials(credentials, target);
5626
+ if (matched.length === 0) fail(`No deploy credential named or prefixed '${asShown(target)}' in this workspace.`, "Run 'wawesome credentials list' to see what this workspace has.");
5627
+ if (matched.length > 1) fail(`'${asShown(target)}' names more than one credential:`, ...matched.map((credential) => ` ${credential.name} (${credential.prefix})`), "Name one of them, or give more of its prefix.");
5628
+ return matched[0];
5323
5629
  }
5324
- async function pauseScheduleCommand(target, subtarget, options = {}) {
5325
- const isVerbose = Boolean(options.verbose);
5630
+ /**
5631
+ * Replace a credential's secret where it stands, keeping the name a pipeline's
5632
+ * settings already refer to.
5633
+ *
5634
+ * Confirmed like a revoke, because it is one for whatever holds the old secret:
5635
+ * the pipeline stops on its very next request until somebody gives it the new
5636
+ * one.
5637
+ */
5638
+ async function regenerateCredentialCommand(target, options) {
5639
+ if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials regenerate <name-or-prefix>");
5326
5640
  const creds = requireCredentials();
5327
- const config = readFunctionConfig();
5328
- let funcName;
5329
- let scheduleName;
5330
- if (target && subtarget) {
5331
- funcName = target;
5332
- scheduleName = subtarget;
5333
- } else if (target) {
5334
- scheduleName = target;
5335
- funcName = options.function || config?.function;
5336
- }
5337
- if (options.function) funcName = options.function;
5338
- const appSlug = options.app || config?.app;
5339
- if (!scheduleName) {
5340
- console.error("[wawesome] Error: Missing schedule name.");
5341
- console.error("[wawesome] Usage: wawesome cron pause <schedule-name> [--reason <reason>] or wawesome cron pause <function> <schedule-name>");
5342
- process.exit(1);
5343
- }
5344
- if (!funcName) {
5345
- console.error("[wawesome] Error: Missing function name.");
5346
- console.error("[wawesome] Usage: wawesome cron pause <function> <schedule-name> or specify -f/--function <name>.");
5347
- process.exit(1);
5348
- }
5349
- const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
5350
- if (!res.ok) {
5351
- const errorBody = await res.text();
5352
- if (res.status === 401 || res.status === 403) {
5353
- console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
5354
- process.exit(1);
5641
+ const credential = await resolveCredential(creds, target, options);
5642
+ if (credential.revoked_at) fail(`'${credential.name}' (${credential.prefix}) was revoked at ${asUtc(credential.revoked_at)}, and nothing un-revokes one.`, `Mint a replacement, or delete this one first to free its name for it.`);
5643
+ const payload = {};
5644
+ const expiry = parseExpiry(options.expires);
5645
+ if (expiry !== void 0) payload.expiry = expiry;
5646
+ if (!options.yes) {
5647
+ if (!isInteractive()) fail(`Regenerating asks for confirmation, and there is nobody to ask.`, `Pass --yes to regenerate '${credential.name}' (${credential.prefix}) without being asked.`);
5648
+ let confirmed;
5649
+ try {
5650
+ confirmed = await confirm({
5651
+ message: `Regenerate '${credential.name}' (${credential.prefix})? Anything holding the old secret stops working at once and has to be given the new one.`,
5652
+ default: false
5653
+ });
5654
+ } catch {
5655
+ confirmed = false;
5355
5656
  }
5356
- if (res.status === 404) {
5357
- console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
5358
- process.exit(1);
5657
+ if (!confirmed) {
5658
+ console.error(`[wawesome] Cancelled. '${credential.name}' still works.`);
5659
+ return;
5359
5660
  }
5360
- const refusal = rejectionOf(errorBody, res.status, `Failed to pause schedule (HTTP ${res.status}).`);
5361
- console.error(`[wawesome] Error: ${refusal.message}`);
5362
- const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
5363
- if (advice) console.error(`[wawesome] ${advice}`);
5364
- if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
5365
- process.exit(1);
5366
5661
  }
5367
- const data = await res.json();
5368
- const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
5369
- console.log(`[wawesome] ✔ Paused schedule '${data.schedule.name}' on '${displayTarget}'.`);
5370
- console.log(` State: ${whenItRuns(data.schedule)} (survives future deploys)`);
5371
- if (data.schedule.pause_reason) console.log(` Reason: ${data.schedule.pause_reason}`);
5372
- if (data.cancelled_runs && data.cancelled_runs.length > 0) {
5373
- const count = data.cancelled_runs.length;
5374
- console.log(` Cancelled ${count} queued run${count === 1 ? "" : "s"} (never started).`);
5662
+ if (options.verbose) {
5663
+ console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials/${credential.id}/regenerate`);
5664
+ console.error(`[wawesome:verbose] Payload: ${JSON.stringify(payload)}`);
5375
5665
  }
5376
- if (data.running_runs && data.running_runs.length > 0) {
5377
- const count = data.running_runs.length;
5378
- console.log(` Left ${count} in-flight run${count === 1 ? "" : "s"} to complete.`);
5666
+ let regenerated;
5667
+ try {
5668
+ regenerated = await regenerateCredential(creds, credential.id, payload);
5669
+ } catch (err) {
5670
+ if (err instanceof GatewayError && err.status === 404) fail(`'${credential.name}' (${credential.prefix}) no longer exists in this workspace.`);
5671
+ fail(errorText(err));
5379
5672
  }
5673
+ reveal(`Regenerated '${regenerated.name}'. Anything still presenting the old secret is refused from its very next request.`, regenerated, REGENERATED_SECRET_SHOWN_ONCE);
5380
5674
  }
5381
- async function resumeScheduleCommand(target, subtarget, options = {}) {
5382
- const isVerbose = Boolean(options.verbose);
5675
+ async function revokeCredentialCommand(target, options) {
5676
+ if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials revoke <name-or-prefix>");
5383
5677
  const creds = requireCredentials();
5384
- const config = readFunctionConfig();
5385
- let funcName;
5386
- let scheduleName;
5387
- if (target && subtarget) {
5388
- funcName = target;
5389
- scheduleName = subtarget;
5390
- } else if (target) {
5391
- scheduleName = target;
5392
- funcName = options.function || config?.function;
5393
- }
5394
- if (options.function) funcName = options.function;
5395
- const appSlug = options.app || config?.app;
5396
- if (!scheduleName) {
5397
- console.error("[wawesome] Error: Missing schedule name.");
5398
- console.error("[wawesome] Usage: wawesome cron resume <schedule-name> or wawesome cron resume <function> <schedule-name>");
5399
- process.exit(1);
5400
- }
5401
- if (!funcName) {
5402
- console.error("[wawesome] Error: Missing function name.");
5403
- console.error("[wawesome] Usage: wawesome cron resume <function> <schedule-name> or specify -f/--function <name>.");
5404
- process.exit(1);
5678
+ const credential = await resolveCredential(creds, target, options);
5679
+ if (credential.revoked_at) {
5680
+ console.log(`[wawesome] '${credential.name}' (${credential.prefix}) was already revoked at ${asUtc(credential.revoked_at)}. Nothing to do.`);
5681
+ return;
5405
5682
  }
5406
- const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
5407
- if (!res.ok) {
5408
- const errorBody = await res.text();
5409
- if (res.status === 401 || res.status === 403) {
5410
- console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
5411
- process.exit(1);
5683
+ if (!options.yes) {
5684
+ if (!isInteractive()) fail(`Revoking asks for confirmation, and there is nobody to ask.`, `Pass --yes to revoke '${credential.name}' (${credential.prefix}) without being asked.`);
5685
+ let confirmed;
5686
+ try {
5687
+ confirmed = await confirm({
5688
+ message: `Revoke '${credential.name}' (${credential.prefix})? Nothing un-revokes it.`,
5689
+ default: false
5690
+ });
5691
+ } catch {
5692
+ confirmed = false;
5412
5693
  }
5413
- if (res.status === 404) {
5414
- console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
5415
- process.exit(1);
5694
+ if (!confirmed) {
5695
+ console.log(`[wawesome] Cancelled. '${credential.name}' still works.`);
5696
+ return;
5416
5697
  }
5417
- const refusal = rejectionOf(errorBody, res.status, `Failed to resume schedule (HTTP ${res.status}).`);
5418
- console.error(`[wawesome] Error: ${refusal.message}`);
5419
- const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
5420
- if (advice) console.error(`[wawesome] ${advice}`);
5421
- if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
5422
- process.exit(1);
5423
5698
  }
5424
- const data = await res.json();
5425
- const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
5426
- console.log(`[wawesome] ✔ Resumed schedule '${data.schedule.name}' on '${displayTarget}'.`);
5427
- console.log(` ${whenItRuns(data.schedule)} (no backfill)`);
5699
+ if (options.verbose) console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials/${credential.id}/revoke`);
5700
+ let revoked;
5701
+ try {
5702
+ revoked = await revokeCredential(creds, credential.id);
5703
+ } catch (err) {
5704
+ if (err instanceof GatewayError && err.status === 404) fail(`'${credential.name}' (${credential.prefix}) no longer exists in this workspace.`);
5705
+ fail(errorText(err));
5706
+ }
5707
+ console.log(`\n[wawesome] ✔ Revoked '${revoked.name}' (${revoked.prefix}).`);
5708
+ console.log("[wawesome] Anything still presenting it is refused from its very next request.\n");
5428
5709
  }
5429
- async function runHistoryCommand(target, options = {}) {
5430
- const isVerbose = Boolean(options.verbose);
5710
+ async function deleteCredentialCommand(target, options) {
5711
+ if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials delete <name-or-prefix>");
5431
5712
  const creds = requireCredentials();
5432
- const config = readFunctionConfig();
5433
- const funcName = target || options.function || config?.function;
5434
- const appSlug = options.app || config?.app;
5435
- if (!funcName) {
5436
- console.error("[wawesome] Error: Missing function name.");
5437
- console.error("[wawesome] Usage: wawesome cron history [function-name] or run inside a function directory with wawesome-function.json.");
5438
- process.exit(1);
5439
- }
5440
- const limit = options.limit ? Number(options.limit) : 50;
5441
- const stateFilter = options.state || options.status;
5442
- const res = await fetchBackgroundRunsApi(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, {
5443
- state: stateFilter,
5444
- limit
5445
- }, isVerbose);
5446
- if (!res.ok) {
5447
- const errorBody = await res.text();
5448
- if (res.status === 401 || res.status === 403) {
5449
- console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
5450
- process.exit(1);
5713
+ const credential = await resolveCredential(creds, target, options);
5714
+ const named = `'${credential.name}' (${credential.prefix})`;
5715
+ if (statusOf(credential, Date.now()) === "active") fail(`${named} still works, and only a credential that has stopped working is deleted.`, `Revoke it first: wawesome credentials revoke ${credential.name}`);
5716
+ if (!options.yes) {
5717
+ if (!isInteractive()) fail("Deleting asks for confirmation, and there is nobody to ask.", `Pass --yes to delete ${named} without being asked.`);
5718
+ console.log(`\n[wawesome] Deleting ${named} forgets it.`);
5719
+ console.log(`[wawesome] It leaves the listing, and the name '${credential.name}' comes free.`);
5720
+ console.log("[wawesome] Anything still presenting its secret is told the secret is unknown, rather");
5721
+ console.log("[wawesome] than that somebody here revoked it.\n");
5722
+ let confirmed;
5723
+ try {
5724
+ confirmed = await confirm({
5725
+ message: `Delete ${named}?`,
5726
+ default: false
5727
+ });
5728
+ } catch {
5729
+ confirmed = false;
5451
5730
  }
5452
- if (res.status === 404) {
5453
- console.error(`[wawesome] Error: Function '${funcName}' not found.`);
5454
- process.exit(1);
5731
+ if (!confirmed) {
5732
+ console.log(`[wawesome] Cancelled. ${named} is still listed.`);
5733
+ return;
5455
5734
  }
5456
- const refusal = rejectionOf(errorBody, res.status, `Failed to fetch run history (HTTP ${res.status}).`);
5457
- console.error(`[wawesome] Error: ${refusal.message}`);
5458
- const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
5459
- if (advice) console.error(`[wawesome] ${advice}`);
5460
- if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
5461
- process.exit(1);
5462
5735
  }
5463
- const data = await res.json();
5464
- const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
5465
- if (!data.runs || data.runs.length === 0) {
5466
- console.log(`[wawesome] No run history found for '${displayTarget}'.`);
5467
- return;
5736
+ if (options.verbose) console.error(`[wawesome:verbose] DELETE ${creds.gateway_url}/v1/tenant/credentials/${credential.id}`);
5737
+ try {
5738
+ await deleteCredential(creds, credential.id);
5739
+ } catch (err) {
5740
+ if (err instanceof GatewayError && err.status === 404) fail(`${named} no longer exists in this workspace.`);
5741
+ fail(errorText(err));
5468
5742
  }
5469
- const total = data.total ?? data.runs.length;
5470
- console.log(`\n📜 \x1b[1mRun history for '${displayTarget}' (Showing ${data.runs.length} of ${total} records)\x1b[0m\n`);
5471
- const tableLines = formatRunHistoryTable(data.runs);
5472
- for (const line of tableLines) console.log(line);
5473
- console.log("");
5743
+ console.log(`\n[wawesome] ✔ Deleted ${named}. The name '${credential.name}' is free again.`);
5744
+ console.log("[wawesome] Anything still presenting its secret is told the secret is unknown.\n");
5474
5745
  }
5475
- async function cronCommand(action, target, subtarget, options = {}) {
5476
- const normAction = (action || "").toLowerCase().trim();
5477
- if (!normAction || normAction === "list" || normAction === "ls") return listSchedulesCommand(target, options);
5478
- if (normAction === "pause") return pauseScheduleCommand(target, subtarget, options);
5479
- if (normAction === "resume") return resumeScheduleCommand(target, subtarget, options);
5480
- if (normAction === "history" || normAction === "runs" || normAction === "log" || normAction === "logs") return runHistoryCommand(target, options);
5481
- return listSchedulesCommand(action, options);
5746
+ async function credentialsCommand(action, target, options) {
5747
+ if (!action || action === "list" || action === "ls") return listCredentialsCommand(options);
5748
+ if (action === "mint") return mintCredentialCommand(target, options);
5749
+ if (action === "regenerate") return regenerateCredentialCommand(target, options);
5750
+ if (action === "revoke") return revokeCredentialCommand(target, options);
5751
+ if (action === "delete") return deleteCredentialCommand(target, options);
5752
+ fail(`Unknown credentials action '${action}'.`, "Usage: wawesome credentials [list|mint <name>|regenerate <name-or-prefix>|revoke <name-or-prefix>|delete <name-or-prefix>]");
5482
5753
  }
5483
5754
  //#endregion
5484
5755
  //#region src/index.ts
@@ -5516,19 +5787,23 @@ cli.command("credentials [action] [target]", "Mint, list, regenerate, revoke and
5516
5787
  revoke <name-or-prefix> Revoke one, immediately and for good
5517
5788
  delete <name-or-prefix> Forget a revoked or expired one, freeing its name
5518
5789
 
5790
+ Presets (--preset), each the words one job needs, a write carrying its read:
5791
+ ${presetsOffered(" ").join("\n")}
5792
+
5519
5793
  Examples:
5520
5794
  wawesome credentials
5521
5795
  wawesome credentials mint ci-pipeline
5522
- wawesome credentials mint agent -c read,logs:read,invoke --expires never
5523
- wawesome credentials mint prod-deploy -c deploy -a prod --expires 30
5524
- wawesome credentials mint ci -c deploy,domains:attach -a prod
5796
+ wawesome credentials mint viewer --preset viewer -a prod
5797
+ wawesome credentials mint reader -c read:apps,read:functions,read:invocations -a prod
5798
+ wawesome credentials mint ci --preset deployer --expires never
5799
+ wawesome credentials mint ci -c write:apps,write:functions,write:env,read:tenant,write:domains -a prod
5525
5800
  wawesome credentials mint ci-pipeline > /tmp/secret
5526
5801
  wawesome credentials regenerate ci-pipeline
5527
5802
  wawesome credentials regenerate ci --expires never --yes
5528
5803
  wawesome credentials revoke ci-pipeline
5529
5804
  wawesome credentials revoke wawe_ab3k9x --yes
5530
- wawesome credentials delete ci-pipeline`).option("-c, --capability <capability>", "Capability to grant: deploy, read, logs:read, invoke, domains:attach (repeatable or comma-separated, default: deploy, which carries read)").option("-a, --app <app>", "Restrict the credential to this App (repeatable or comma-separated, default: every App)").option("--expires <days|never>", "Days until the credential expires, or 'never' (default: 90)").option("-y, --yes", "Skip the confirmation a regenerate, revoke or delete asks for").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => credentialsCommand(action, target, options));
5531
- cli.command("credentials mint <name>", "Mint a deploy credential, printing its secret exactly once").option("-c, --capability <capability>", "Capability to grant: deploy, read, logs:read, invoke, domains:attach (repeatable or comma-separated; deploy carries read)").option("-a, --app <app>", "Restrict the credential to this App (repeatable or comma-separated)").option("--expires <days|never>", "Days until the credential expires, or 'never' (default: 90)").option("-v, --verbose", "Enable verbose debug output").action((name, options) => mintCredentialCommand(name, options));
5805
+ wawesome credentials delete ci-pipeline`).option("-c, --capability <capability>", "Capability to grant, as {read|write}:{resource}: read:apps, write:functions, write:env, write:runs and the rest (repeatable or comma-separated, default: what a deploy needs)").option("--preset <preset>", PRESET_FLAG_HELP).option("-a, --app <app>", "Restrict the credential to this App (repeatable or comma-separated, default: every App)").option("--expires <days|never>", "Days until the credential expires, or 'never' (default: 90)").option("-y, --yes", "Skip the confirmation a regenerate, revoke or delete asks for").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => credentialsCommand(action, target, options));
5806
+ cli.command("credentials mint <name>", "Mint a deploy credential, printing its secret exactly once").option("-c, --capability <capability>", "Capability to grant, as {read|write}:{resource}: read:apps, write:functions, write:env, write:runs and the rest (repeatable or comma-separated; a write carries its read)").option("--preset <preset>", PRESET_FLAG_HELP).option("-a, --app <app>", "Restrict the credential to this App (repeatable or comma-separated)").option("--expires <days|never>", "Days until the credential expires, or 'never' (default: 90)").option("-v, --verbose", "Enable verbose debug output").action((name, options) => mintCredentialCommand(name, options));
5532
5807
  cli.command("credentials list", "List deploy credentials, never their secrets").alias("credentials ls").option("-v, --verbose", "Enable verbose debug output").action((options) => listCredentialsCommand(options));
5533
5808
  cli.command("credentials regenerate <name-or-prefix>", "Replace a deploy credential's secret, keeping its name, capabilities and Apps").option("--expires <days|never>", "Days until the regenerated credential expires, or 'never' (default: 90)").option("-y, --yes", "Regenerate without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => regenerateCredentialCommand(target, options));
5534
5809
  cli.command("credentials revoke <name-or-prefix>", "Revoke a deploy credential, immediately and for good").option("-y, --yes", "Revoke without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => revokeCredentialCommand(target, options));