wawesome 0.7.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 +757 -187
  3. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -12,12 +12,91 @@ 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
+ };
99
+ const SUPABASE_ANON_KEY = "sb_publishable_ZlgDAI9cNZkefEwVPKIngA_ktuvTpjD";
21
100
  /** Path to the user-level settings file (~/.wawesome/settings.json) */
22
101
  function getSettingsPath() {
23
102
  return path.join(os.homedir(), ".wawesome", "settings.json");
@@ -50,11 +129,22 @@ function resolveAddress(overrideUrl, envNames, settingsKey, stored, fallback) {
50
129
  return stored || fallback;
51
130
  }
52
131
  function getGatewayUrl(overrideUrl) {
53
- 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);
54
144
  }
55
145
  getGatewayUrl();
56
146
  function getDashboardUrl(overrideUrl) {
57
- 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);
58
148
  }
59
149
  /**
60
150
  * The dashboard for the environment this login is going to.
@@ -65,7 +155,7 @@ function getDashboardUrl(overrideUrl) {
65
155
  * so is dropping one where the gateway did not change at all.
66
156
  */
67
157
  function resolveDashboardUrlForLogin(overrideUrl, gatewayUrl) {
68
- const creds = readCredentials();
158
+ const creds = readStoredCredentials();
69
159
  const sameEnvironment = creds?.gateway_url === gatewayUrl;
70
160
  return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", sameEnvironment ? creds?.dashboard_url : void 0, DASHBOARD_URL_FALLBACK);
71
161
  }
@@ -76,9 +166,31 @@ function getCredentialsPath() {
76
166
  return path.join(os.homedir(), ".wawesome", "credentials.json");
77
167
  }
78
168
  /**
79
- * 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.
80
174
  */
81
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() {
82
194
  const credPath = getCredentialsPath();
83
195
  if (!fs.existsSync(credPath)) return null;
84
196
  try {
@@ -90,12 +202,24 @@ function readCredentials() {
90
202
  }
91
203
  /**
92
204
  * Write credentials to disk, creating the directory if needed.
205
+ *
206
+ * Owner-only, because the file holds a refresh token. The `chmod` is what
207
+ * tightens a file an older CLI left world-readable; a mode passed to
208
+ * `writeFileSync` applies only where it creates the file.
93
209
  */
94
210
  function writeCredentials(creds) {
211
+ if (credentialFromEnvironment()) return;
95
212
  const credPath = getCredentialsPath();
96
213
  const dir = path.dirname(credPath);
97
- fs.mkdirSync(dir, { recursive: true });
98
- fs.writeFileSync(credPath, JSON.stringify(creds, null, 2), "utf-8");
214
+ fs.mkdirSync(dir, {
215
+ recursive: true,
216
+ mode: 448
217
+ });
218
+ fs.writeFileSync(credPath, JSON.stringify(creds, null, 2), {
219
+ encoding: "utf-8",
220
+ mode: 384
221
+ });
222
+ fs.chmodSync(credPath, 384);
99
223
  }
100
224
  /**
101
225
  * Delete stored credentials.
@@ -759,7 +883,7 @@ async function buildJs(entryInput, options) {
759
883
  * that has to name this version — `--version`, the dependency a scaffolded
760
884
  * project pins — reads it here, so a release bumps one file.
761
885
  */
762
- const CLI_VERSION = "0.7.0";
886
+ const CLI_VERSION = "0.9.0";
763
887
  //#endregion
764
888
  //#region src/prompt.ts
765
889
  /**
@@ -816,53 +940,130 @@ function isInteractive() {
816
940
  return Boolean(process.stdin.isTTY);
817
941
  }
818
942
  //#endregion
819
- //#region src/errors.ts
820
- /** A rejection carrying the gateway's own prose and, where given, its reason. */
821
- var GatewayError = class extends Error {
822
- status;
823
- reason;
824
- /** The raw response, for `--verbose` to print when the prose is not enough. */
825
- body;
826
- constructor(message, status, reason, body = "") {
827
- super(message);
828
- this.name = "GatewayError";
829
- this.status = status;
830
- this.reason = reason;
831
- this.body = body;
832
- }
833
- };
943
+ //#region src/session.ts
834
944
  /**
835
- * Read a failed response into a `GatewayError`.
945
+ * A gateway request, retried once against a renewed session if it comes back 401.
836
946
  *
837
- * `fallback` is used only when the response carries no prose of its own — a
838
- * proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
839
- * cased here: what to say about an expired session depends on what the caller
840
- * was doing, so callers handle it before reaching this.
947
+ * A caller's `Authorization` is a snapshot of what was on disk when the command
948
+ * started, so a renewal here replaces it. Otherwise every later request in the
949
+ * same command would spend a 401 learning what this already knows.
950
+ *
951
+ * A body that can only be read once, an asset streamed off disk, passes a
952
+ * function so the retry is built one of its own.
841
953
  */
842
- async function asGatewayError(res, fallback) {
843
- return rejectionOf(await res.text().catch(() => ""), res.status, fallback);
954
+ async function authorizedFetch(url, init = {}) {
955
+ const request = typeof init === "function" ? init : () => init;
956
+ const sentAt = Date.now();
957
+ const res = await fetch(url, renewedJwt ? withBearer(request(), renewedJwt) : request());
958
+ if (res.status !== 401) return res;
959
+ if (credentialFromEnvironment()) return res;
960
+ if (renewedJwt && renewedAt > sentAt) return retry(url, request, renewedJwt, res);
961
+ if (renewedJwt && Date.now() - renewedAt < JUST_RENEWED_MS) return res;
962
+ const renewed = await renewSession();
963
+ if (!renewed) return res;
964
+ return retry(url, request, renewed.tenant_jwt, res);
965
+ }
966
+ /** Thirty minutes of token life leaves this nowhere near a genuine expiry. */
967
+ const JUST_RENEWED_MS = 6e4;
968
+ async function retry(url, request, tenantJwt, refused) {
969
+ await refused.body?.cancel().catch(() => {});
970
+ return fetch(url, withBearer(request(), tenantJwt));
971
+ }
972
+ function withBearer(init, tenantJwt) {
973
+ return {
974
+ ...init,
975
+ headers: {
976
+ ...headerRecord(init.headers),
977
+ Authorization: `Bearer ${tenantJwt}`
978
+ }
979
+ };
844
980
  }
845
- /** The same reading, for a caller that has already taken the body off the wire. */
846
- function rejectionOf(body, status, fallback) {
847
- try {
848
- const parsed = JSON.parse(body);
849
- return new GatewayError(parsed.error || fallback, status, parsed.reason, body);
850
- } catch {
851
- return new GatewayError(fallback, status, void 0, body);
981
+ function headerRecord(headers) {
982
+ if (!headers) return {};
983
+ if (headers instanceof Headers || Array.isArray(headers)) return Object.fromEntries(new Headers(headers).entries());
984
+ return { ...headers };
985
+ }
986
+ let renewal = null;
987
+ let renewedJwt = null;
988
+ let renewedAt = 0;
989
+ /** Drop what this process learned about the session, as ending one does. */
990
+ function forgetRenewedSession() {
991
+ renewal = null;
992
+ renewedJwt = null;
993
+ renewedAt = 0;
994
+ }
995
+ /**
996
+ * Supabase rotates the refresh token on every use and revokes the whole family
997
+ * if a spent one comes back, so requests that meet the 401 together share one
998
+ * renewal instead of racing with the same token.
999
+ */
1000
+ function renewSession() {
1001
+ if (!renewal) {
1002
+ const inFlight = renewOnce().finally(() => {
1003
+ if (renewal === inFlight) renewal = null;
1004
+ });
1005
+ renewal = inFlight;
852
1006
  }
1007
+ return renewal;
853
1008
  }
854
- /** Why the gateway refused, where what was thrown might not be a refusal at all. */
855
- function reasonOf(err) {
856
- return err instanceof GatewayError ? err.reason : void 0;
1009
+ /**
1010
+ * Null where there is nothing to renew from. Credentials written before refresh
1011
+ * tokens were stored still describe a session, so the 401 goes back to the
1012
+ * caller, whose own message for it is the right one.
1013
+ */
1014
+ async function renewOnce() {
1015
+ const creds = readCredentials();
1016
+ if (!creds?.refresh_token) return null;
1017
+ const session = await refreshSupabaseSession(creds.refresh_token);
1018
+ const exchanged = await exchangeForTenantJwt(creds, session.access_token);
1019
+ const renewed = {
1020
+ ...creds,
1021
+ tenant_jwt: exchanged.tenant_jwt,
1022
+ refresh_token: session.refresh_token || creds.refresh_token,
1023
+ tenant_slug: exchanged.tenant_slug || creds.tenant_slug,
1024
+ tenant_name: exchanged.tenant_name || creds.tenant_name
1025
+ };
1026
+ writeCredentials(renewed);
1027
+ renewedJwt = renewed.tenant_jwt;
1028
+ renewedAt = Date.now();
1029
+ return renewed;
857
1030
  }
858
- /** What went wrong, as text, whatever was thrown. */
859
- function errorText(err) {
860
- return err instanceof Error ? err.message : String(err);
1031
+ async function refreshSupabaseSession(refreshToken) {
1032
+ const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
1033
+ method: "POST",
1034
+ headers: {
1035
+ apikey: SUPABASE_ANON_KEY,
1036
+ "Content-Type": "application/json"
1037
+ },
1038
+ body: JSON.stringify({ refresh_token: refreshToken })
1039
+ });
1040
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) endSession("Your sign-in was revoked or has expired, so the session could not be renewed.");
1041
+ if (!res.ok) throw new Error(`Could not renew your session (Supabase HTTP ${res.status}).`);
1042
+ const session = await res.json();
1043
+ if (!session.access_token) throw new Error("Could not renew your session (Supabase returned no access token).");
1044
+ return session;
1045
+ }
1046
+ async function exchangeForTenantJwt(creds, supabaseAccessToken) {
1047
+ const res = await fetch(`${creds.gateway_url}/api/v1/auth/token-exchange`, {
1048
+ method: "POST",
1049
+ headers: {
1050
+ Authorization: `Bearer ${supabaseAccessToken}`,
1051
+ "Content-Type": "application/json"
1052
+ },
1053
+ body: JSON.stringify({ tenant_id: creds.tenant_id })
1054
+ });
1055
+ if (res.status === 401 || res.status === 403) endSession("Your access to this workspace has been withdrawn.");
1056
+ if (!res.ok) throw new Error(`Could not renew your session (token exchange HTTP ${res.status}).`);
1057
+ return await res.json();
1058
+ }
1059
+ function endSession(reason) {
1060
+ console.error(`[wawesome] Error: ${reason} Run 'wawesome login' to sign in again.`);
1061
+ process.exit(1);
861
1062
  }
862
1063
  //#endregion
863
1064
  //#region src/tenant.ts
864
1065
  async function fetchTenantDetails(creds) {
865
- const res = await fetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
1066
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
866
1067
  if (!res.ok) throw await asGatewayError(res, `Failed to read workspace (HTTP ${res.status}).`);
867
1068
  return await res.json();
868
1069
  }
@@ -928,7 +1129,7 @@ function renameAdvice(reason) {
928
1129
  * unusable name comes back as a `malformed` rejection instead.
929
1130
  */
930
1131
  async function renameTenantSlug(creds, slug) {
931
- const res = await fetch(`${creds.gateway_url}/v1/tenant/slug`, {
1132
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/slug`, {
932
1133
  method: "PUT",
933
1134
  headers: {
934
1135
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -972,6 +1173,109 @@ async function promptForWorkspaceName(options) {
972
1173
  throw new Error("No workspace name given. Nothing was created.");
973
1174
  }
974
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
+ /**
1201
+ * Turn a finished OAuth callback into stored credentials.
1202
+ *
1203
+ * Split from the callback server so what login stores can be exercised without
1204
+ * a browser and a port.
1205
+ */
1206
+ async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshToken, options }) {
1207
+ const isVerbose = Boolean(options.verbose);
1208
+ console.log("[wawesome] Fetching your workspaces...");
1209
+ const tenantsRes = await fetch(`${gatewayUrl}/api/v1/me/tenants`, { headers: { Authorization: `Bearer ${accessToken}` } });
1210
+ if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
1211
+ const tenants = await tenantsRes.json();
1212
+ let primaryTenantId;
1213
+ let workspaceName;
1214
+ let workspaceSlug;
1215
+ if (tenants.length === 0) {
1216
+ const chosenName = await promptForWorkspaceName(options);
1217
+ await confirmTermsAccepted(options);
1218
+ console.log(`[wawesome] Creating workspace "${chosenName}"...`);
1219
+ const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
1220
+ method: "POST",
1221
+ headers: {
1222
+ Authorization: `Bearer ${accessToken}`,
1223
+ "Content-Type": "application/json"
1224
+ },
1225
+ body: JSON.stringify({
1226
+ tenant_name: chosenName,
1227
+ accepted_terms: true
1228
+ })
1229
+ });
1230
+ if (!initRes.ok) throw await asGatewayError(initRes, `Failed to initialize workspace (HTTP ${initRes.status}).`);
1231
+ const initData = await initRes.json();
1232
+ primaryTenantId = initData.tenant_id;
1233
+ workspaceSlug = initData.tenant_slug;
1234
+ workspaceName = initData.tenant_name || chosenName;
1235
+ } else primaryTenantId = tenants[0].tenant_id;
1236
+ if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
1237
+ console.log("[wawesome] Exchanging for tenant-scoped credentials...");
1238
+ const exchangeRes = await fetch(`${gatewayUrl}/api/v1/auth/token-exchange`, {
1239
+ method: "POST",
1240
+ headers: {
1241
+ Authorization: `Bearer ${accessToken}`,
1242
+ "Content-Type": "application/json"
1243
+ },
1244
+ body: JSON.stringify({ tenant_id: primaryTenantId })
1245
+ });
1246
+ if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
1247
+ const exchangeData = await exchangeRes.json();
1248
+ workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
1249
+ workspaceName = exchangeData.tenant_name || workspaceName;
1250
+ let userEmail = exchangeData.email || "unknown";
1251
+ try {
1252
+ const payloadPart = exchangeData.tenant_jwt.split(".")[1];
1253
+ const decoded = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf-8"));
1254
+ if (decoded.email) userEmail = decoded.email;
1255
+ } catch {}
1256
+ forgetRenewedSession();
1257
+ writeCredentials({
1258
+ gateway_url: gatewayUrl,
1259
+ dashboard_url: dashboardUrl,
1260
+ tenant_jwt: exchangeData.tenant_jwt,
1261
+ refresh_token: refreshToken,
1262
+ tenant_id: primaryTenantId,
1263
+ user_email: userEmail,
1264
+ tenant_slug: workspaceSlug,
1265
+ tenant_name: workspaceName
1266
+ });
1267
+ console.log("\n======================================================");
1268
+ console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
1269
+ console.log("======================================================");
1270
+ if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
1271
+ if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
1272
+ console.log(` Tenant: ${primaryTenantId}`);
1273
+ console.log(` Gateway: ${gatewayUrl}`);
1274
+ console.log(` Email: ${userEmail}`);
1275
+ console.log("\n Credentials saved to ~/.wawesome/credentials.json");
1276
+ console.log("======================================================\n");
1277
+ }
1278
+ /**
975
1279
  * OAuth login flow:
976
1280
  * 1. Build Supabase OAuth URL
977
1281
  * 2. Open browser
@@ -980,7 +1284,12 @@ async function promptForWorkspaceName(options) {
980
1284
  * 5. Store credentials in ~/.wawesome/credentials.json
981
1285
  */
982
1286
  async function login(options) {
983
- 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);
984
1293
  const dashboardUrl = resolveDashboardUrlForLogin(options.dashboard, gatewayUrl);
985
1294
  const provider = options.provider || "github";
986
1295
  const isVerbose = Boolean(options.verbose);
@@ -1025,6 +1334,7 @@ async function login(options) {
1025
1334
  }
1026
1335
  if (reqUrl.pathname === "/token") {
1027
1336
  const accessToken = reqUrl.searchParams.get("access_token");
1337
+ const refreshToken = reqUrl.searchParams.get("refresh_token");
1028
1338
  res.writeHead(200, { "Content-Type": "text/plain" });
1029
1339
  res.end("Token received by CLI.");
1030
1340
  if (!accessToken) {
@@ -1035,69 +1345,13 @@ async function login(options) {
1035
1345
  }
1036
1346
  if (isVerbose) console.log("[wawesome:verbose] Supabase access token received.");
1037
1347
  try {
1038
- console.log("[wawesome] Fetching your workspaces...");
1039
- const tenantsRes = await fetch(`${gatewayUrl}/api/v1/me/tenants`, { headers: { Authorization: `Bearer ${accessToken}` } });
1040
- if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
1041
- const tenants = await tenantsRes.json();
1042
- let primaryTenantId;
1043
- let workspaceName;
1044
- let workspaceSlug;
1045
- if (tenants.length === 0) {
1046
- const chosenName = await promptForWorkspaceName(options);
1047
- console.log(`[wawesome] Creating workspace "${chosenName}"...`);
1048
- const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
1049
- method: "POST",
1050
- headers: {
1051
- Authorization: `Bearer ${accessToken}`,
1052
- "Content-Type": "application/json"
1053
- },
1054
- body: JSON.stringify({ tenant_name: chosenName })
1055
- });
1056
- if (!initRes.ok) throw new Error(`Failed to initialize workspace (HTTP ${initRes.status}).`);
1057
- const initData = await initRes.json();
1058
- primaryTenantId = initData.tenant_id;
1059
- workspaceSlug = initData.tenant_slug;
1060
- workspaceName = initData.tenant_name || chosenName;
1061
- } else primaryTenantId = tenants[0].tenant_id;
1062
- if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
1063
- console.log("[wawesome] Exchanging for tenant-scoped credentials...");
1064
- const exchangeRes = await fetch(`${gatewayUrl}/api/v1/auth/token-exchange`, {
1065
- method: "POST",
1066
- headers: {
1067
- Authorization: `Bearer ${accessToken}`,
1068
- "Content-Type": "application/json"
1069
- },
1070
- body: JSON.stringify({ tenant_id: primaryTenantId })
1071
- });
1072
- if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
1073
- const exchangeData = await exchangeRes.json();
1074
- workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
1075
- workspaceName = exchangeData.tenant_name || workspaceName;
1076
- let userEmail = exchangeData.email || "unknown";
1077
- try {
1078
- const payloadPart = exchangeData.tenant_jwt.split(".")[1];
1079
- const decoded = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf-8"));
1080
- if (decoded.email) userEmail = decoded.email;
1081
- } catch {}
1082
- writeCredentials({
1083
- gateway_url: gatewayUrl,
1084
- dashboard_url: dashboardUrl,
1085
- tenant_jwt: exchangeData.tenant_jwt,
1086
- tenant_id: primaryTenantId,
1087
- user_email: userEmail,
1088
- tenant_slug: workspaceSlug,
1089
- tenant_name: workspaceName
1348
+ await completeLogin({
1349
+ gatewayUrl,
1350
+ dashboardUrl,
1351
+ accessToken,
1352
+ refreshToken: refreshToken || void 0,
1353
+ options
1090
1354
  });
1091
- console.log("\n======================================================");
1092
- console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
1093
- console.log("======================================================");
1094
- if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
1095
- if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
1096
- console.log(` Tenant: ${primaryTenantId}`);
1097
- console.log(` Gateway: ${gatewayUrl}`);
1098
- console.log(` Email: ${userEmail}`);
1099
- console.log("\n Credentials saved to ~/.wawesome/credentials.json");
1100
- console.log("======================================================\n");
1101
1355
  server.close();
1102
1356
  resolve();
1103
1357
  } catch (err) {
@@ -1127,6 +1381,12 @@ async function login(options) {
1127
1381
  * Clear stored credentials.
1128
1382
  */
1129
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
+ }
1389
+ forgetRenewedSession();
1130
1390
  if (deleteCredentials()) console.log("[wawesome] ✅ Logged out. Credentials removed.");
1131
1391
  else console.log("[wawesome] Not logged in (no credentials found).");
1132
1392
  }
@@ -1135,6 +1395,7 @@ function logout() {
1135
1395
  */
1136
1396
  async function whoami() {
1137
1397
  const creds = readCredentials();
1398
+ if (creds?.credential_prefix) return reportCredential(creds);
1138
1399
  if (!creds) {
1139
1400
  console.log("[wawesome] Not logged in. Run 'wawesome login' to authenticate.");
1140
1401
  return;
@@ -1154,6 +1415,33 @@ async function whoami() {
1154
1415
  console.log(` Tenant: ${creds.tenant_id}`);
1155
1416
  console.log(` Gateway: ${creds.gateway_url}\n`);
1156
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
+ }
1157
1445
  //#endregion
1158
1446
  //#region src/schedules.ts
1159
1447
  /**
@@ -1164,15 +1452,18 @@ async function whoami() {
1164
1452
  * press resume, and file a bug when neither starts it.
1165
1453
  */
1166
1454
  function whenItRuns(schedule) {
1167
- if (schedule.state === "suspended") return "suspended — the workspace owes payment";
1168
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
+ }
1169
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";
1170
1461
  if (undeclared) return "not in this config file — disabled, kept";
1171
1462
  if (schedule.state !== "active") return schedule.state;
1172
- 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";
1173
1464
  }
1174
1465
  /** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
1175
- function asUtc(iso) {
1466
+ function asUtc$1(iso) {
1176
1467
  const at = new Date(iso);
1177
1468
  if (Number.isNaN(at.getTime())) return iso;
1178
1469
  const pad = (n) => String(n).padStart(2, "0");
@@ -1201,7 +1492,7 @@ async function fetchTenantUsage(creds, timeoutMs = USAGE_TIMEOUT_MS) {
1201
1492
  const controller = new AbortController();
1202
1493
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1203
1494
  try {
1204
- const res = await fetch(`${creds.gateway_url}/v1/tenant/usage`, {
1495
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/usage`, {
1205
1496
  headers: { Authorization: `Bearer ${creds.tenant_jwt}` },
1206
1497
  signal: controller.signal
1207
1498
  });
@@ -1697,7 +1988,7 @@ async function deploy(entryInput, options) {
1697
1988
  const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
1698
1989
  if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
1699
1990
  const declaredOnTheWire = declaredFields(declared);
1700
- const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await fetch(uploadUrl, {
1991
+ const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await authorizedFetch(uploadUrl, {
1701
1992
  method: "POST",
1702
1993
  headers: {
1703
1994
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1709,7 +2000,7 @@ async function deploy(entryInput, options) {
1709
2000
  ...declaresSchedules ? { schedules: config.schedules } : {},
1710
2001
  ...declaredOnTheWire
1711
2002
  })
1712
- }) : await fetch(uploadUrl, {
2003
+ }) : await authorizedFetch(uploadUrl, {
1713
2004
  method: "POST",
1714
2005
  headers: {
1715
2006
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1720,7 +2011,7 @@ async function deploy(entryInput, options) {
1720
2011
  if (!uploadRes.ok) {
1721
2012
  const errorBody = await uploadRes.text();
1722
2013
  const refusal = rejectionOf(errorBody, uploadRes.status, "Version with this code bundle already exists.");
1723
- 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)}`);
1724
2015
  else if (refusal.reason === "paid-plan-required") {
1725
2016
  console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
1726
2017
  console.error(`[wawesome] ${planLimitAdvice(refusal.reason)}\n`);
@@ -1755,7 +2046,7 @@ async function deploy(entryInput, options) {
1755
2046
  if (isVerbose) console.log(`[wawesome:verbose] POST ${deployUrl}`);
1756
2047
  const deployBody = {};
1757
2048
  if (version !== void 0) deployBody.version_number = version;
1758
- const deployRes = await fetch(deployUrl, {
2049
+ const deployRes = await authorizedFetch(deployUrl, {
1759
2050
  method: "POST",
1760
2051
  headers: {
1761
2052
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1765,7 +2056,7 @@ async function deploy(entryInput, options) {
1765
2056
  });
1766
2057
  if (!deployRes.ok) {
1767
2058
  const errorBody = await deployRes.text();
1768
- 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)}`);
1769
2060
  else {
1770
2061
  console.error(`[wawesome] Error: Deployment failed (HTTP ${deployRes.status}).`);
1771
2062
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -1836,8 +2127,7 @@ async function deploy(entryInput, options) {
1836
2127
  * changed nothing at all is refused here — before a byte of it has moved.
1837
2128
  */
1838
2129
  async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVerbose, continueWhenUnchanged) {
1839
- const manifestUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/assets/manifest`;
1840
- const manifestRes = await fetch(manifestUrl, {
2130
+ const manifestRes = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/assets/manifest`, {
1841
2131
  method: "POST",
1842
2132
  headers: {
1843
2133
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1851,7 +2141,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
1851
2141
  });
1852
2142
  if (!manifestRes.ok) {
1853
2143
  const errorBody = await manifestRes.text();
1854
- 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)}`);
1855
2145
  else if (manifestRes.status === 409) {
1856
2146
  if (continueWhenUnchanged) {
1857
2147
  console.log("[wawesome] Code and files unchanged since the last deploy; applying schedules.");
@@ -1880,7 +2170,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
1880
2170
  console.log(`[wawesome] Uploading ${toUpload.length} of ${assets.length} asset(s)...`);
1881
2171
  for (const asset of toUpload) {
1882
2172
  if (isVerbose) console.log(`[wawesome:verbose] PUT ${asset.path} (${asset.size_bytes} bytes)`);
1883
- const res = await fetch(`${creds.gateway_url}/v1/assets/${asset.content_hash}`, {
2173
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/assets/${asset.content_hash}`, () => ({
1884
2174
  method: "PUT",
1885
2175
  headers: {
1886
2176
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1889,7 +2179,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
1889
2179
  },
1890
2180
  body: Readable.toWeb(fs.createReadStream(asset.source)),
1891
2181
  duplex: "half"
1892
- });
2182
+ }));
1893
2183
  if (!res.ok) {
1894
2184
  const errorBody = await res.text();
1895
2185
  const refusal = rejectionOf(errorBody, res.status, `Upload of '${asset.path}' failed (HTTP ${res.status}).`);
@@ -1937,8 +2227,7 @@ function loadClientContext() {
1937
2227
  * the time it does, so it needs to say what did land before it gives up.
1938
2228
  */
1939
2229
  async function putEnvVar(creds, app, key, value, isSecret) {
1940
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
1941
- const res = await fetch(url, {
2230
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`, {
1942
2231
  method: "POST",
1943
2232
  headers: {
1944
2233
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1951,7 +2240,7 @@ async function putEnvVar(creds, app, key, value, isSecret) {
1951
2240
  })
1952
2241
  });
1953
2242
  if (!res.ok) {
1954
- 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);
1955
2244
  throw await asGatewayError(res, `Failed to set variable '${key}' (HTTP ${res.status}).`);
1956
2245
  }
1957
2246
  return await res.json();
@@ -2001,13 +2290,13 @@ async function listEnvVars(options) {
2001
2290
  const { creds, app } = loadClientContext();
2002
2291
  const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
2003
2292
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
2004
- const res = await fetch(url, {
2293
+ const res = await authorizedFetch(url, {
2005
2294
  method: "GET",
2006
2295
  headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
2007
2296
  });
2008
2297
  if (!res.ok) {
2009
2298
  const errorBody = await res.text();
2010
- 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)}`);
2011
2300
  else {
2012
2301
  console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
2013
2302
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -2055,13 +2344,13 @@ async function removeEnvVar(key, options) {
2055
2344
  const { creds, app } = loadClientContext();
2056
2345
  const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
2057
2346
  if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
2058
- const res = await fetch(url, {
2347
+ const res = await authorizedFetch(url, {
2059
2348
  method: "DELETE",
2060
2349
  headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
2061
2350
  });
2062
2351
  if (!res.ok) {
2063
2352
  const errorBody = await res.text();
2064
- 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)}`);
2065
2354
  else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
2066
2355
  else {
2067
2356
  console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
@@ -2137,7 +2426,7 @@ function installDependencies(dir, options = {}) {
2137
2426
  //#region src/provision.ts
2138
2427
  /** The gateway's own words for a failure, or a plain HTTP status if it gave none. */
2139
2428
  async function rejected(res, fallback) {
2140
- 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);
2141
2430
  return asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
2142
2431
  }
2143
2432
  function authHeaders(creds) {
@@ -2158,10 +2447,10 @@ function authHeaders(creds) {
2158
2447
  * between is still treated as the success it is.
2159
2448
  */
2160
2449
  async function ensureApp(creds, appSlug) {
2161
- const listRes = await fetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
2450
+ const listRes = await authorizedFetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
2162
2451
  if (!listRes.ok) throw await rejected(listRes, "Could not list the Tenant's apps");
2163
2452
  if ((await listRes.json()).applications?.some((app) => app.slug === appSlug)) return;
2164
- const createRes = await fetch(`${creds.gateway_url}/v1/apps`, {
2453
+ const createRes = await authorizedFetch(`${creds.gateway_url}/v1/apps`, {
2165
2454
  method: "POST",
2166
2455
  headers: authHeaders(creds),
2167
2456
  body: JSON.stringify({
@@ -2182,8 +2471,7 @@ async function ensureApp(creds, appSlug) {
2182
2471
  * scaffolded template and an outbound request that silently fails at runtime.
2183
2472
  */
2184
2473
  async function enableEgressProvider(creds, appSlug, providerKey) {
2185
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`;
2186
- const res = await fetch(url, {
2474
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`, {
2187
2475
  method: "POST",
2188
2476
  headers: authHeaders(creds),
2189
2477
  body: JSON.stringify({ provider_key: providerKey })
@@ -2645,7 +2933,7 @@ function anArticle(label, capitalized = false) {
2645
2933
  return `${capitalized ? article[0].toUpperCase() + article.slice(1) : article} ${label}`;
2646
2934
  }
2647
2935
  /** Report a failure the user can act on, and stop. */
2648
- function fail(message, ...advice) {
2936
+ function fail$1(message, ...advice) {
2649
2937
  console.error(`[wawesome] Error: ${message}`);
2650
2938
  for (const line of advice) console.error(`[wawesome] ${line}`);
2651
2939
  process.exit(1);
@@ -2825,7 +3113,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
2825
3113
  try {
2826
3114
  await ensureApp(creds, appSlug);
2827
3115
  } catch (err) {
2828
- fail(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
3116
+ fail$1(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
2829
3117
  }
2830
3118
  for (const { declared, value } of answers) {
2831
3119
  if (!value) {
@@ -2836,7 +3124,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
2836
3124
  try {
2837
3125
  await putEnvVar(creds, appSlug, declared.key, value, declared.secret);
2838
3126
  } catch (err) {
2839
- fail(errorText(err), scaffolded);
3127
+ fail$1(errorText(err), scaffolded);
2840
3128
  }
2841
3129
  console.log(`[wawesome] ✅ Stored ${declared.key}${declared.secret ? " (secret)" : ""}.`);
2842
3130
  }
@@ -2844,7 +3132,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
2844
3132
  try {
2845
3133
  await enableEgressProvider(creds, appSlug, provider);
2846
3134
  } catch (err) {
2847
- fail(errorText(err), scaffolded);
3135
+ fail$1(errorText(err), scaffolded);
2848
3136
  }
2849
3137
  console.log(`[wawesome] ✅ Enabled outbound calls to '${provider}'.`);
2850
3138
  }
@@ -2866,7 +3154,7 @@ async function initFromTemplate(templateName, options) {
2866
3154
  try {
2867
3155
  template = await resolveTemplate(gatewayUrl, templateName);
2868
3156
  } catch (err) {
2869
- fail(errorText(err));
3157
+ fail$1(errorText(err));
2870
3158
  }
2871
3159
  console.log(`[wawesome] Fetching ${template.title} (${template.ref})...`);
2872
3160
  if (isVerbose) console.log(`[wawesome:verbose] GET ${template.archive_url}`);
@@ -2874,7 +3162,7 @@ async function initFromTemplate(templateName, options) {
2874
3162
  try {
2875
3163
  archive = await downloadTemplateArchive(template.archive_url);
2876
3164
  } catch (err) {
2877
- fail(errorText(err));
3165
+ fail$1(errorText(err));
2878
3166
  }
2879
3167
  const staging = fs.mkdtempSync(path.join(os.tmpdir(), "wawesome-template-"));
2880
3168
  const sweepStaging = () => fs.rmSync(staging, {
@@ -2896,10 +3184,10 @@ async function initFromTemplate(templateName, options) {
2896
3184
  });
2897
3185
  manifest = readTemplateManifest(staging);
2898
3186
  } catch (err) {
2899
- fail(errorText(err));
3187
+ fail$1(errorText(err));
2900
3188
  }
2901
3189
  const collisions = collidingPaths(cwd, files);
2902
- 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.");
2903
3191
  tenantSession = await ensureSession(options);
2904
3192
  const session = openPromptSession();
2905
3193
  try {
@@ -2915,7 +3203,7 @@ async function initFromTemplate(templateName, options) {
2915
3203
  });
2916
3204
  repinned = alignCliDependency(cwd, CLI_VERSION);
2917
3205
  } catch (err) {
2918
- 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}.`);
2919
3207
  }
2920
3208
  console.log(`\n[wawesome] ✅ Scaffolded ${files.length} files from '${template.name}'.`);
2921
3209
  if (repinned) console.log(`[wawesome] Using wawesome ${repinned.to} — the template pinned ${repinned.from}.`);
@@ -3086,13 +3374,13 @@ const RECLAIMED = "⚠ [assets reclaimed]";
3086
3374
  async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
3087
3375
  const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
3088
3376
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3089
- const res = await fetch(url, {
3377
+ const res = await authorizedFetch(url, {
3090
3378
  method: "GET",
3091
3379
  headers: { Authorization: `Bearer ${tenantJwt}` }
3092
3380
  });
3093
3381
  if (!res.ok) {
3094
3382
  const errorBody = await res.text();
3095
- 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)}`);
3096
3384
  else {
3097
3385
  console.error(`[wawesome] Error: Failed to list versions (HTTP ${res.status}).`);
3098
3386
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -3199,8 +3487,7 @@ async function switchVersion(targetInput, options) {
3199
3487
  }
3200
3488
  }
3201
3489
  console.log(`[wawesome] Promoting v${selectedVersionNum} to ${env}...`);
3202
- const deployUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/deploy`;
3203
- const deployRes = await fetch(deployUrl, {
3490
+ const deployRes = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/deploy`, {
3204
3491
  method: "POST",
3205
3492
  headers: {
3206
3493
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -3290,13 +3577,13 @@ async function logsCommand(target, options = {}) {
3290
3577
  async function fetchInvocationLogBody(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3291
3578
  const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
3292
3579
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3293
- const res = await fetch(url, {
3580
+ const res = await authorizedFetch(url, {
3294
3581
  method: "GET",
3295
3582
  headers: { Authorization: `Bearer ${tenantJwt}` }
3296
3583
  });
3297
3584
  if (!res.ok) {
3298
3585
  const errorText = await res.text();
3299
- 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)}`);
3300
3587
  else if (res.status === 404) console.error(`[wawesome] Error: Invocation log '${invocationId}' not found or expired (logs are retained for 14 days).`);
3301
3588
  else if (res.status === 400) console.error(`[wawesome] Error: Invalid invocation ID '${invocationId}'.`);
3302
3589
  else {
@@ -3326,7 +3613,7 @@ async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride
3326
3613
  const res = await fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, `?${queryParams.toString()}`, isVerbose);
3327
3614
  if (!res.ok) {
3328
3615
  const errorText = await res.text();
3329
- 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)}`);
3330
3617
  else if (res.status === 404) console.error(`[wawesome] Error: Function '${funcName}' not found.`);
3331
3618
  else {
3332
3619
  console.error(`[wawesome] Error: Failed to list invocations (HTTP ${res.status}).`);
@@ -3366,7 +3653,7 @@ async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride
3366
3653
  async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, queryString, isVerbose) {
3367
3654
  const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/invocations${queryString}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
3368
3655
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3369
- let res = await fetch(url, {
3656
+ let res = await authorizedFetch(url, {
3370
3657
  method: "GET",
3371
3658
  headers: { Authorization: `Bearer ${tenantJwt}` }
3372
3659
  });
@@ -3374,7 +3661,7 @@ async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug
3374
3661
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
3375
3662
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
3376
3663
  try {
3377
- const fallbackRes = await fetch(fallbackUrl, {
3664
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
3378
3665
  method: "GET",
3379
3666
  headers: { Authorization: `Bearer ${tenantJwt}` }
3380
3667
  });
@@ -3486,7 +3773,7 @@ async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbos
3486
3773
  while (true) {
3487
3774
  let res = null;
3488
3775
  try {
3489
- res = await fetch(url, {
3776
+ res = await authorizedFetch(url, {
3490
3777
  method: "GET",
3491
3778
  headers: {
3492
3779
  Authorization: `Bearer ${tenantJwt}`,
@@ -3508,8 +3795,8 @@ async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbos
3508
3795
  if (isVerbose) console.error(`[wawesome:verbose] ${String(err)}`);
3509
3796
  process.exit(1);
3510
3797
  }
3511
- if (res.status === 401) {
3512
- 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(() => ""))}`);
3513
3800
  process.exit(1);
3514
3801
  }
3515
3802
  if (res.status === 404) {
@@ -3618,18 +3905,18 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
3618
3905
  let res = null;
3619
3906
  try {
3620
3907
  if (scopedUrl) {
3621
- res = await fetch(scopedUrl, {
3908
+ res = await authorizedFetch(scopedUrl, {
3622
3909
  headers,
3623
3910
  signal: controller.signal
3624
3911
  });
3625
3912
  if (res.status === 404) {
3626
3913
  if (isVerbose) console.error(`[wawesome:verbose] 404 on app-scoped route, retrying unscoped.`);
3627
- res = await fetch(unscopedUrl, {
3914
+ res = await authorizedFetch(unscopedUrl, {
3628
3915
  headers,
3629
3916
  signal: controller.signal
3630
3917
  });
3631
3918
  }
3632
- } else res = await fetch(unscopedUrl, {
3919
+ } else res = await authorizedFetch(unscopedUrl, {
3633
3920
  headers,
3634
3921
  signal: controller.signal
3635
3922
  });
@@ -3646,8 +3933,8 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
3646
3933
  console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
3647
3934
  process.exit(1);
3648
3935
  }
3649
- if (res.status === 401) {
3650
- 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(() => ""))}`);
3651
3938
  process.exit(1);
3652
3939
  }
3653
3940
  if (res.status === 404) {
@@ -3733,7 +4020,7 @@ function printOutcome(meta) {
3733
4020
  async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, payload, isVerbose) {
3734
4021
  const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
3735
4022
  if (isVerbose) console.log(`[wawesome:verbose] POST ${url}`);
3736
- let res = await fetch(url, {
4023
+ let res = await authorizedFetch(url, {
3737
4024
  method: "POST",
3738
4025
  headers: {
3739
4026
  Authorization: `Bearer ${tenantJwt}`,
@@ -3745,7 +4032,7 @@ async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride,
3745
4032
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
3746
4033
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
3747
4034
  try {
3748
- const fallbackRes = await fetch(fallbackUrl, {
4035
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
3749
4036
  method: "POST",
3750
4037
  headers: {
3751
4038
  Authorization: `Bearer ${tenantJwt}`,
@@ -3761,7 +4048,7 @@ async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride,
3761
4048
  async function fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3762
4049
  const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}`;
3763
4050
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3764
- const res = await fetch(url, {
4051
+ const res = await authorizedFetch(url, {
3765
4052
  method: "GET",
3766
4053
  headers: { Authorization: `Bearer ${tenantJwt}` }
3767
4054
  });
@@ -3771,7 +4058,7 @@ async function fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVe
3771
4058
  async function fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3772
4059
  const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
3773
4060
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3774
- const res = await fetch(url, {
4061
+ const res = await authorizedFetch(url, {
3775
4062
  method: "GET",
3776
4063
  headers: { Authorization: `Bearer ${tenantJwt}` }
3777
4064
  });
@@ -3795,7 +4082,7 @@ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollInt
3795
4082
  if (controller.signal.aborted) return;
3796
4083
  let res = null;
3797
4084
  try {
3798
- res = await fetch(streamUrl, {
4085
+ res = await authorizedFetch(streamUrl, {
3799
4086
  method: "GET",
3800
4087
  headers: {
3801
4088
  Authorization: `Bearer ${tenantJwt}`,
@@ -3834,8 +4121,8 @@ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollInt
3834
4121
  console.error(`[wawesome] ✖ Run ${meta.status}${meta.failure_reason ? `: ${meta.failure_reason}` : ""}`);
3835
4122
  return;
3836
4123
  }
3837
- if (res.status === 401) {
3838
- 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(() => ""))}`);
3839
4126
  process.exit(1);
3840
4127
  }
3841
4128
  if (!res.ok && res.status >= 500) {
@@ -3920,8 +4207,8 @@ async function invokeCommand(target, options = {}) {
3920
4207
  const res = await triggerRun(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, options.app, triggerPayload, isVerbose);
3921
4208
  if (!res.ok) {
3922
4209
  const errorBody = await res.text();
3923
- if (res.status === 401) {
3924
- 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)}`);
3925
4212
  process.exit(1);
3926
4213
  }
3927
4214
  if (res.status === 404) {
@@ -3943,7 +4230,7 @@ async function invokeCommand(target, options = {}) {
3943
4230
  }
3944
4231
  //#endregion
3945
4232
  //#region src/workspace.ts
3946
- function requireCredentials$1() {
4233
+ function requireCredentials$2() {
3947
4234
  const creds = readCredentials();
3948
4235
  if (!creds) {
3949
4236
  console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
@@ -3956,7 +4243,7 @@ function requireCredentials$1() {
3956
4243
  * change — the rename is offered here rather than discovered by attempting it.
3957
4244
  */
3958
4245
  async function showWorkspace(options) {
3959
- const creds = requireCredentials$1();
4246
+ const creds = requireCredentials$2();
3960
4247
  let tenant;
3961
4248
  try {
3962
4249
  tenant = await fetchTenantDetails(creds);
@@ -3991,7 +4278,7 @@ async function showWorkspace(options) {
3991
4278
  * gateway knows something this command could not.
3992
4279
  */
3993
4280
  async function renameWorkspace(slug, options) {
3994
- const creds = requireCredentials$1();
4281
+ const creds = requireCredentials$2();
3995
4282
  if (!slug || !slug.trim()) {
3996
4283
  console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
3997
4284
  process.exit(1);
@@ -4035,6 +4322,267 @@ async function workspaceCommand(action, target, options) {
4035
4322
  process.exit(1);
4036
4323
  }
4037
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
4038
4586
  //#region src/cron.ts
4039
4587
  /** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
4040
4588
  function asUtcWithSeconds(iso) {
@@ -4145,7 +4693,7 @@ function formatRunHistoryTable(runs) {
4145
4693
  async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug, isVerbose) {
4146
4694
  const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
4147
4695
  if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
4148
- let res = await fetch(scopedUrl, {
4696
+ let res = await authorizedFetch(scopedUrl, {
4149
4697
  method: "GET",
4150
4698
  headers: { Authorization: `Bearer ${tenantJwt}` }
4151
4699
  });
@@ -4153,7 +4701,7 @@ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug,
4153
4701
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
4154
4702
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
4155
4703
  try {
4156
- const fallbackRes = await fetch(fallbackUrl, {
4704
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4157
4705
  method: "GET",
4158
4706
  headers: { Authorization: `Bearer ${tenantJwt}` }
4159
4707
  });
@@ -4167,7 +4715,7 @@ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug,
4167
4715
  async function fetchAppFunctions(gatewayUrl, tenantJwt, appSlug, isVerbose) {
4168
4716
  const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions`;
4169
4717
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
4170
- const res = await fetch(url, {
4718
+ const res = await authorizedFetch(url, {
4171
4719
  method: "GET",
4172
4720
  headers: { Authorization: `Bearer ${tenantJwt}` }
4173
4721
  });
@@ -4178,7 +4726,7 @@ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, a
4178
4726
  const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
4179
4727
  const body = reason?.trim() ? JSON.stringify({ reason: reason.trim() }) : void 0;
4180
4728
  if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
4181
- let res = await fetch(scopedUrl, {
4729
+ let res = await authorizedFetch(scopedUrl, {
4182
4730
  method: "POST",
4183
4731
  headers: {
4184
4732
  Authorization: `Bearer ${tenantJwt}`,
@@ -4190,7 +4738,7 @@ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, a
4190
4738
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
4191
4739
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
4192
4740
  try {
4193
- const fallbackRes = await fetch(fallbackUrl, {
4741
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4194
4742
  method: "POST",
4195
4743
  headers: {
4196
4744
  Authorization: `Bearer ${tenantJwt}`,
@@ -4206,7 +4754,7 @@ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, a
4206
4754
  async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, isVerbose) {
4207
4755
  const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
4208
4756
  if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
4209
- let res = await fetch(scopedUrl, {
4757
+ let res = await authorizedFetch(scopedUrl, {
4210
4758
  method: "POST",
4211
4759
  headers: { Authorization: `Bearer ${tenantJwt}` }
4212
4760
  });
@@ -4214,7 +4762,7 @@ async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName,
4214
4762
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
4215
4763
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
4216
4764
  try {
4217
- const fallbackRes = await fetch(fallbackUrl, {
4765
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4218
4766
  method: "POST",
4219
4767
  headers: { Authorization: `Bearer ${tenantJwt}` }
4220
4768
  });
@@ -4231,7 +4779,7 @@ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug,
4231
4779
  const qs = params.toString() ? `?${params.toString()}` : "";
4232
4780
  const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs${qs}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
4233
4781
  if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
4234
- let res = await fetch(scopedUrl, {
4782
+ let res = await authorizedFetch(scopedUrl, {
4235
4783
  method: "GET",
4236
4784
  headers: { Authorization: `Bearer ${tenantJwt}` }
4237
4785
  });
@@ -4239,7 +4787,7 @@ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug,
4239
4787
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
4240
4788
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
4241
4789
  try {
4242
- const fallbackRes = await fetch(fallbackUrl, {
4790
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4243
4791
  method: "GET",
4244
4792
  headers: { Authorization: `Bearer ${tenantJwt}` }
4245
4793
  });
@@ -4339,8 +4887,8 @@ async function pauseScheduleCommand(target, subtarget, options = {}) {
4339
4887
  const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
4340
4888
  if (!res.ok) {
4341
4889
  const errorBody = await res.text();
4342
- if (res.status === 401) {
4343
- 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)}`);
4344
4892
  process.exit(1);
4345
4893
  }
4346
4894
  if (res.status === 404) {
@@ -4396,8 +4944,8 @@ async function resumeScheduleCommand(target, subtarget, options = {}) {
4396
4944
  const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
4397
4945
  if (!res.ok) {
4398
4946
  const errorBody = await res.text();
4399
- if (res.status === 401) {
4400
- 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)}`);
4401
4949
  process.exit(1);
4402
4950
  }
4403
4951
  if (res.status === 404) {
@@ -4435,8 +4983,8 @@ async function runHistoryCommand(target, options = {}) {
4435
4983
  }, isVerbose);
4436
4984
  if (!res.ok) {
4437
4985
  const errorBody = await res.text();
4438
- if (res.status === 401) {
4439
- 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)}`);
4440
4988
  process.exit(1);
4441
4989
  }
4442
4990
  if (res.status === 404) {
@@ -4486,11 +5034,33 @@ cli.command("env [action] [key] [value]", "Manage environment variables (set, li
4486
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));
4487
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));
4488
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));
4489
- 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));
4490
5038
  cli.command("logout", "Clear stored authentication credentials").action(() => logout());
4491
5039
  cli.command("whoami", "Show current login session info").action(() => whoami());
4492
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));
4493
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));
4494
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));
4495
5065
  cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
4496
5066