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.
- package/README.md +127 -59
- package/dist/index.mjs +1224 -949
- 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
|
|
811
|
-
const entry =
|
|
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.
|
|
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 `
|
|
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/
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
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
|
-
*
|
|
2249
|
-
* so a typo is a message at the keyboard.
|
|
2324
|
+
* Format a list of schedules for a single function.
|
|
2250
2325
|
*/
|
|
2251
|
-
function
|
|
2252
|
-
|
|
2253
|
-
if (
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
-
*
|
|
2338
|
+
* Format schedules across all functions in an App.
|
|
2268
2339
|
*/
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
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
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
const
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
-
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
const
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
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
|
-
|
|
5175
|
+
return creds;
|
|
4809
5176
|
}
|
|
4810
5177
|
/**
|
|
4811
|
-
*
|
|
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
|
|
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
|
-
|
|
4830
|
-
let credentials;
|
|
5183
|
+
let tenant;
|
|
4831
5184
|
try {
|
|
4832
|
-
|
|
5185
|
+
tenant = await fetchTenantDetails(creds);
|
|
4833
5186
|
} catch (err) {
|
|
4834
|
-
|
|
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
|
|
4842
|
-
|
|
4843
|
-
console.log(
|
|
4844
|
-
}
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
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
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
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
|
-
*
|
|
4925
|
-
* settings already refer to.
|
|
5209
|
+
* Change the workspace's public address.
|
|
4926
5210
|
*
|
|
4927
|
-
*
|
|
4928
|
-
*
|
|
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
|
|
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
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
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
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
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
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
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
|
|
5034
|
-
|
|
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
|
-
|
|
5040
|
-
|
|
5041
|
-
if (action === "
|
|
5042
|
-
if (action === "
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
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
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
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
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
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
|
-
*
|
|
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
|
|
5084
|
-
|
|
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
|
-
*
|
|
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
|
|
5098
|
-
|
|
5099
|
-
const
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
const
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
return
|
|
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
|
-
*
|
|
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
|
|
5115
|
-
if (
|
|
5116
|
-
const
|
|
5117
|
-
|
|
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
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
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
|
-
|
|
5178
|
-
|
|
5179
|
-
if (
|
|
5180
|
-
const
|
|
5181
|
-
|
|
5182
|
-
|
|
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)
|
|
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
|
|
5188
|
-
const
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
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 ${
|
|
5195
|
-
|
|
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.
|
|
5200
|
-
|
|
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
|
|
5217
|
-
const
|
|
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 ${
|
|
5476
|
+
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
5222
5477
|
});
|
|
5223
|
-
if (res.
|
|
5224
|
-
|
|
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
|
|
5237
|
-
const
|
|
5238
|
-
|
|
5239
|
-
|
|
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.
|
|
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
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5270
|
-
|
|
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
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
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
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
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
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
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
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
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
|
-
|
|
5325
|
-
|
|
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
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
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 (
|
|
5357
|
-
console.error(`[wawesome]
|
|
5358
|
-
|
|
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
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
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
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
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
|
|
5382
|
-
|
|
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
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
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
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
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 (
|
|
5414
|
-
console.
|
|
5415
|
-
|
|
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
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
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
|
|
5430
|
-
|
|
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
|
|
5433
|
-
const
|
|
5434
|
-
|
|
5435
|
-
if (!
|
|
5436
|
-
|
|
5437
|
-
console.
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
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 (
|
|
5453
|
-
console.
|
|
5454
|
-
|
|
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
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
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
|
-
|
|
5470
|
-
console.log(
|
|
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
|
|
5476
|
-
|
|
5477
|
-
if (
|
|
5478
|
-
if (
|
|
5479
|
-
if (
|
|
5480
|
-
if (
|
|
5481
|
-
|
|
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
|
|
5523
|
-
wawesome credentials mint
|
|
5524
|
-
wawesome credentials mint ci
|
|
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
|
|
5531
|
-
cli.command("credentials mint <name>", "Mint a deploy credential, printing its secret exactly once").option("-c, --capability <capability>", "Capability to grant
|
|
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));
|