wawesome 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +113 -5
  2. package/dist/index.mjs +514 -92
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -116,6 +116,9 @@ npx wawesome deploy
116
116
  | `npx wawesome env list` | View environment variables for the current app |
117
117
  | `npx wawesome env set <key> <val>` | Set an environment variable (add `--secret` for write-only) |
118
118
  | `npx wawesome env rm <key>` | Delete an environment variable |
119
+ | `npx wawesome credentials` | List deploy credentials — name, prefix, capabilities, Apps, last use |
120
+ | `npx wawesome credentials mint <name>` | Mint a deploy credential and print its secret, once |
121
+ | `npx wawesome credentials revoke <name>`| Revoke a deploy credential by name or prefix |
119
122
 
120
123
  ---
121
124
 
@@ -279,6 +282,74 @@ npx wawesome cron history my-function --state failed
279
282
 
280
283
  ---
281
284
 
285
+ ## 🔑 Deploy Credentials
286
+
287
+ A deploy credential is what a CI pipeline or an agent authenticates with, where there is nobody at a
288
+ keyboard to log in. It belongs to the workspace rather than to you: it keeps working after you leave,
289
+ and revoking it does not end your own session. Once you have one, [Deploying from CI](#-deploying-from-ci)
290
+ is what to do with it.
291
+
292
+ Minting, listing and revoking are the workspace owner's, and are closed to deploy credentials
293
+ themselves whatever they carry — a leaked credential cannot mint another. So these three run on your
294
+ ordinary `npx wawesome login` session.
295
+
296
+ ### Mint one
297
+
298
+ ```bash
299
+ npx wawesome credentials mint ci-pipeline
300
+ ```
301
+
302
+ The secret is printed once and never again — the platform stores a digest of it, so no later read can
303
+ rebuild it. It goes to stdout on a line of its own and everything else the command prints goes to
304
+ stderr, so a redirect catches the secret and not one character besides:
305
+
306
+ ```bash
307
+ npx wawesome credentials mint ci-pipeline > secret.txt
308
+ ```
309
+
310
+ A credential carries `deploy`, reaches every App in the workspace, and expires in ninety days unless
311
+ you say otherwise:
312
+
313
+ ```bash
314
+ # reads logs and cannot deploy, restricted to one App, never expires
315
+ npx wawesome credentials mint agent -c logs:read -a prod --expires never
316
+ ```
317
+
318
+ | Flag | Meaning |
319
+ |:---------------------|:------------------------------------------------------------------------------|
320
+ | `-c, --capability` | `deploy`, `logs:read`, or both. Repeatable or comma-separated. Default `deploy` |
321
+ | `-a, --app` | Restrict to these Apps, by slug. Repeatable. Default: every App in the workspace |
322
+ | `--expires` | Days, or `never`. Default: 90 |
323
+
324
+ An App named in a restriction does not have to exist yet — a pipeline whose first deploy creates the
325
+ App it was minted for is the ordinary case. A restriction bounds what a credential can disturb, not
326
+ what it can read: a Function deployed into one App still reads the whole workspace's environment at
327
+ runtime.
328
+
329
+ ### List them
330
+
331
+ ```bash
332
+ npx wawesome credentials
333
+ ```
334
+
335
+ Name, displayable prefix, capabilities, Apps, last use and expiry — never the secret, which the
336
+ platform no longer holds. `LAST USED` lags by a few minutes and reads `never` for a credential
337
+ nothing has ever presented, which is a better reason to revoke one than any calendar date.
338
+
339
+ ### Revoke one
340
+
341
+ ```bash
342
+ npx wawesome credentials revoke ci-pipeline
343
+ npx wawesome credentials revoke wawe_ab3k9x --yes
344
+ ```
345
+
346
+ By name or by displayable prefix, whole or partial. It asks before it does it, `--yes` is the answer
347
+ where nobody is at the keyboard, and a prefix naming more than one credential is refused rather than
348
+ guessed at. A revocation lands on the very next request presenting the secret, and nothing un-revokes
349
+ it.
350
+
351
+ ---
352
+
282
353
  ## ⚙️ Configuration & Custom Gateway
283
354
 
284
355
  ### `wawesome-function.json`
@@ -340,11 +411,14 @@ Two rules to know about:
340
411
  **HTML is refused at deploy time.** Your Function renders its own markup, and a document served from
341
412
  your App's own origin is the sharpest same-origin vector a static file has.
342
413
 
343
- **An SVG is served with script denied.** The rule behind the refusal above is that nothing you deploy
344
- as a file runs script on your App's own origin, and an SVG opened directly in a browser would. It is
345
- served rather than refused because denying it costs the file nothing: an `<img src="logo.svg">` never
346
- ran that script, so your drawings render as they always did every SVG and XML file carries
347
- `Content-Security-Policy: script-src 'none'`, and only navigating straight to one loses anything.
414
+ **An SVG is served inert.** The rule behind the refusal above is that nothing you deploy as a file
415
+ becomes a page on your App's own origin, and an SVG opened directly in a browser would it runs
416
+ script, and it renders whatever HTML a `<foreignObject>` holds. It is served rather than refused
417
+ because making it inert costs the file nothing: an `<img src="logo.svg">` never ran that script and
418
+ is never checked against the policy, so your drawings render as they always did. Every SVG and XML
419
+ file carries `Content-Security-Policy: script-src 'none'; sandbox`, and only navigating straight to
420
+ one loses anything — such a file is sandboxed onto an origin of its own, so its links no longer
421
+ navigate and a page embedding it through `<object>` or `<iframe>` cannot reach into its DOM.
348
422
 
349
423
  ### Schedules
350
424
 
@@ -497,6 +571,40 @@ export WAWESOME_GATEWAY_URL="http://localhost:3000"
497
571
  npx wawesome login --gateway http://localhost:3000
498
572
  ```
499
573
 
574
+ A successful login records the gateway it used, and every later command goes there, because a token is only valid at
575
+ the gateway that issued it. `login` itself ignores that record and reads only the three overrides above, so a bare
576
+ `npx wawesome login` with none of them set goes to `https://api.wawesome.io`. `npx wawesome logout` clears the recorded
577
+ gateway along with the credentials.
578
+
579
+ ---
580
+
581
+ ## 🤖 Deploying from CI
582
+
583
+ A **deploy credential** is what deploys without a person at a keyboard. Your workspace mints one, and the CLI reads it
584
+ from a single environment variable — there is no login, and no credentials file is involved at any point:
585
+
586
+ ```bash
587
+ export WAWESOME_DEPLOY_CREDENTIAL="wawe_..."
588
+ export WAWESOME_GATEWAY_URL="https://api.wawesome.io"
589
+
590
+ npx wawesome deploy
591
+ ```
592
+
593
+ The workspace is resolved from the credential itself, so nothing in your pipeline has to carry a workspace identifier.
594
+ The variable name is prefixed and has no shorter alias, so it cannot collide with another tool's CI variable.
595
+
596
+ While it is set:
597
+
598
+ - it wins over any `~/.wawesome/credentials.json` on the machine, so a runner with a stale cached home directory still
599
+ deploys as the credential rather than as whoever last logged in there;
600
+ - nothing is written to disk, and the secret stays in the environment where you put it;
601
+ - a refusal says which of four things happened — the credential is unknown, revoked, expired, or does not carry what the
602
+ command needs — instead of asking you to sign in again, which a pipeline cannot do;
603
+ - `login` and `logout` refuse, because neither would change what the next command authenticates as;
604
+ - `credentials` is refused by the platform, because minting, listing and revoking are closed to
605
+ credentials whatever they carry;
606
+ - `whoami` names the credential by its prefix, so you can match it against the one in your CI settings.
607
+
500
608
  ---
501
609
 
502
610
  ## 🔒 Security & Secrets
package/dist/index.mjs CHANGED
@@ -12,12 +12,90 @@ import { Readable } from "node:stream";
12
12
  import crypto from "node:crypto";
13
13
  import { confirm, select } from "@inquirer/prompts";
14
14
  import zlib from "node:zlib";
15
+ //#region src/errors.ts
16
+ /** A rejection carrying the gateway's own prose and, where given, its reason. */
17
+ var GatewayError = class extends Error {
18
+ status;
19
+ reason;
20
+ /** The raw response, for `--verbose` to print when the prose is not enough. */
21
+ body;
22
+ constructor(message, status, reason, body = "") {
23
+ super(message);
24
+ this.name = "GatewayError";
25
+ this.status = status;
26
+ this.reason = reason;
27
+ this.body = body;
28
+ }
29
+ };
30
+ /**
31
+ * Read a failed response into a `GatewayError`.
32
+ *
33
+ * `fallback` is used only when the response carries no prose of its own — a
34
+ * proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
35
+ * cased here: what to say about an expired session depends on what the caller
36
+ * was doing, so callers handle it before reaching this.
37
+ */
38
+ async function asGatewayError(res, fallback) {
39
+ return rejectionOf(await res.text().catch(() => ""), res.status, fallback);
40
+ }
41
+ /** The same reading, for a caller that has already taken the body off the wire. */
42
+ function rejectionOf(body, status, fallback) {
43
+ try {
44
+ const parsed = JSON.parse(body);
45
+ return new GatewayError(parsed.error || fallback, status, parsed.reason, body);
46
+ } catch {
47
+ return new GatewayError(fallback, status, void 0, body);
48
+ }
49
+ }
50
+ /** Why the gateway refused, where what was thrown might not be a refusal at all. */
51
+ function reasonOf(err) {
52
+ return err instanceof GatewayError ? err.reason : void 0;
53
+ }
54
+ /** What went wrong, as text, whatever was thrown. */
55
+ function errorText(err) {
56
+ return err instanceof Error ? err.message : String(err);
57
+ }
58
+ //#endregion
59
+ //#region src/credential.ts
60
+ const CREDENTIAL_ENV_VAR = "WAWESOME_DEPLOY_CREDENTIAL";
61
+ function credentialFromEnvironment() {
62
+ const secret = process.env[CREDENTIAL_ENV_VAR]?.trim();
63
+ return secret ? secret : null;
64
+ }
65
+ /** Enough to match a credential against the one in a CI settings page, and no more. */
66
+ function credentialPrefix(secret) {
67
+ return secret.slice(0, 11);
68
+ }
69
+ /**
70
+ * A credential has no session behind it, so the platform's own reason — unknown,
71
+ * revoked, expired, or a scope it does not carry — is the whole answer, and
72
+ * "sign in again" names a door a CI runner cannot open.
73
+ */
74
+ function unauthorizedMessage(status, body) {
75
+ const secret = credentialFromEnvironment();
76
+ if (!secret) return status === 401 ? "Authentication expired. Run 'wawesome login' again." : rejectionOf(body, status, `The platform refused this request (HTTP ${status}).`).message;
77
+ return rejectionOf(body, status, `The deploy credential ${credentialPrefix(secret)} was refused (HTTP ${status}).`).message;
78
+ }
79
+ function reportSessionCommandRefused(secret, consequence) {
80
+ console.error(`[wawesome] Error: ${CREDENTIAL_ENV_VAR} is set, so this CLI authenticates as deploy credential ${credentialPrefix(secret)}.`);
81
+ console.error(`[wawesome] ${consequence} Unset ${CREDENTIAL_ENV_VAR} first.`);
82
+ }
83
+ //#endregion
15
84
  //#region src/config.ts
16
85
  /**
17
86
  * Hardcoded Supabase project config for the wawesome.io platform.
18
87
  * These are public values — the anon key is designed to be embedded in clients.
19
88
  */
20
89
  const SUPABASE_URL = "https://vclasavxxoufwymrutai.supabase.co";
90
+ /**
91
+ * Where the documents accepted at signup are published. Fixed rather than
92
+ * resolved per environment: there is one website, and a signup has to be able
93
+ * to link at something a person can actually read before they accept it.
94
+ */
95
+ const LEGAL_URLS = {
96
+ terms: "https://wawesome.io/legal/terms-of-service",
97
+ privacy: "https://wawesome.io/legal/privacy-policy"
98
+ };
21
99
  const SUPABASE_ANON_KEY = "sb_publishable_ZlgDAI9cNZkefEwVPKIngA_ktuvTpjD";
22
100
  /** Path to the user-level settings file (~/.wawesome/settings.json) */
23
101
  function getSettingsPath() {
@@ -51,11 +129,22 @@ function resolveAddress(overrideUrl, envNames, settingsKey, stored, fallback) {
51
129
  return stored || fallback;
52
130
  }
53
131
  function getGatewayUrl(overrideUrl) {
54
- return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", readCredentials()?.gateway_url, GATEWAY_URL_FALLBACK);
132
+ return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", readStoredCredentials()?.gateway_url, GATEWAY_URL_FALLBACK);
133
+ }
134
+ /**
135
+ * A tenant token is only valid at the gateway that issued it, so every other
136
+ * command has to follow the credentials. A login holds no token yet, and
137
+ * reading that rung here made a local gateway outlive the removal of every
138
+ * documented override, since only `logout` clears it. A **Deploy credential**
139
+ * reads the same rungs, and for a sharper reason: following a stale file would
140
+ * present it at a gateway that never minted it.
141
+ */
142
+ function resolveGatewayUrlWithoutSession(overrideUrl) {
143
+ return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", void 0, GATEWAY_URL_FALLBACK);
55
144
  }
56
145
  getGatewayUrl();
57
146
  function getDashboardUrl(overrideUrl) {
58
- return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", readCredentials()?.dashboard_url, DASHBOARD_URL_FALLBACK);
147
+ return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", readStoredCredentials()?.dashboard_url, DASHBOARD_URL_FALLBACK);
59
148
  }
60
149
  /**
61
150
  * The dashboard for the environment this login is going to.
@@ -66,7 +155,7 @@ function getDashboardUrl(overrideUrl) {
66
155
  * so is dropping one where the gateway did not change at all.
67
156
  */
68
157
  function resolveDashboardUrlForLogin(overrideUrl, gatewayUrl) {
69
- const creds = readCredentials();
158
+ const creds = readStoredCredentials();
70
159
  const sameEnvironment = creds?.gateway_url === gatewayUrl;
71
160
  return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", sameEnvironment ? creds?.dashboard_url : void 0, DASHBOARD_URL_FALLBACK);
72
161
  }
@@ -77,9 +166,31 @@ function getCredentialsPath() {
77
166
  return path.join(os.homedir(), ".wawesome", "credentials.json");
78
167
  }
79
168
  /**
80
- * Read stored credentials. Returns null if not logged in.
169
+ * What this process authenticates with: the **Deploy credential** in the
170
+ * environment where one is set, and otherwise the session `login` left on disk.
171
+ *
172
+ * The environment wins outright rather than filling a gap, so a CI runner
173
+ * carrying a stale cached home directory still authenticates as itself.
81
174
  */
82
175
  function readCredentials() {
176
+ const secret = credentialFromEnvironment();
177
+ return secret ? environmentCredential(secret) : readStoredCredentials();
178
+ }
179
+ /**
180
+ * A credential wears the shape of a session so that every command reaches the
181
+ * platform the same way. What it does not carry is the point: no refresh token,
182
+ * because there is nothing behind it to renew, and no Tenant, because the
183
+ * platform resolves that from the credential.
184
+ */
185
+ function environmentCredential(secret) {
186
+ return {
187
+ gateway_url: resolveGatewayUrlWithoutSession(void 0),
188
+ tenant_jwt: secret,
189
+ credential_prefix: credentialPrefix(secret)
190
+ };
191
+ }
192
+ /** The session on disk, whatever the environment says. */
193
+ function readStoredCredentials() {
83
194
  const credPath = getCredentialsPath();
84
195
  if (!fs.existsSync(credPath)) return null;
85
196
  try {
@@ -97,6 +208,7 @@ function readCredentials() {
97
208
  * `writeFileSync` applies only where it creates the file.
98
209
  */
99
210
  function writeCredentials(creds) {
211
+ if (credentialFromEnvironment()) return;
100
212
  const credPath = getCredentialsPath();
101
213
  const dir = path.dirname(credPath);
102
214
  fs.mkdirSync(dir, {
@@ -771,7 +883,7 @@ async function buildJs(entryInput, options) {
771
883
  * that has to name this version — `--version`, the dependency a scaffolded
772
884
  * project pins — reads it here, so a release bumps one file.
773
885
  */
774
- const CLI_VERSION = "0.8.0";
886
+ const CLI_VERSION = "0.9.0";
775
887
  //#endregion
776
888
  //#region src/prompt.ts
777
889
  /**
@@ -844,6 +956,7 @@ async function authorizedFetch(url, init = {}) {
844
956
  const sentAt = Date.now();
845
957
  const res = await fetch(url, renewedJwt ? withBearer(request(), renewedJwt) : request());
846
958
  if (res.status !== 401) return res;
959
+ if (credentialFromEnvironment()) return res;
847
960
  if (renewedJwt && renewedAt > sentAt) return retry(url, request, renewedJwt, res);
848
961
  if (renewedJwt && Date.now() - renewedAt < JUST_RENEWED_MS) return res;
849
962
  const renewed = await renewSession();
@@ -948,50 +1061,6 @@ function endSession(reason) {
948
1061
  process.exit(1);
949
1062
  }
950
1063
  //#endregion
951
- //#region src/errors.ts
952
- /** A rejection carrying the gateway's own prose and, where given, its reason. */
953
- var GatewayError = class extends Error {
954
- status;
955
- reason;
956
- /** The raw response, for `--verbose` to print when the prose is not enough. */
957
- body;
958
- constructor(message, status, reason, body = "") {
959
- super(message);
960
- this.name = "GatewayError";
961
- this.status = status;
962
- this.reason = reason;
963
- this.body = body;
964
- }
965
- };
966
- /**
967
- * Read a failed response into a `GatewayError`.
968
- *
969
- * `fallback` is used only when the response carries no prose of its own — a
970
- * proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
971
- * cased here: what to say about an expired session depends on what the caller
972
- * was doing, so callers handle it before reaching this.
973
- */
974
- async function asGatewayError(res, fallback) {
975
- return rejectionOf(await res.text().catch(() => ""), res.status, fallback);
976
- }
977
- /** The same reading, for a caller that has already taken the body off the wire. */
978
- function rejectionOf(body, status, fallback) {
979
- try {
980
- const parsed = JSON.parse(body);
981
- return new GatewayError(parsed.error || fallback, status, parsed.reason, body);
982
- } catch {
983
- return new GatewayError(fallback, status, void 0, body);
984
- }
985
- }
986
- /** Why the gateway refused, where what was thrown might not be a refusal at all. */
987
- function reasonOf(err) {
988
- return err instanceof GatewayError ? err.reason : void 0;
989
- }
990
- /** What went wrong, as text, whatever was thrown. */
991
- function errorText(err) {
992
- return err instanceof Error ? err.message : String(err);
993
- }
994
- //#endregion
995
1064
  //#region src/tenant.ts
996
1065
  async function fetchTenantDetails(creds) {
997
1066
  const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
@@ -1104,6 +1173,31 @@ async function promptForWorkspaceName(options) {
1104
1173
  throw new Error("No workspace name given. Nothing was created.");
1105
1174
  }
1106
1175
  /**
1176
+ * Ask the person to accept the terms, which is what creating a workspace
1177
+ * through this door means too.
1178
+ *
1179
+ * Declining is the default answer, not the one that needs typing: end of input
1180
+ * takes the default, so a piped login that says nothing has accepted nothing —
1181
+ * the terminal's version of an unticked box. The flag is a word from the person
1182
+ * running the command rather than a prompt, so a pipeline reads the same as a
1183
+ * terminal, and both documents are linked either way.
1184
+ */
1185
+ async function confirmTermsAccepted(options) {
1186
+ if (options.acceptTerms) return;
1187
+ if (!isInteractive()) throw new Error(`Creating a workspace means accepting the terms of service and the privacy policy, and there is no terminal to ask in. Read ${LEGAL_URLS.terms} and ${LEGAL_URLS.privacy}, then re-run with --accept-terms.`);
1188
+ console.log("\n[wawesome] Creating a workspace means accepting:");
1189
+ console.log(` Terms of Service ${LEGAL_URLS.terms}`);
1190
+ console.log(` Privacy Policy ${LEGAL_URLS.privacy}`);
1191
+ const session = openPromptSession();
1192
+ try {
1193
+ const answer = await session.ask(" Accept both?", "n");
1194
+ if (/^y(es)?$/i.test(answer.trim())) return;
1195
+ } finally {
1196
+ session.close();
1197
+ }
1198
+ throw new Error("The terms were not accepted. Nothing was created.");
1199
+ }
1200
+ /**
1107
1201
  * Turn a finished OAuth callback into stored credentials.
1108
1202
  *
1109
1203
  * Split from the callback server so what login stores can be exercised without
@@ -1120,6 +1214,7 @@ async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshTok
1120
1214
  let workspaceSlug;
1121
1215
  if (tenants.length === 0) {
1122
1216
  const chosenName = await promptForWorkspaceName(options);
1217
+ await confirmTermsAccepted(options);
1123
1218
  console.log(`[wawesome] Creating workspace "${chosenName}"...`);
1124
1219
  const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
1125
1220
  method: "POST",
@@ -1127,9 +1222,12 @@ async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshTok
1127
1222
  Authorization: `Bearer ${accessToken}`,
1128
1223
  "Content-Type": "application/json"
1129
1224
  },
1130
- body: JSON.stringify({ tenant_name: chosenName })
1225
+ body: JSON.stringify({
1226
+ tenant_name: chosenName,
1227
+ accepted_terms: true
1228
+ })
1131
1229
  });
1132
- if (!initRes.ok) throw new Error(`Failed to initialize workspace (HTTP ${initRes.status}).`);
1230
+ if (!initRes.ok) throw await asGatewayError(initRes, `Failed to initialize workspace (HTTP ${initRes.status}).`);
1133
1231
  const initData = await initRes.json();
1134
1232
  primaryTenantId = initData.tenant_id;
1135
1233
  workspaceSlug = initData.tenant_slug;
@@ -1186,7 +1284,12 @@ async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshTok
1186
1284
  * 5. Store credentials in ~/.wawesome/credentials.json
1187
1285
  */
1188
1286
  async function login(options) {
1189
- const gatewayUrl = getGatewayUrl(options.gateway || options.api);
1287
+ const credential = credentialFromEnvironment();
1288
+ if (credential) {
1289
+ reportSessionCommandRefused(credential, "A session signed in here would be read by nothing.");
1290
+ process.exit(1);
1291
+ }
1292
+ const gatewayUrl = resolveGatewayUrlWithoutSession(options.gateway || options.api);
1190
1293
  const dashboardUrl = resolveDashboardUrlForLogin(options.dashboard, gatewayUrl);
1191
1294
  const provider = options.provider || "github";
1192
1295
  const isVerbose = Boolean(options.verbose);
@@ -1278,6 +1381,11 @@ async function login(options) {
1278
1381
  * Clear stored credentials.
1279
1382
  */
1280
1383
  function logout() {
1384
+ const credential = credentialFromEnvironment();
1385
+ if (credential) {
1386
+ reportSessionCommandRefused(credential, "The next command would authenticate all the same.");
1387
+ process.exit(1);
1388
+ }
1281
1389
  forgetRenewedSession();
1282
1390
  if (deleteCredentials()) console.log("[wawesome] ✅ Logged out. Credentials removed.");
1283
1391
  else console.log("[wawesome] Not logged in (no credentials found).");
@@ -1287,6 +1395,7 @@ function logout() {
1287
1395
  */
1288
1396
  async function whoami() {
1289
1397
  const creds = readCredentials();
1398
+ if (creds?.credential_prefix) return reportCredential(creds);
1290
1399
  if (!creds) {
1291
1400
  console.log("[wawesome] Not logged in. Run 'wawesome login' to authenticate.");
1292
1401
  return;
@@ -1306,6 +1415,33 @@ async function whoami() {
1306
1415
  console.log(` Tenant: ${creds.tenant_id}`);
1307
1416
  console.log(` Gateway: ${creds.gateway_url}\n`);
1308
1417
  }
1418
+ /**
1419
+ * Name the credential the commands here run as.
1420
+ *
1421
+ * By its prefix, never the secret: what a developer needs is to match the
1422
+ * identity in front of them against the one in their CI settings, and the
1423
+ * output of `whoami` is the sort of thing that ends up pasted into an issue.
1424
+ * The workspace is asked for rather than read, because a credential names no
1425
+ * Tenant of its own — the platform resolves that from the credential.
1426
+ */
1427
+ async function reportCredential(creds) {
1428
+ let workspace = null;
1429
+ let refusal = null;
1430
+ try {
1431
+ workspace = await resolveWorkspace(creds);
1432
+ } catch (err) {
1433
+ if (err instanceof GatewayError && (err.status === 401 || err.status === 403)) refusal = err.message;
1434
+ }
1435
+ console.log("\n[wawesome] Acting as a deploy credential:");
1436
+ console.log(` Credential: ${creds.credential_prefix} (from ${CREDENTIAL_ENV_VAR})`);
1437
+ if (workspace) {
1438
+ console.log(` Workspace: ${workspace.name}`);
1439
+ console.log(` Address: ${workspace.slug}`);
1440
+ }
1441
+ console.log(` Gateway: ${creds.gateway_url}`);
1442
+ if (refusal) console.log(` Refused: ${refusal}`);
1443
+ console.log("");
1444
+ }
1309
1445
  //#endregion
1310
1446
  //#region src/schedules.ts
1311
1447
  /**
@@ -1316,15 +1452,18 @@ async function whoami() {
1316
1452
  * press resume, and file a bug when neither starts it.
1317
1453
  */
1318
1454
  function whenItRuns(schedule) {
1319
- if (schedule.state === "suspended") return "suspended — the workspace owes payment";
1320
1455
  const undeclared = schedule.declared === false;
1456
+ if (schedule.state === "suspended") {
1457
+ const why = "the workspace owes payment, its subscription ended, or it has spent past its plan's allowance";
1458
+ return undeclared ? `suspended, and not in this config file — ${why}; declare it again too` : `suspended — ${why}`;
1459
+ }
1321
1460
  if (schedule.state === "paused") return undeclared ? "paused, and not in this config file — declare it again, then resume" : "paused — resume it to run again";
1322
1461
  if (undeclared) return "not in this config file — disabled, kept";
1323
1462
  if (schedule.state !== "active") return schedule.state;
1324
- return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
1463
+ return schedule.next_fire_at ? `next run ${asUtc$1(schedule.next_fire_at)}` : "next run unknown";
1325
1464
  }
1326
1465
  /** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
1327
- function asUtc(iso) {
1466
+ function asUtc$1(iso) {
1328
1467
  const at = new Date(iso);
1329
1468
  if (Number.isNaN(at.getTime())) return iso;
1330
1469
  const pad = (n) => String(n).padStart(2, "0");
@@ -1872,7 +2011,7 @@ async function deploy(entryInput, options) {
1872
2011
  if (!uploadRes.ok) {
1873
2012
  const errorBody = await uploadRes.text();
1874
2013
  const refusal = rejectionOf(errorBody, uploadRes.status, "Version with this code bundle already exists.");
1875
- if (uploadRes.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
2014
+ if (uploadRes.status === 401 || uploadRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(uploadRes.status, errorBody)}`);
1876
2015
  else if (refusal.reason === "paid-plan-required") {
1877
2016
  console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
1878
2017
  console.error(`[wawesome] ${planLimitAdvice(refusal.reason)}\n`);
@@ -1917,7 +2056,7 @@ async function deploy(entryInput, options) {
1917
2056
  });
1918
2057
  if (!deployRes.ok) {
1919
2058
  const errorBody = await deployRes.text();
1920
- if (deployRes.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
2059
+ if (deployRes.status === 401 || deployRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(deployRes.status, errorBody)}`);
1921
2060
  else {
1922
2061
  console.error(`[wawesome] Error: Deployment failed (HTTP ${deployRes.status}).`);
1923
2062
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -2002,7 +2141,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
2002
2141
  });
2003
2142
  if (!manifestRes.ok) {
2004
2143
  const errorBody = await manifestRes.text();
2005
- if (manifestRes.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
2144
+ if (manifestRes.status === 401 || manifestRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(manifestRes.status, errorBody)}`);
2006
2145
  else if (manifestRes.status === 409) {
2007
2146
  if (continueWhenUnchanged) {
2008
2147
  console.log("[wawesome] Code and files unchanged since the last deploy; applying schedules.");
@@ -2101,7 +2240,7 @@ async function putEnvVar(creds, app, key, value, isSecret) {
2101
2240
  })
2102
2241
  });
2103
2242
  if (!res.ok) {
2104
- if (res.status === 401) throw new GatewayError("Authentication expired. Run 'wawesome login' again.", 401);
2243
+ if (res.status === 401 || res.status === 403) throw new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
2105
2244
  throw await asGatewayError(res, `Failed to set variable '${key}' (HTTP ${res.status}).`);
2106
2245
  }
2107
2246
  return await res.json();
@@ -2157,7 +2296,7 @@ async function listEnvVars(options) {
2157
2296
  });
2158
2297
  if (!res.ok) {
2159
2298
  const errorBody = await res.text();
2160
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
2299
+ if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
2161
2300
  else {
2162
2301
  console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
2163
2302
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -2211,7 +2350,7 @@ async function removeEnvVar(key, options) {
2211
2350
  });
2212
2351
  if (!res.ok) {
2213
2352
  const errorBody = await res.text();
2214
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
2353
+ if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
2215
2354
  else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
2216
2355
  else {
2217
2356
  console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
@@ -2287,7 +2426,7 @@ function installDependencies(dir, options = {}) {
2287
2426
  //#region src/provision.ts
2288
2427
  /** The gateway's own words for a failure, or a plain HTTP status if it gave none. */
2289
2428
  async function rejected(res, fallback) {
2290
- if (res.status === 401) return new GatewayError("Authentication expired. Run 'wawesome login' again.", 401);
2429
+ if (res.status === 401 || res.status === 403) return new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
2291
2430
  return asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
2292
2431
  }
2293
2432
  function authHeaders(creds) {
@@ -2794,7 +2933,7 @@ function anArticle(label, capitalized = false) {
2794
2933
  return `${capitalized ? article[0].toUpperCase() + article.slice(1) : article} ${label}`;
2795
2934
  }
2796
2935
  /** Report a failure the user can act on, and stop. */
2797
- function fail(message, ...advice) {
2936
+ function fail$1(message, ...advice) {
2798
2937
  console.error(`[wawesome] Error: ${message}`);
2799
2938
  for (const line of advice) console.error(`[wawesome] ${line}`);
2800
2939
  process.exit(1);
@@ -2974,7 +3113,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
2974
3113
  try {
2975
3114
  await ensureApp(creds, appSlug);
2976
3115
  } catch (err) {
2977
- fail(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
3116
+ fail$1(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
2978
3117
  }
2979
3118
  for (const { declared, value } of answers) {
2980
3119
  if (!value) {
@@ -2985,7 +3124,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
2985
3124
  try {
2986
3125
  await putEnvVar(creds, appSlug, declared.key, value, declared.secret);
2987
3126
  } catch (err) {
2988
- fail(errorText(err), scaffolded);
3127
+ fail$1(errorText(err), scaffolded);
2989
3128
  }
2990
3129
  console.log(`[wawesome] ✅ Stored ${declared.key}${declared.secret ? " (secret)" : ""}.`);
2991
3130
  }
@@ -2993,7 +3132,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
2993
3132
  try {
2994
3133
  await enableEgressProvider(creds, appSlug, provider);
2995
3134
  } catch (err) {
2996
- fail(errorText(err), scaffolded);
3135
+ fail$1(errorText(err), scaffolded);
2997
3136
  }
2998
3137
  console.log(`[wawesome] ✅ Enabled outbound calls to '${provider}'.`);
2999
3138
  }
@@ -3015,7 +3154,7 @@ async function initFromTemplate(templateName, options) {
3015
3154
  try {
3016
3155
  template = await resolveTemplate(gatewayUrl, templateName);
3017
3156
  } catch (err) {
3018
- fail(errorText(err));
3157
+ fail$1(errorText(err));
3019
3158
  }
3020
3159
  console.log(`[wawesome] Fetching ${template.title} (${template.ref})...`);
3021
3160
  if (isVerbose) console.log(`[wawesome:verbose] GET ${template.archive_url}`);
@@ -3023,7 +3162,7 @@ async function initFromTemplate(templateName, options) {
3023
3162
  try {
3024
3163
  archive = await downloadTemplateArchive(template.archive_url);
3025
3164
  } catch (err) {
3026
- fail(errorText(err));
3165
+ fail$1(errorText(err));
3027
3166
  }
3028
3167
  const staging = fs.mkdtempSync(path.join(os.tmpdir(), "wawesome-template-"));
3029
3168
  const sweepStaging = () => fs.rmSync(staging, {
@@ -3045,10 +3184,10 @@ async function initFromTemplate(templateName, options) {
3045
3184
  });
3046
3185
  manifest = readTemplateManifest(staging);
3047
3186
  } catch (err) {
3048
- fail(errorText(err));
3187
+ fail$1(errorText(err));
3049
3188
  }
3050
3189
  const collisions = collidingPaths(cwd, files);
3051
- if (collisions.length > 0) fail(`${collisions.join(", ")} already exist${collisions.length === 1 ? "s" : ""} here.`, "Run this in an empty directory, or move those files aside first. Nothing was written.");
3190
+ if (collisions.length > 0) fail$1(`${collisions.join(", ")} already exist${collisions.length === 1 ? "s" : ""} here.`, "Run this in an empty directory, or move those files aside first. Nothing was written.");
3052
3191
  tenantSession = await ensureSession(options);
3053
3192
  const session = openPromptSession();
3054
3193
  try {
@@ -3064,7 +3203,7 @@ async function initFromTemplate(templateName, options) {
3064
3203
  });
3065
3204
  repinned = alignCliDependency(cwd, CLI_VERSION);
3066
3205
  } catch (err) {
3067
- fail(errorText(err), `Some of '${template.name}' may have been written to ${cwd}.`);
3206
+ fail$1(errorText(err), `Some of '${template.name}' may have been written to ${cwd}.`);
3068
3207
  }
3069
3208
  console.log(`\n[wawesome] ✅ Scaffolded ${files.length} files from '${template.name}'.`);
3070
3209
  if (repinned) console.log(`[wawesome] Using wawesome ${repinned.to} — the template pinned ${repinned.from}.`);
@@ -3241,7 +3380,7 @@ async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbos
3241
3380
  });
3242
3381
  if (!res.ok) {
3243
3382
  const errorBody = await res.text();
3244
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3383
+ if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
3245
3384
  else {
3246
3385
  console.error(`[wawesome] Error: Failed to list versions (HTTP ${res.status}).`);
3247
3386
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -3444,7 +3583,7 @@ async function fetchInvocationLogBody(gatewayUrl, tenantJwt, invocationId, isVer
3444
3583
  });
3445
3584
  if (!res.ok) {
3446
3585
  const errorText = await res.text();
3447
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3586
+ if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorText)}`);
3448
3587
  else if (res.status === 404) console.error(`[wawesome] Error: Invocation log '${invocationId}' not found or expired (logs are retained for 14 days).`);
3449
3588
  else if (res.status === 400) console.error(`[wawesome] Error: Invalid invocation ID '${invocationId}'.`);
3450
3589
  else {
@@ -3474,7 +3613,7 @@ async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride
3474
3613
  const res = await fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, `?${queryParams.toString()}`, isVerbose);
3475
3614
  if (!res.ok) {
3476
3615
  const errorText = await res.text();
3477
- if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3616
+ if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorText)}`);
3478
3617
  else if (res.status === 404) console.error(`[wawesome] Error: Function '${funcName}' not found.`);
3479
3618
  else {
3480
3619
  console.error(`[wawesome] Error: Failed to list invocations (HTTP ${res.status}).`);
@@ -3656,8 +3795,8 @@ async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbos
3656
3795
  if (isVerbose) console.error(`[wawesome:verbose] ${String(err)}`);
3657
3796
  process.exit(1);
3658
3797
  }
3659
- if (res.status === 401) {
3660
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3798
+ if (res.status === 401 || res.status === 403) {
3799
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, await res.text().catch(() => ""))}`);
3661
3800
  process.exit(1);
3662
3801
  }
3663
3802
  if (res.status === 404) {
@@ -3794,8 +3933,8 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
3794
3933
  console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
3795
3934
  process.exit(1);
3796
3935
  }
3797
- if (res.status === 401) {
3798
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3936
+ if (res.status === 401 || res.status === 403) {
3937
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, await res.text().catch(() => ""))}`);
3799
3938
  process.exit(1);
3800
3939
  }
3801
3940
  if (res.status === 404) {
@@ -3982,8 +4121,8 @@ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollInt
3982
4121
  console.error(`[wawesome] ✖ Run ${meta.status}${meta.failure_reason ? `: ${meta.failure_reason}` : ""}`);
3983
4122
  return;
3984
4123
  }
3985
- if (res.status === 401) {
3986
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4124
+ if (res.status === 401 || res.status === 403) {
4125
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, await res.text().catch(() => ""))}`);
3987
4126
  process.exit(1);
3988
4127
  }
3989
4128
  if (!res.ok && res.status >= 500) {
@@ -4068,8 +4207,8 @@ async function invokeCommand(target, options = {}) {
4068
4207
  const res = await triggerRun(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, options.app, triggerPayload, isVerbose);
4069
4208
  if (!res.ok) {
4070
4209
  const errorBody = await res.text();
4071
- if (res.status === 401) {
4072
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4210
+ if (res.status === 401 || res.status === 403) {
4211
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
4073
4212
  process.exit(1);
4074
4213
  }
4075
4214
  if (res.status === 404) {
@@ -4091,7 +4230,7 @@ async function invokeCommand(target, options = {}) {
4091
4230
  }
4092
4231
  //#endregion
4093
4232
  //#region src/workspace.ts
4094
- function requireCredentials$1() {
4233
+ function requireCredentials$2() {
4095
4234
  const creds = readCredentials();
4096
4235
  if (!creds) {
4097
4236
  console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
@@ -4104,7 +4243,7 @@ function requireCredentials$1() {
4104
4243
  * change — the rename is offered here rather than discovered by attempting it.
4105
4244
  */
4106
4245
  async function showWorkspace(options) {
4107
- const creds = requireCredentials$1();
4246
+ const creds = requireCredentials$2();
4108
4247
  let tenant;
4109
4248
  try {
4110
4249
  tenant = await fetchTenantDetails(creds);
@@ -4139,7 +4278,7 @@ async function showWorkspace(options) {
4139
4278
  * gateway knows something this command could not.
4140
4279
  */
4141
4280
  async function renameWorkspace(slug, options) {
4142
- const creds = requireCredentials$1();
4281
+ const creds = requireCredentials$2();
4143
4282
  if (!slug || !slug.trim()) {
4144
4283
  console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
4145
4284
  process.exit(1);
@@ -4183,6 +4322,267 @@ async function workspaceCommand(action, target, options) {
4183
4322
  process.exit(1);
4184
4323
  }
4185
4324
  //#endregion
4325
+ //#region src/deploy-credentials.ts
4326
+ 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.";
4327
+ function requireCredentials$1() {
4328
+ const creds = readCredentials();
4329
+ if (!creds) {
4330
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
4331
+ process.exit(1);
4332
+ }
4333
+ return creds;
4334
+ }
4335
+ function fail(message, ...rest) {
4336
+ console.error(`[wawesome] Error: ${message}`);
4337
+ for (const line of rest) console.error(`[wawesome] ${line}`);
4338
+ process.exit(1);
4339
+ }
4340
+ /** A repeated flag and a comma-separated one, read as the same list. */
4341
+ function asList(raw) {
4342
+ if (raw === void 0) return void 0;
4343
+ const words = (Array.isArray(raw) ? raw : [raw]).flatMap((word) => String(word).split(",")).map((word) => word.trim()).filter((word) => word.length > 0);
4344
+ return words.length > 0 ? words : void 0;
4345
+ }
4346
+ function parseExpiry(raw) {
4347
+ if (raw === void 0) return void 0;
4348
+ const asked = String(raw).trim().toLowerCase();
4349
+ if (asked === "never") return "never";
4350
+ const days = /^(\d+)\s*d(ays?)?$/.exec(asked) ?? /^(\d+)$/.exec(asked);
4351
+ if (!days) fail(`'${raw}' is not an expiry. Name a number of days (--expires 30) or 'never' (--expires never).`);
4352
+ return { days: Number(days[1]) };
4353
+ }
4354
+ /**
4355
+ * What a target may be repeated back as.
4356
+ *
4357
+ * Somebody revoking a leak pastes the whole secret as often as the prefix, and
4358
+ * a refusal that echoed it would write it into a shell history and a CI log —
4359
+ * for the one feature built on the secret being shown exactly once.
4360
+ */
4361
+ function asShown(target) {
4362
+ if (!target.startsWith("wawe_")) return target;
4363
+ const shown = credentialPrefix(target);
4364
+ return shown === target ? target : `${shown}…`;
4365
+ }
4366
+ /** The Apps a credential names, or `null` where it names none and reaches all. */
4367
+ function namedApps(apps) {
4368
+ return apps && apps.scope === "only" ? apps.apps.join(", ") : null;
4369
+ }
4370
+ /** `2026-11-30 12:00 UTC`. Minutes, because nothing here is read to the second. */
4371
+ function asUtc(iso) {
4372
+ if (!iso) return "never";
4373
+ const at = new Date(iso);
4374
+ if (Number.isNaN(at.getTime())) return iso;
4375
+ return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
4376
+ }
4377
+ function isExpired(credential, now) {
4378
+ if (!credential.expires_at) return false;
4379
+ const at = new Date(credential.expires_at).getTime();
4380
+ return !Number.isNaN(at) && at <= now;
4381
+ }
4382
+ function statusOf(credential, now) {
4383
+ if (credential.revoked_at) return "revoked";
4384
+ if (isExpired(credential, now)) return "expired";
4385
+ return "active";
4386
+ }
4387
+ async function refusal(res, fallback) {
4388
+ if (res.status === 401 || res.status === 403) return new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
4389
+ return asGatewayError(res, fallback);
4390
+ }
4391
+ async function mintCredential(creds, payload) {
4392
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, {
4393
+ method: "POST",
4394
+ headers: {
4395
+ Authorization: `Bearer ${creds.tenant_jwt}`,
4396
+ "Content-Type": "application/json"
4397
+ },
4398
+ body: JSON.stringify(payload)
4399
+ });
4400
+ if (!res.ok) throw await refusal(res, `Failed to mint the credential (HTTP ${res.status}).`);
4401
+ return await res.json();
4402
+ }
4403
+ async function fetchCredentials(creds) {
4404
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
4405
+ if (!res.ok) throw await refusal(res, `Failed to read the credentials (HTTP ${res.status}).`);
4406
+ return (await res.json()).credentials ?? [];
4407
+ }
4408
+ async function revokeCredential(creds, credentialId) {
4409
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}/revoke`, {
4410
+ method: "POST",
4411
+ headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
4412
+ });
4413
+ if (!res.ok) throw await refusal(res, `Failed to revoke the credential (HTTP ${res.status}).`);
4414
+ return await res.json();
4415
+ }
4416
+ /**
4417
+ * Mint one credential.
4418
+ *
4419
+ * The secret goes to stdout on a line of its own and everything a person reads
4420
+ * goes to stderr, so `wawesome credentials mint ci > secret` writes the secret
4421
+ * and not one character besides.
4422
+ */
4423
+ async function mintCredentialCommand(name, options) {
4424
+ 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.");
4425
+ const creds = requireCredentials$1();
4426
+ const payload = { name: name.trim() };
4427
+ const capabilities = asList(options.capability);
4428
+ if (capabilities) payload.capabilities = capabilities;
4429
+ const apps = asList(options.app);
4430
+ if (apps) payload.apps = apps;
4431
+ const expiry = parseExpiry(options.expires);
4432
+ if (expiry !== void 0) payload.expiry = expiry;
4433
+ if (options.verbose) {
4434
+ console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials`);
4435
+ console.error(`[wawesome:verbose] Payload: ${JSON.stringify({ ...payload })}`);
4436
+ }
4437
+ let credential;
4438
+ try {
4439
+ credential = await mintCredential(creds, payload);
4440
+ } catch (err) {
4441
+ fail(errorText(err));
4442
+ }
4443
+ console.error(`\n[wawesome] ✔ Minted deploy credential '${credential.name}'.\n`);
4444
+ console.error(` Prefix: ${credential.prefix}`);
4445
+ console.error(` Capabilities: ${credential.capabilities.join(", ")}`);
4446
+ console.error(` Apps: ${namedApps(credential.apps) ?? "every App in the workspace"}`);
4447
+ console.error(` Expires: ${asUtc(credential.expires_at)}`);
4448
+ console.error(`\n ${SECRET_SHOWN_ONCE}\n`);
4449
+ console.log(credential.secret);
4450
+ console.error("");
4451
+ }
4452
+ async function listCredentialsCommand(options) {
4453
+ const creds = requireCredentials$1();
4454
+ if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
4455
+ let credentials;
4456
+ try {
4457
+ credentials = await fetchCredentials(creds);
4458
+ } catch (err) {
4459
+ fail(errorText(err));
4460
+ }
4461
+ if (credentials.length === 0) {
4462
+ console.log("\n[wawesome] This workspace has no deploy credentials.");
4463
+ console.log("[wawesome] Mint one: \x1B[36mwawesome credentials mint <name>\x1B[0m\n");
4464
+ return;
4465
+ }
4466
+ console.log("\n🔑 \x1B[1mDeploy credentials\x1B[0m\n");
4467
+ for (const line of formatCredentials(credentials, Date.now())) console.log(line);
4468
+ console.log("");
4469
+ }
4470
+ /** Never the secret: no read after minting carries one. */
4471
+ function formatCredentials(credentials, now) {
4472
+ const rows = credentials.map((credential) => ({
4473
+ name: credential.name,
4474
+ prefix: credential.prefix,
4475
+ capabilities: credential.capabilities.join(", "),
4476
+ apps: namedApps(credential.apps) ?? "every App",
4477
+ lastUsed: asUtc(credential.last_used_at),
4478
+ expires: asUtc(credential.expires_at),
4479
+ status: statusOf(credential, now)
4480
+ }));
4481
+ const columns = [
4482
+ {
4483
+ header: "NAME",
4484
+ of: (row) => row.name
4485
+ },
4486
+ {
4487
+ header: "PREFIX",
4488
+ of: (row) => row.prefix
4489
+ },
4490
+ {
4491
+ header: "CAPABILITIES",
4492
+ of: (row) => row.capabilities
4493
+ },
4494
+ {
4495
+ header: "APPS",
4496
+ of: (row) => row.apps
4497
+ },
4498
+ {
4499
+ header: "LAST USED",
4500
+ of: (row) => row.lastUsed
4501
+ },
4502
+ {
4503
+ header: "EXPIRES",
4504
+ of: (row) => row.expires
4505
+ },
4506
+ {
4507
+ header: "STATUS",
4508
+ of: (row) => row.status
4509
+ }
4510
+ ];
4511
+ const widths = columns.map((column) => Math.max(column.header.length, ...rows.map((row) => column.of(row).length)));
4512
+ const line = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i])).join(" | ")}`.trimEnd();
4513
+ return [
4514
+ line(columns.map((column) => column.header)),
4515
+ ` ${widths.map((width) => "-".repeat(width)).join("-|-")}`,
4516
+ ...rows.map((row) => line(columns.map((column) => column.of(row))))
4517
+ ];
4518
+ }
4519
+ /**
4520
+ * Which credential a user meant by a name or a prefix.
4521
+ *
4522
+ * A partial prefix matches, because the point of the displayable prefix is
4523
+ * matching one against a CI settings page by eye — and a partial that names
4524
+ * more than one is refused rather than guessed at, since the guess would be a
4525
+ * revocation.
4526
+ */
4527
+ function matchCredentials(credentials, target) {
4528
+ const typed = target.trim();
4529
+ const named = credentials.filter((credential) => credential.name === typed);
4530
+ if (named.length > 0) return named;
4531
+ const wanted = typed.startsWith("wawe_") ? credentialPrefix(typed) : typed;
4532
+ return credentials.filter((credential) => credential.prefix.startsWith(wanted));
4533
+ }
4534
+ async function revokeCredentialCommand(target, options) {
4535
+ if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials revoke <name-or-prefix>");
4536
+ const creds = requireCredentials$1();
4537
+ if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
4538
+ let credentials;
4539
+ try {
4540
+ credentials = await fetchCredentials(creds);
4541
+ } catch (err) {
4542
+ fail(errorText(err));
4543
+ }
4544
+ const matched = matchCredentials(credentials, target);
4545
+ 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.");
4546
+ 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.");
4547
+ const credential = matched[0];
4548
+ if (credential.revoked_at) {
4549
+ console.log(`[wawesome] '${credential.name}' (${credential.prefix}) was already revoked at ${asUtc(credential.revoked_at)}. Nothing to do.`);
4550
+ return;
4551
+ }
4552
+ if (!options.yes) {
4553
+ if (!isInteractive()) fail(`Revoking asks for confirmation, and there is nobody to ask.`, `Pass --yes to revoke '${credential.name}' (${credential.prefix}) without being asked.`);
4554
+ let confirmed;
4555
+ try {
4556
+ confirmed = await confirm({
4557
+ message: `Revoke '${credential.name}' (${credential.prefix})? Nothing un-revokes it.`,
4558
+ default: false
4559
+ });
4560
+ } catch {
4561
+ confirmed = false;
4562
+ }
4563
+ if (!confirmed) {
4564
+ console.log(`[wawesome] Cancelled. '${credential.name}' still works.`);
4565
+ return;
4566
+ }
4567
+ }
4568
+ if (options.verbose) console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials/${credential.id}/revoke`);
4569
+ let revoked;
4570
+ try {
4571
+ revoked = await revokeCredential(creds, credential.id);
4572
+ } catch (err) {
4573
+ if (err instanceof GatewayError && err.status === 404) fail(`'${credential.name}' (${credential.prefix}) no longer exists in this workspace.`);
4574
+ fail(errorText(err));
4575
+ }
4576
+ console.log(`\n[wawesome] ✔ Revoked '${revoked.name}' (${revoked.prefix}).`);
4577
+ console.log("[wawesome] Anything still presenting it is refused from its very next request.\n");
4578
+ }
4579
+ async function credentialsCommand(action, target, options) {
4580
+ if (!action || action === "list" || action === "ls") return listCredentialsCommand(options);
4581
+ if (action === "mint") return mintCredentialCommand(target, options);
4582
+ if (action === "revoke") return revokeCredentialCommand(target, options);
4583
+ fail(`Unknown credentials action '${action}'.`, "Usage: wawesome credentials [list|mint <name>|revoke <name-or-prefix>]");
4584
+ }
4585
+ //#endregion
4186
4586
  //#region src/cron.ts
4187
4587
  /** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
4188
4588
  function asUtcWithSeconds(iso) {
@@ -4487,8 +4887,8 @@ async function pauseScheduleCommand(target, subtarget, options = {}) {
4487
4887
  const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
4488
4888
  if (!res.ok) {
4489
4889
  const errorBody = await res.text();
4490
- if (res.status === 401) {
4491
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4890
+ if (res.status === 401 || res.status === 403) {
4891
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
4492
4892
  process.exit(1);
4493
4893
  }
4494
4894
  if (res.status === 404) {
@@ -4544,8 +4944,8 @@ async function resumeScheduleCommand(target, subtarget, options = {}) {
4544
4944
  const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
4545
4945
  if (!res.ok) {
4546
4946
  const errorBody = await res.text();
4547
- if (res.status === 401) {
4548
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4947
+ if (res.status === 401 || res.status === 403) {
4948
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
4549
4949
  process.exit(1);
4550
4950
  }
4551
4951
  if (res.status === 404) {
@@ -4583,8 +4983,8 @@ async function runHistoryCommand(target, options = {}) {
4583
4983
  }, isVerbose);
4584
4984
  if (!res.ok) {
4585
4985
  const errorBody = await res.text();
4586
- if (res.status === 401) {
4587
- console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4986
+ if (res.status === 401 || res.status === 403) {
4987
+ console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
4588
4988
  process.exit(1);
4589
4989
  }
4590
4990
  if (res.status === 404) {
@@ -4634,11 +5034,33 @@ cli.command("env [action] [key] [value]", "Manage environment variables (set, li
4634
5034
  cli.command("env set <key> <value>", "Set or overwrite an environment variable on the current app").option("-s, --secret", "Flag variable as secret (write-only)").option("-v, --verbose", "Enable verbose debug output").action((key, value, options) => setEnvVar(key, value, options));
4635
5035
  cli.command("env list", "List environment variables for the current app").alias("env ls").option("-v, --verbose", "Enable verbose debug output").action((options) => listEnvVars(options));
4636
5036
  cli.command("env rm <key>", "Delete an environment variable from the current app").alias("env remove").alias("env delete").alias("env unset").option("-v, --verbose", "Enable verbose debug output").action((key, options) => removeEnvVar(key, options));
4637
- cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--provider <name>", "OAuth provider (default: github)").option("--workspace <name>", "Name for the workspace, when signing up without a terminal to prompt").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
5037
+ cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--provider <name>", "OAuth provider (default: github)").option("--workspace <name>", "Name for the workspace, when signing up without a terminal to prompt").option("--accept-terms", "Accept the terms of service and privacy policy, when signing up without a terminal to prompt").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
4638
5038
  cli.command("logout", "Clear stored authentication credentials").action(() => logout());
4639
5039
  cli.command("whoami", "Show current login session info").action(() => whoami());
4640
5040
  cli.command("workspace [action] [name]", "Show the workspace, or rename its public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n rename <name> Change the public address, while nothing live depends on it").example("wawesome workspace").example("wawesome workspace rename northwind").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
4641
5041
  cli.command("templates [action]", "Browse the template catalog").usage("templates [action]\n\nActions:\n list (ls) Show every available template (default)").example("wawesome templates").example("wawesome templates list").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("-v, --verbose", "Enable verbose debug output").action((action, options) => templatesCommand(action, options));
5042
+ cli.command("credentials [action] [target]", "Mint, list and revoke deploy credentials").alias("credential").usage(`credentials <action> [options]
5043
+
5044
+ A deploy credential is what a CI pipeline or an agent authenticates with, where
5045
+ there is nobody at a keyboard to log in. Minting is the only time the secret is
5046
+ shown. It is printed on its own line, so a pipe catches it and nothing else.
5047
+
5048
+ Actions:
5049
+ list (ls) Show every credential: name, prefix, capabilities, Apps, last use, expiry (default)
5050
+ mint <name> Mint one, printing the secret exactly once
5051
+ revoke <name-or-prefix> Revoke one, immediately and for good
5052
+
5053
+ Examples:
5054
+ wawesome credentials
5055
+ wawesome credentials mint ci-pipeline
5056
+ wawesome credentials mint agent -c logs:read --expires never
5057
+ wawesome credentials mint prod-deploy -c deploy -a prod --expires 30
5058
+ wawesome credentials mint ci-pipeline > /tmp/secret
5059
+ wawesome credentials revoke ci-pipeline
5060
+ wawesome credentials revoke wawe_ab3k9x --yes`).option("-c, --capability <capability>", "Capability to grant: deploy, logs:read (repeatable or comma-separated, default: deploy)").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 revoke confirmation").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => credentialsCommand(action, target, options));
5061
+ cli.command("credentials mint <name>", "Mint a deploy credential, printing its secret exactly once").option("-c, --capability <capability>", "Capability to grant: deploy, logs:read (repeatable or comma-separated)").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));
5062
+ cli.command("credentials list", "List deploy credentials — never their secrets").alias("credentials ls").option("-v, --verbose", "Enable verbose debug output").action((options) => listCredentialsCommand(options));
5063
+ 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));
4642
5064
  cli.command("init", "Scaffold a new function project in the current directory").usage("init [options]\n\nWith --template, the project is fetched from the template catalog, wired up\nfrom what the template declares it needs, and deployed. Run 'wawesome templates'\nto see what is available.").example("wawesome init").example("wawesome init --template stripe-webhook").option("-t, --template <name>", "Scaffold from a catalog template and deploy it").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--no-install", "Skip installing dependencies after scaffolding").option("--root", "Generate a root function router template").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
4643
5065
  cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
4644
5066
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wawesome",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "CLI tool for building and deploying serverless functions on wawesome.io platform",
5
5
  "type": "module",
6
6
  "bin": {