wawesome 0.6.0 → 0.8.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 +85 -2
  2. package/dist/index.mjs +1063 -109
  3. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -18,6 +18,7 @@ import zlib from "node:zlib";
18
18
  * These are public values — the anon key is designed to be embedded in clients.
19
19
  */
20
20
  const SUPABASE_URL = "https://vclasavxxoufwymrutai.supabase.co";
21
+ const SUPABASE_ANON_KEY = "sb_publishable_ZlgDAI9cNZkefEwVPKIngA_ktuvTpjD";
21
22
  /** Path to the user-level settings file (~/.wawesome/settings.json) */
22
23
  function getSettingsPath() {
23
24
  return path.join(os.homedir(), ".wawesome", "settings.json");
@@ -90,12 +91,23 @@ function readCredentials() {
90
91
  }
91
92
  /**
92
93
  * Write credentials to disk, creating the directory if needed.
94
+ *
95
+ * Owner-only, because the file holds a refresh token. The `chmod` is what
96
+ * tightens a file an older CLI left world-readable; a mode passed to
97
+ * `writeFileSync` applies only where it creates the file.
93
98
  */
94
99
  function writeCredentials(creds) {
95
100
  const credPath = getCredentialsPath();
96
101
  const dir = path.dirname(credPath);
97
- fs.mkdirSync(dir, { recursive: true });
98
- fs.writeFileSync(credPath, JSON.stringify(creds, null, 2), "utf-8");
102
+ fs.mkdirSync(dir, {
103
+ recursive: true,
104
+ mode: 448
105
+ });
106
+ fs.writeFileSync(credPath, JSON.stringify(creds, null, 2), {
107
+ encoding: "utf-8",
108
+ mode: 384
109
+ });
110
+ fs.chmodSync(credPath, 384);
99
111
  }
100
112
  /**
101
113
  * Delete stored credentials.
@@ -759,7 +771,7 @@ async function buildJs(entryInput, options) {
759
771
  * that has to name this version — `--version`, the dependency a scaffolded
760
772
  * project pins — reads it here, so a release bumps one file.
761
773
  */
762
- const CLI_VERSION = "0.6.0";
774
+ const CLI_VERSION = "0.8.0";
763
775
  //#endregion
764
776
  //#region src/prompt.ts
765
777
  /**
@@ -816,6 +828,126 @@ function isInteractive() {
816
828
  return Boolean(process.stdin.isTTY);
817
829
  }
818
830
  //#endregion
831
+ //#region src/session.ts
832
+ /**
833
+ * A gateway request, retried once against a renewed session if it comes back 401.
834
+ *
835
+ * A caller's `Authorization` is a snapshot of what was on disk when the command
836
+ * started, so a renewal here replaces it. Otherwise every later request in the
837
+ * same command would spend a 401 learning what this already knows.
838
+ *
839
+ * A body that can only be read once, an asset streamed off disk, passes a
840
+ * function so the retry is built one of its own.
841
+ */
842
+ async function authorizedFetch(url, init = {}) {
843
+ const request = typeof init === "function" ? init : () => init;
844
+ const sentAt = Date.now();
845
+ const res = await fetch(url, renewedJwt ? withBearer(request(), renewedJwt) : request());
846
+ if (res.status !== 401) return res;
847
+ if (renewedJwt && renewedAt > sentAt) return retry(url, request, renewedJwt, res);
848
+ if (renewedJwt && Date.now() - renewedAt < JUST_RENEWED_MS) return res;
849
+ const renewed = await renewSession();
850
+ if (!renewed) return res;
851
+ return retry(url, request, renewed.tenant_jwt, res);
852
+ }
853
+ /** Thirty minutes of token life leaves this nowhere near a genuine expiry. */
854
+ const JUST_RENEWED_MS = 6e4;
855
+ async function retry(url, request, tenantJwt, refused) {
856
+ await refused.body?.cancel().catch(() => {});
857
+ return fetch(url, withBearer(request(), tenantJwt));
858
+ }
859
+ function withBearer(init, tenantJwt) {
860
+ return {
861
+ ...init,
862
+ headers: {
863
+ ...headerRecord(init.headers),
864
+ Authorization: `Bearer ${tenantJwt}`
865
+ }
866
+ };
867
+ }
868
+ function headerRecord(headers) {
869
+ if (!headers) return {};
870
+ if (headers instanceof Headers || Array.isArray(headers)) return Object.fromEntries(new Headers(headers).entries());
871
+ return { ...headers };
872
+ }
873
+ let renewal = null;
874
+ let renewedJwt = null;
875
+ let renewedAt = 0;
876
+ /** Drop what this process learned about the session, as ending one does. */
877
+ function forgetRenewedSession() {
878
+ renewal = null;
879
+ renewedJwt = null;
880
+ renewedAt = 0;
881
+ }
882
+ /**
883
+ * Supabase rotates the refresh token on every use and revokes the whole family
884
+ * if a spent one comes back, so requests that meet the 401 together share one
885
+ * renewal instead of racing with the same token.
886
+ */
887
+ function renewSession() {
888
+ if (!renewal) {
889
+ const inFlight = renewOnce().finally(() => {
890
+ if (renewal === inFlight) renewal = null;
891
+ });
892
+ renewal = inFlight;
893
+ }
894
+ return renewal;
895
+ }
896
+ /**
897
+ * Null where there is nothing to renew from. Credentials written before refresh
898
+ * tokens were stored still describe a session, so the 401 goes back to the
899
+ * caller, whose own message for it is the right one.
900
+ */
901
+ async function renewOnce() {
902
+ const creds = readCredentials();
903
+ if (!creds?.refresh_token) return null;
904
+ const session = await refreshSupabaseSession(creds.refresh_token);
905
+ const exchanged = await exchangeForTenantJwt(creds, session.access_token);
906
+ const renewed = {
907
+ ...creds,
908
+ tenant_jwt: exchanged.tenant_jwt,
909
+ refresh_token: session.refresh_token || creds.refresh_token,
910
+ tenant_slug: exchanged.tenant_slug || creds.tenant_slug,
911
+ tenant_name: exchanged.tenant_name || creds.tenant_name
912
+ };
913
+ writeCredentials(renewed);
914
+ renewedJwt = renewed.tenant_jwt;
915
+ renewedAt = Date.now();
916
+ return renewed;
917
+ }
918
+ async function refreshSupabaseSession(refreshToken) {
919
+ const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
920
+ method: "POST",
921
+ headers: {
922
+ apikey: SUPABASE_ANON_KEY,
923
+ "Content-Type": "application/json"
924
+ },
925
+ body: JSON.stringify({ refresh_token: refreshToken })
926
+ });
927
+ 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.");
928
+ if (!res.ok) throw new Error(`Could not renew your session (Supabase HTTP ${res.status}).`);
929
+ const session = await res.json();
930
+ if (!session.access_token) throw new Error("Could not renew your session (Supabase returned no access token).");
931
+ return session;
932
+ }
933
+ async function exchangeForTenantJwt(creds, supabaseAccessToken) {
934
+ const res = await fetch(`${creds.gateway_url}/api/v1/auth/token-exchange`, {
935
+ method: "POST",
936
+ headers: {
937
+ Authorization: `Bearer ${supabaseAccessToken}`,
938
+ "Content-Type": "application/json"
939
+ },
940
+ body: JSON.stringify({ tenant_id: creds.tenant_id })
941
+ });
942
+ if (res.status === 401 || res.status === 403) endSession("Your access to this workspace has been withdrawn.");
943
+ if (!res.ok) throw new Error(`Could not renew your session (token exchange HTTP ${res.status}).`);
944
+ return await res.json();
945
+ }
946
+ function endSession(reason) {
947
+ console.error(`[wawesome] Error: ${reason} Run 'wawesome login' to sign in again.`);
948
+ process.exit(1);
949
+ }
950
+ //#endregion
819
951
  //#region src/errors.ts
820
952
  /** A rejection carrying the gateway's own prose and, where given, its reason. */
821
953
  var GatewayError = class extends Error {
@@ -862,7 +994,7 @@ function errorText(err) {
862
994
  //#endregion
863
995
  //#region src/tenant.ts
864
996
  async function fetchTenantDetails(creds) {
865
- const res = await fetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
997
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
866
998
  if (!res.ok) throw await asGatewayError(res, `Failed to read workspace (HTTP ${res.status}).`);
867
999
  return await res.json();
868
1000
  }
@@ -928,7 +1060,7 @@ function renameAdvice(reason) {
928
1060
  * unusable name comes back as a `malformed` rejection instead.
929
1061
  */
930
1062
  async function renameTenantSlug(creds, slug) {
931
- const res = await fetch(`${creds.gateway_url}/v1/tenant/slug`, {
1063
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/slug`, {
932
1064
  method: "PUT",
933
1065
  headers: {
934
1066
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -972,6 +1104,80 @@ async function promptForWorkspaceName(options) {
972
1104
  throw new Error("No workspace name given. Nothing was created.");
973
1105
  }
974
1106
  /**
1107
+ * Turn a finished OAuth callback into stored credentials.
1108
+ *
1109
+ * Split from the callback server so what login stores can be exercised without
1110
+ * a browser and a port.
1111
+ */
1112
+ async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshToken, options }) {
1113
+ const isVerbose = Boolean(options.verbose);
1114
+ console.log("[wawesome] Fetching your workspaces...");
1115
+ const tenantsRes = await fetch(`${gatewayUrl}/api/v1/me/tenants`, { headers: { Authorization: `Bearer ${accessToken}` } });
1116
+ if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
1117
+ const tenants = await tenantsRes.json();
1118
+ let primaryTenantId;
1119
+ let workspaceName;
1120
+ let workspaceSlug;
1121
+ if (tenants.length === 0) {
1122
+ const chosenName = await promptForWorkspaceName(options);
1123
+ console.log(`[wawesome] Creating workspace "${chosenName}"...`);
1124
+ const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
1125
+ method: "POST",
1126
+ headers: {
1127
+ Authorization: `Bearer ${accessToken}`,
1128
+ "Content-Type": "application/json"
1129
+ },
1130
+ body: JSON.stringify({ tenant_name: chosenName })
1131
+ });
1132
+ if (!initRes.ok) throw new Error(`Failed to initialize workspace (HTTP ${initRes.status}).`);
1133
+ const initData = await initRes.json();
1134
+ primaryTenantId = initData.tenant_id;
1135
+ workspaceSlug = initData.tenant_slug;
1136
+ workspaceName = initData.tenant_name || chosenName;
1137
+ } else primaryTenantId = tenants[0].tenant_id;
1138
+ if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
1139
+ console.log("[wawesome] Exchanging for tenant-scoped credentials...");
1140
+ const exchangeRes = await fetch(`${gatewayUrl}/api/v1/auth/token-exchange`, {
1141
+ method: "POST",
1142
+ headers: {
1143
+ Authorization: `Bearer ${accessToken}`,
1144
+ "Content-Type": "application/json"
1145
+ },
1146
+ body: JSON.stringify({ tenant_id: primaryTenantId })
1147
+ });
1148
+ if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
1149
+ const exchangeData = await exchangeRes.json();
1150
+ workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
1151
+ workspaceName = exchangeData.tenant_name || workspaceName;
1152
+ let userEmail = exchangeData.email || "unknown";
1153
+ try {
1154
+ const payloadPart = exchangeData.tenant_jwt.split(".")[1];
1155
+ const decoded = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf-8"));
1156
+ if (decoded.email) userEmail = decoded.email;
1157
+ } catch {}
1158
+ forgetRenewedSession();
1159
+ writeCredentials({
1160
+ gateway_url: gatewayUrl,
1161
+ dashboard_url: dashboardUrl,
1162
+ tenant_jwt: exchangeData.tenant_jwt,
1163
+ refresh_token: refreshToken,
1164
+ tenant_id: primaryTenantId,
1165
+ user_email: userEmail,
1166
+ tenant_slug: workspaceSlug,
1167
+ tenant_name: workspaceName
1168
+ });
1169
+ console.log("\n======================================================");
1170
+ console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
1171
+ console.log("======================================================");
1172
+ if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
1173
+ if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
1174
+ console.log(` Tenant: ${primaryTenantId}`);
1175
+ console.log(` Gateway: ${gatewayUrl}`);
1176
+ console.log(` Email: ${userEmail}`);
1177
+ console.log("\n Credentials saved to ~/.wawesome/credentials.json");
1178
+ console.log("======================================================\n");
1179
+ }
1180
+ /**
975
1181
  * OAuth login flow:
976
1182
  * 1. Build Supabase OAuth URL
977
1183
  * 2. Open browser
@@ -1025,6 +1231,7 @@ async function login(options) {
1025
1231
  }
1026
1232
  if (reqUrl.pathname === "/token") {
1027
1233
  const accessToken = reqUrl.searchParams.get("access_token");
1234
+ const refreshToken = reqUrl.searchParams.get("refresh_token");
1028
1235
  res.writeHead(200, { "Content-Type": "text/plain" });
1029
1236
  res.end("Token received by CLI.");
1030
1237
  if (!accessToken) {
@@ -1035,69 +1242,13 @@ async function login(options) {
1035
1242
  }
1036
1243
  if (isVerbose) console.log("[wawesome:verbose] Supabase access token received.");
1037
1244
  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
1245
+ await completeLogin({
1246
+ gatewayUrl,
1247
+ dashboardUrl,
1248
+ accessToken,
1249
+ refreshToken: refreshToken || void 0,
1250
+ options
1090
1251
  });
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
1252
  server.close();
1102
1253
  resolve();
1103
1254
  } catch (err) {
@@ -1127,6 +1278,7 @@ async function login(options) {
1127
1278
  * Clear stored credentials.
1128
1279
  */
1129
1280
  function logout() {
1281
+ forgetRenewedSession();
1130
1282
  if (deleteCredentials()) console.log("[wawesome] ✅ Logged out. Credentials removed.");
1131
1283
  else console.log("[wawesome] Not logged in (no credentials found).");
1132
1284
  }
@@ -1159,18 +1311,17 @@ async function whoami() {
1159
1311
  /**
1160
1312
  * When a schedule next runs, or why it does not run at all.
1161
1313
  *
1162
- * The three ways of being off never render alike: someone who cannot tell a
1163
- * pause from a schedule they deleted from their config file will press resume
1164
- * and watch nothing happen.
1314
+ * The three ways of being off never render alike, and a schedule that is off
1315
+ * twice over says so: someone who reads only the pause will declare it again,
1316
+ * press resume, and file a bug when neither starts it.
1165
1317
  */
1166
1318
  function whenItRuns(schedule) {
1167
- switch (schedule.state) {
1168
- case "active": return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
1169
- case "paused": return "paused — resume it to run again";
1170
- case "undeclared": return "not in this config file — disabled, kept";
1171
- case "suspended": return "suspended — the workspace owes payment";
1172
- default: return schedule.state;
1173
- }
1319
+ if (schedule.state === "suspended") return "suspended — the workspace owes payment";
1320
+ const undeclared = schedule.declared === false;
1321
+ 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
+ if (undeclared) return "not in this config file — disabled, kept";
1323
+ if (schedule.state !== "active") return schedule.state;
1324
+ return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
1174
1325
  }
1175
1326
  /** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
1176
1327
  function asUtc(iso) {
@@ -1202,7 +1353,7 @@ async function fetchTenantUsage(creds, timeoutMs = USAGE_TIMEOUT_MS) {
1202
1353
  const controller = new AbortController();
1203
1354
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1204
1355
  try {
1205
- const res = await fetch(`${creds.gateway_url}/v1/tenant/usage`, {
1356
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/usage`, {
1206
1357
  headers: { Authorization: `Bearer ${creds.tenant_jwt}` },
1207
1358
  signal: controller.signal
1208
1359
  });
@@ -1363,7 +1514,9 @@ function billingPageUrl() {
1363
1514
  const PLAN_LIMITS = [
1364
1515
  "app-slots-exhausted",
1365
1516
  "storage-exhausted",
1366
- "paid-plan-required"
1517
+ "paid-plan-required",
1518
+ "payment-required",
1519
+ "schedule-suspended"
1367
1520
  ];
1368
1521
  /** The rule itself is the gateway's prose, and is deliberately not restated here. */
1369
1522
  function planLimitAdvice(reason) {
@@ -1513,6 +1666,97 @@ function trimTrailingSlashes(origin) {
1513
1666
  return origin.replace(/\/+$/, "");
1514
1667
  }
1515
1668
  //#endregion
1669
+ //#region src/deploy-target.ts
1670
+ const RECORD_PATH = path.join(".wawesome", "last-deploy.json");
1671
+ function readLastDeployTarget(projectDir = process.cwd()) {
1672
+ let parsed;
1673
+ try {
1674
+ parsed = JSON.parse(fs.readFileSync(path.join(projectDir, RECORD_PATH), "utf-8"));
1675
+ } catch {
1676
+ return null;
1677
+ }
1678
+ if (typeof parsed.app !== "string" || typeof parsed.function !== "string") return null;
1679
+ return {
1680
+ app: parsed.app,
1681
+ function: parsed.function
1682
+ };
1683
+ }
1684
+ /**
1685
+ * The directory ignores itself rather than relying on the project's `.gitignore`,
1686
+ * since this is per-checkout state and most projects reaching it were scaffolded
1687
+ * before it existed. A write that fails is dropped: the deploy has already
1688
+ * landed, and reporting a read-only project directory as a failed deploy would
1689
+ * be a worse lie than forgetting the name.
1690
+ */
1691
+ function recordDeployTarget(target, projectDir = process.cwd()) {
1692
+ const file = path.join(projectDir, RECORD_PATH);
1693
+ try {
1694
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1695
+ fs.writeFileSync(path.join(path.dirname(file), ".gitignore"), "*\n", "utf-8");
1696
+ fs.writeFileSync(file, JSON.stringify(target, null, 2) + "\n", "utf-8");
1697
+ } catch {
1698
+ return;
1699
+ }
1700
+ }
1701
+ /** See ADR-0016 for the fork this asks about, and why no rename is on offer. */
1702
+ async function confirmDeployTarget(creds, target, projectDir = process.cwd()) {
1703
+ const previous = readLastDeployTarget(projectDir);
1704
+ if (!previous || previous.app === target.app && previous.function === target.function) return true;
1705
+ const surface = await addressesFor(creds, previous, target);
1706
+ for (const line of divergenceLines(previous, target, surface)) console.warn(line);
1707
+ if (!isInteractive()) {
1708
+ console.warn("[wawesome] There is no terminal to confirm at, so the deploy continues.\n");
1709
+ return true;
1710
+ }
1711
+ const session = openPromptSession();
1712
+ try {
1713
+ const answer = await session.ask(` Deploy to '${qualifiedName(target)}' and leave '${qualifiedName(previous)}' live? [y/N]`, "");
1714
+ return /^y(es)?$/i.test(answer.trim());
1715
+ } finally {
1716
+ session.close();
1717
+ }
1718
+ }
1719
+ function divergenceLines(previous, target, addresses) {
1720
+ return [
1721
+ "",
1722
+ `[wawesome] \x1b[33mThis project last deployed to ${qualifiedName(previous)}.\x1b[0m`,
1723
+ `[wawesome] wawesome-function.json now says ${qualifiedName(target)}.`,
1724
+ "",
1725
+ "[wawesome] Deploying will not rename it. A Function's address is built from its",
1726
+ "[wawesome] name, so this deploy lands on a Function of its own and leaves the old",
1727
+ "[wawesome] one exactly as it is — promoted, and still answering the code on it:",
1728
+ "",
1729
+ `[wawesome] stays live: ${addresses.previous ?? qualifiedName(previous)}`,
1730
+ `[wawesome] gets this deploy: ${addresses.next ?? qualifiedName(target)}`,
1731
+ "",
1732
+ "[wawesome] Anything holding the old address keeps reaching the old code. Delete",
1733
+ "[wawesome] the old Function from the dashboard once nothing calls it.",
1734
+ ""
1735
+ ];
1736
+ }
1737
+ function qualifiedName(target) {
1738
+ return `${target.app}/${target.function}`;
1739
+ }
1740
+ /**
1741
+ * A gateway that cannot be reached must not turn this into a failed deploy: the
1742
+ * names alone still say what is about to happen, which is the point of saying it.
1743
+ */
1744
+ async function addressesFor(creds, previous, target) {
1745
+ try {
1746
+ const { slug } = await resolveWorkspace(creds);
1747
+ const surface = await invocationSurfaceOrNone(creds.gateway_url);
1748
+ return {
1749
+ previous: publicAddress(surface, slug, previous.app, previous.function),
1750
+ next: publicAddress(surface, slug, target.app, target.function)
1751
+ };
1752
+ } catch {
1753
+ return {
1754
+ previous: null,
1755
+ next: null
1756
+ };
1757
+ }
1758
+ }
1759
+ //#endregion
1516
1760
  //#region src/deploy.ts
1517
1761
  function declaredFields(declared) {
1518
1762
  return {
@@ -1564,6 +1808,13 @@ async function deploy(entryInput, options) {
1564
1808
  console.log(`[wawesome:verbose] Deploying to app=${app}, function=${funcName}`);
1565
1809
  console.log(`[wawesome:verbose] Gateway: ${creds.gateway_url}`);
1566
1810
  }
1811
+ if (!await confirmDeployTarget(creds, {
1812
+ app,
1813
+ function: funcName
1814
+ })) {
1815
+ console.log("[wawesome] Nothing was deployed.");
1816
+ process.exit(0);
1817
+ }
1567
1818
  let bundlePath;
1568
1819
  if (options.skipBuild) {
1569
1820
  bundlePath = path.resolve(options.out);
@@ -1598,7 +1849,7 @@ async function deploy(entryInput, options) {
1598
1849
  const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
1599
1850
  if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
1600
1851
  const declaredOnTheWire = declaredFields(declared);
1601
- const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await fetch(uploadUrl, {
1852
+ const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await authorizedFetch(uploadUrl, {
1602
1853
  method: "POST",
1603
1854
  headers: {
1604
1855
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1610,7 +1861,7 @@ async function deploy(entryInput, options) {
1610
1861
  ...declaresSchedules ? { schedules: config.schedules } : {},
1611
1862
  ...declaredOnTheWire
1612
1863
  })
1613
- }) : await fetch(uploadUrl, {
1864
+ }) : await authorizedFetch(uploadUrl, {
1614
1865
  method: "POST",
1615
1866
  headers: {
1616
1867
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1631,8 +1882,12 @@ async function deploy(entryInput, options) {
1631
1882
  process.exit(1);
1632
1883
  }
1633
1884
  console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
1634
- console.error("[wawesome] Code versions are immutable and cannot be overwritten.");
1635
- console.error("[wawesome] To switch active version, run: \x1B[36mwawesome version switch\x1B[0m\n");
1885
+ const advice = planLimitAdvice(refusal.reason);
1886
+ if (advice) console.error(`[wawesome] ${advice}\n`);
1887
+ else if (!refusal.reason) {
1888
+ console.error("[wawesome] Code versions are immutable and cannot be overwritten.");
1889
+ console.error("[wawesome] To switch active version, run: \x1B[36mwawesome version switch\x1B[0m\n");
1890
+ }
1636
1891
  } else {
1637
1892
  console.error(`[wawesome] Error: Code upload failed (HTTP ${uploadRes.status}).`);
1638
1893
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -1652,7 +1907,7 @@ async function deploy(entryInput, options) {
1652
1907
  if (isVerbose) console.log(`[wawesome:verbose] POST ${deployUrl}`);
1653
1908
  const deployBody = {};
1654
1909
  if (version !== void 0) deployBody.version_number = version;
1655
- const deployRes = await fetch(deployUrl, {
1910
+ const deployRes = await authorizedFetch(deployUrl, {
1656
1911
  method: "POST",
1657
1912
  headers: {
1658
1913
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1670,6 +1925,10 @@ async function deploy(entryInput, options) {
1670
1925
  process.exit(1);
1671
1926
  }
1672
1927
  }
1928
+ recordDeployTarget({
1929
+ app,
1930
+ function: funcName
1931
+ });
1673
1932
  let address = null;
1674
1933
  let surface = null;
1675
1934
  if (visibility === "public") try {
@@ -1729,8 +1988,7 @@ async function deploy(entryInput, options) {
1729
1988
  * changed nothing at all is refused here — before a byte of it has moved.
1730
1989
  */
1731
1990
  async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVerbose, continueWhenUnchanged) {
1732
- const manifestUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/assets/manifest`;
1733
- const manifestRes = await fetch(manifestUrl, {
1991
+ const manifestRes = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/assets/manifest`, {
1734
1992
  method: "POST",
1735
1993
  headers: {
1736
1994
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1773,7 +2031,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
1773
2031
  console.log(`[wawesome] Uploading ${toUpload.length} of ${assets.length} asset(s)...`);
1774
2032
  for (const asset of toUpload) {
1775
2033
  if (isVerbose) console.log(`[wawesome:verbose] PUT ${asset.path} (${asset.size_bytes} bytes)`);
1776
- const res = await fetch(`${creds.gateway_url}/v1/assets/${asset.content_hash}`, {
2034
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/assets/${asset.content_hash}`, () => ({
1777
2035
  method: "PUT",
1778
2036
  headers: {
1779
2037
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1782,7 +2040,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
1782
2040
  },
1783
2041
  body: Readable.toWeb(fs.createReadStream(asset.source)),
1784
2042
  duplex: "half"
1785
- });
2043
+ }));
1786
2044
  if (!res.ok) {
1787
2045
  const errorBody = await res.text();
1788
2046
  const refusal = rejectionOf(errorBody, res.status, `Upload of '${asset.path}' failed (HTTP ${res.status}).`);
@@ -1830,8 +2088,7 @@ function loadClientContext() {
1830
2088
  * the time it does, so it needs to say what did land before it gives up.
1831
2089
  */
1832
2090
  async function putEnvVar(creds, app, key, value, isSecret) {
1833
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
1834
- const res = await fetch(url, {
2091
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`, {
1835
2092
  method: "POST",
1836
2093
  headers: {
1837
2094
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -1894,7 +2151,7 @@ async function listEnvVars(options) {
1894
2151
  const { creds, app } = loadClientContext();
1895
2152
  const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
1896
2153
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
1897
- const res = await fetch(url, {
2154
+ const res = await authorizedFetch(url, {
1898
2155
  method: "GET",
1899
2156
  headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
1900
2157
  });
@@ -1948,7 +2205,7 @@ async function removeEnvVar(key, options) {
1948
2205
  const { creds, app } = loadClientContext();
1949
2206
  const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
1950
2207
  if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
1951
- const res = await fetch(url, {
2208
+ const res = await authorizedFetch(url, {
1952
2209
  method: "DELETE",
1953
2210
  headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
1954
2211
  });
@@ -2051,10 +2308,10 @@ function authHeaders(creds) {
2051
2308
  * between is still treated as the success it is.
2052
2309
  */
2053
2310
  async function ensureApp(creds, appSlug) {
2054
- const listRes = await fetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
2311
+ const listRes = await authorizedFetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
2055
2312
  if (!listRes.ok) throw await rejected(listRes, "Could not list the Tenant's apps");
2056
2313
  if ((await listRes.json()).applications?.some((app) => app.slug === appSlug)) return;
2057
- const createRes = await fetch(`${creds.gateway_url}/v1/apps`, {
2314
+ const createRes = await authorizedFetch(`${creds.gateway_url}/v1/apps`, {
2058
2315
  method: "POST",
2059
2316
  headers: authHeaders(creds),
2060
2317
  body: JSON.stringify({
@@ -2075,8 +2332,7 @@ async function ensureApp(creds, appSlug) {
2075
2332
  * scaffolded template and an outbound request that silently fails at runtime.
2076
2333
  */
2077
2334
  async function enableEgressProvider(creds, appSlug, providerKey) {
2078
- const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`;
2079
- const res = await fetch(url, {
2335
+ const res = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`, {
2080
2336
  method: "POST",
2081
2337
  headers: authHeaders(creds),
2082
2338
  body: JSON.stringify({ provider_key: providerKey })
@@ -2979,7 +3235,7 @@ const RECLAIMED = "⚠ [assets reclaimed]";
2979
3235
  async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
2980
3236
  const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
2981
3237
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
2982
- const res = await fetch(url, {
3238
+ const res = await authorizedFetch(url, {
2983
3239
  method: "GET",
2984
3240
  headers: { Authorization: `Bearer ${tenantJwt}` }
2985
3241
  });
@@ -3092,8 +3348,7 @@ async function switchVersion(targetInput, options) {
3092
3348
  }
3093
3349
  }
3094
3350
  console.log(`[wawesome] Promoting v${selectedVersionNum} to ${env}...`);
3095
- const deployUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/deploy`;
3096
- const deployRes = await fetch(deployUrl, {
3351
+ const deployRes = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/deploy`, {
3097
3352
  method: "POST",
3098
3353
  headers: {
3099
3354
  Authorization: `Bearer ${creds.tenant_jwt}`,
@@ -3183,7 +3438,7 @@ async function logsCommand(target, options = {}) {
3183
3438
  async function fetchInvocationLogBody(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3184
3439
  const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
3185
3440
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3186
- const res = await fetch(url, {
3441
+ const res = await authorizedFetch(url, {
3187
3442
  method: "GET",
3188
3443
  headers: { Authorization: `Bearer ${tenantJwt}` }
3189
3444
  });
@@ -3259,7 +3514,7 @@ async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride
3259
3514
  async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, queryString, isVerbose) {
3260
3515
  const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/invocations${queryString}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
3261
3516
  if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3262
- let res = await fetch(url, {
3517
+ let res = await authorizedFetch(url, {
3263
3518
  method: "GET",
3264
3519
  headers: { Authorization: `Bearer ${tenantJwt}` }
3265
3520
  });
@@ -3267,7 +3522,7 @@ async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug
3267
3522
  const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
3268
3523
  if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
3269
3524
  try {
3270
- const fallbackRes = await fetch(fallbackUrl, {
3525
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
3271
3526
  method: "GET",
3272
3527
  headers: { Authorization: `Bearer ${tenantJwt}` }
3273
3528
  });
@@ -3379,7 +3634,7 @@ async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbos
3379
3634
  while (true) {
3380
3635
  let res = null;
3381
3636
  try {
3382
- res = await fetch(url, {
3637
+ res = await authorizedFetch(url, {
3383
3638
  method: "GET",
3384
3639
  headers: {
3385
3640
  Authorization: `Bearer ${tenantJwt}`,
@@ -3511,18 +3766,18 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
3511
3766
  let res = null;
3512
3767
  try {
3513
3768
  if (scopedUrl) {
3514
- res = await fetch(scopedUrl, {
3769
+ res = await authorizedFetch(scopedUrl, {
3515
3770
  headers,
3516
3771
  signal: controller.signal
3517
3772
  });
3518
3773
  if (res.status === 404) {
3519
3774
  if (isVerbose) console.error(`[wawesome:verbose] 404 on app-scoped route, retrying unscoped.`);
3520
- res = await fetch(unscopedUrl, {
3775
+ res = await authorizedFetch(unscopedUrl, {
3521
3776
  headers,
3522
3777
  signal: controller.signal
3523
3778
  });
3524
3779
  }
3525
- } else res = await fetch(unscopedUrl, {
3780
+ } else res = await authorizedFetch(unscopedUrl, {
3526
3781
  headers,
3527
3782
  signal: controller.signal
3528
3783
  });
@@ -3606,8 +3861,237 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
3606
3861
  }
3607
3862
  }
3608
3863
  //#endregion
3864
+ //#region src/invoke.ts
3865
+ const MAX_STREAM_RETRIES = 3;
3866
+ function formatOutcomeDuration(meta) {
3867
+ if (!meta) return "-";
3868
+ if (meta.duration_ms !== void 0 && meta.duration_ms !== null && !isNaN(meta.duration_ms) && meta.duration_ms >= 0) {
3869
+ if (meta.duration_ms < 1e3) return `${meta.duration_ms}ms`;
3870
+ return `${(meta.duration_ms / 1e3).toFixed(2)}s`;
3871
+ }
3872
+ if (meta.started_at) return formatDuration(meta.started_at, meta.ended_at);
3873
+ return "-";
3874
+ }
3875
+ function printOutcome(meta) {
3876
+ const durationStr = formatOutcomeDuration(meta);
3877
+ const statusStr = colorizeStatus(meta.status, meta.status);
3878
+ if (meta.status === "success") console.log(`[wawesome] ✔ Run completed: ${statusStr} (${durationStr})`);
3879
+ else console.log(`[wawesome] ✖ Run completed: ${statusStr} (${durationStr})`);
3880
+ }
3881
+ async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, payload, isVerbose) {
3882
+ const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
3883
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${url}`);
3884
+ let res = await authorizedFetch(url, {
3885
+ method: "POST",
3886
+ headers: {
3887
+ Authorization: `Bearer ${tenantJwt}`,
3888
+ "Content-Type": "application/json"
3889
+ },
3890
+ body: JSON.stringify(payload)
3891
+ });
3892
+ if (res.status === 404 && appSlug && !appOverride) {
3893
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
3894
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
3895
+ try {
3896
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
3897
+ method: "POST",
3898
+ headers: {
3899
+ Authorization: `Bearer ${tenantJwt}`,
3900
+ "Content-Type": "application/json"
3901
+ },
3902
+ body: JSON.stringify(payload)
3903
+ });
3904
+ if (fallbackRes) res = fallbackRes;
3905
+ } catch {}
3906
+ }
3907
+ return res;
3908
+ }
3909
+ async function fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3910
+ const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}`;
3911
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3912
+ const res = await authorizedFetch(url, {
3913
+ method: "GET",
3914
+ headers: { Authorization: `Bearer ${tenantJwt}` }
3915
+ });
3916
+ if (!res.ok) return null;
3917
+ return await res.json();
3918
+ }
3919
+ async function fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3920
+ const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
3921
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3922
+ const res = await authorizedFetch(url, {
3923
+ method: "GET",
3924
+ headers: { Authorization: `Bearer ${tenantJwt}` }
3925
+ });
3926
+ if (!res.ok) return null;
3927
+ return await res.text();
3928
+ }
3929
+ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollIntervalMs = 250) {
3930
+ const streamUrl = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs/stream`;
3931
+ const controller = new AbortController();
3932
+ const onSigint = () => {
3933
+ controller.abort();
3934
+ process.stderr.write("\n[wawesome] Stopped following.\n");
3935
+ process.exit(0);
3936
+ };
3937
+ process.on("SIGINT", onSigint);
3938
+ try {
3939
+ let attempt = 0;
3940
+ let streamReadComplete = false;
3941
+ let printedAny = false;
3942
+ while (!streamReadComplete) {
3943
+ if (controller.signal.aborted) return;
3944
+ let res = null;
3945
+ try {
3946
+ res = await authorizedFetch(streamUrl, {
3947
+ method: "GET",
3948
+ headers: {
3949
+ Authorization: `Bearer ${tenantJwt}`,
3950
+ Accept: "text/event-stream"
3951
+ },
3952
+ signal: controller.signal
3953
+ });
3954
+ } catch (err) {
3955
+ if (controller.signal.aborted) return;
3956
+ if (attempt < MAX_STREAM_RETRIES) {
3957
+ const delay = retryDelay(attempt);
3958
+ if (isVerbose) console.error(`[wawesome:verbose] Stream connection failed (${String(err)}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_STREAM_RETRIES})...`);
3959
+ await retrySleep(delay, controller.signal);
3960
+ attempt++;
3961
+ continue;
3962
+ }
3963
+ console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
3964
+ process.exit(1);
3965
+ }
3966
+ if (res.status === 404) {
3967
+ const meta = await fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose);
3968
+ if (!meta) {
3969
+ console.error(`[wawesome] Error: Invocation '${invocationId}' not found.`);
3970
+ process.exit(1);
3971
+ }
3972
+ if (meta.status === "queued" || meta.status === "running") {
3973
+ await retrySleep(pollIntervalMs, controller.signal);
3974
+ continue;
3975
+ }
3976
+ if (meta.status === "success" || meta.status === "error" || meta.status === "timeout") {
3977
+ const logBody = await fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose);
3978
+ if (logBody) for (const line of logBody.split("\n")) printFollowLine(line);
3979
+ printOutcome(meta);
3980
+ return;
3981
+ }
3982
+ console.error(`[wawesome] ✖ Run ${meta.status}${meta.failure_reason ? `: ${meta.failure_reason}` : ""}`);
3983
+ return;
3984
+ }
3985
+ if (res.status === 401) {
3986
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3987
+ process.exit(1);
3988
+ }
3989
+ if (!res.ok && res.status >= 500) {
3990
+ if (attempt < MAX_STREAM_RETRIES) {
3991
+ const delay = retryDelay(attempt);
3992
+ if (isVerbose) console.error(`[wawesome:verbose] Server error (${res.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_STREAM_RETRIES})...`);
3993
+ await retrySleep(delay, controller.signal);
3994
+ attempt++;
3995
+ continue;
3996
+ }
3997
+ console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}) after retries.`);
3998
+ process.exit(1);
3999
+ }
4000
+ if (!res.ok) {
4001
+ console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}).`);
4002
+ process.exit(1);
4003
+ }
4004
+ if (!res.body) {
4005
+ console.error("[wawesome] Error: Live tail response had no body stream.");
4006
+ process.exit(1);
4007
+ }
4008
+ attempt = 0;
4009
+ try {
4010
+ const reader = res.body.getReader();
4011
+ const decoder = new TextDecoder();
4012
+ let buffer = "";
4013
+ for (;;) {
4014
+ const { value, done } = await reader.read();
4015
+ if (done) break;
4016
+ buffer += decoder.decode(value, { stream: true });
4017
+ const { events, rest } = splitSseEvents(buffer);
4018
+ buffer = rest;
4019
+ for (const chunk of events) for (const line of chunk.split("\n")) {
4020
+ printFollowLine(line);
4021
+ printedAny = true;
4022
+ }
4023
+ }
4024
+ streamReadComplete = true;
4025
+ } catch (streamErr) {
4026
+ if (controller.signal.aborted) return;
4027
+ if (attempt < MAX_STREAM_RETRIES) {
4028
+ await retrySleep(retryDelay(attempt), controller.signal);
4029
+ attempt++;
4030
+ continue;
4031
+ }
4032
+ console.error("[wawesome] Error: Live tail stream interrupted and retries exhausted.");
4033
+ process.exit(1);
4034
+ }
4035
+ }
4036
+ const meta = await fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose);
4037
+ if (meta) {
4038
+ if (!printedAny && (meta.status === "success" || meta.status === "error" || meta.status === "timeout")) {
4039
+ const logBody = await fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose);
4040
+ if (logBody) for (const line of logBody.split("\n")) printFollowLine(line);
4041
+ }
4042
+ printOutcome(meta);
4043
+ }
4044
+ } finally {
4045
+ process.removeListener("SIGINT", onSigint);
4046
+ }
4047
+ }
4048
+ async function invokeCommand(target, options = {}) {
4049
+ const isVerbose = Boolean(options.verbose);
4050
+ const creds = readCredentials();
4051
+ if (!creds) {
4052
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
4053
+ process.exit(1);
4054
+ }
4055
+ const config = readFunctionConfig();
4056
+ const funcName = target || config?.function;
4057
+ const appSlug = options.app || config?.app;
4058
+ if (!funcName) {
4059
+ console.error("[wawesome] Error: Missing function name.");
4060
+ console.error("[wawesome] Usage: wawesome invoke [function-name] or run inside a function directory with wawesome-function.json.");
4061
+ process.exit(1);
4062
+ }
4063
+ const method = options.method?.trim();
4064
+ const body = options.body ?? options.data;
4065
+ const triggerPayload = {};
4066
+ if (method) triggerPayload.method = method;
4067
+ if (body !== void 0) triggerPayload.body = body;
4068
+ const res = await triggerRun(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, options.app, triggerPayload, isVerbose);
4069
+ if (!res.ok) {
4070
+ const errorBody = await res.text();
4071
+ if (res.status === 401) {
4072
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4073
+ process.exit(1);
4074
+ }
4075
+ if (res.status === 404) {
4076
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
4077
+ process.exit(1);
4078
+ }
4079
+ const refusal = rejectionOf(errorBody, res.status, `Failed to invoke function (HTTP ${res.status}).`);
4080
+ console.error(`[wawesome] Error: ${refusal.message}`);
4081
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4082
+ if (advice) console.error(`[wawesome] ${advice}`);
4083
+ if (isVerbose && res.status !== 409 && res.status !== 402) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4084
+ process.exit(1);
4085
+ }
4086
+ const data = await res.json();
4087
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4088
+ console.log(`[wawesome] ⏳ Run ${data.invocation_id} queued for '${displayTarget}'...`);
4089
+ if (options.follow === false) return;
4090
+ await followRun(creds.gateway_url, creds.tenant_jwt, data.invocation_id, isVerbose);
4091
+ }
4092
+ //#endregion
3609
4093
  //#region src/workspace.ts
3610
- function requireCredentials() {
4094
+ function requireCredentials$1() {
3611
4095
  const creds = readCredentials();
3612
4096
  if (!creds) {
3613
4097
  console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
@@ -3620,7 +4104,7 @@ function requireCredentials() {
3620
4104
  * change — the rename is offered here rather than discovered by attempting it.
3621
4105
  */
3622
4106
  async function showWorkspace(options) {
3623
- const creds = requireCredentials();
4107
+ const creds = requireCredentials$1();
3624
4108
  let tenant;
3625
4109
  try {
3626
4110
  tenant = await fetchTenantDetails(creds);
@@ -3655,7 +4139,7 @@ async function showWorkspace(options) {
3655
4139
  * gateway knows something this command could not.
3656
4140
  */
3657
4141
  async function renameWorkspace(slug, options) {
3658
- const creds = requireCredentials();
4142
+ const creds = requireCredentials$1();
3659
4143
  if (!slug || !slug.trim()) {
3660
4144
  console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
3661
4145
  process.exit(1);
@@ -3699,6 +4183,442 @@ async function workspaceCommand(action, target, options) {
3699
4183
  process.exit(1);
3700
4184
  }
3701
4185
  //#endregion
4186
+ //#region src/cron.ts
4187
+ /** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
4188
+ function asUtcWithSeconds(iso) {
4189
+ const at = new Date(iso);
4190
+ if (Number.isNaN(at.getTime())) return iso;
4191
+ const pad = (n) => String(n).padStart(2, "0");
4192
+ return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())} UTC`;
4193
+ }
4194
+ /** Format execution delay in ms or s. */
4195
+ function formatDelay(delayMs) {
4196
+ if (delayMs === void 0 || delayMs === null || isNaN(delayMs) || delayMs < 0) return "-";
4197
+ if (delayMs < 1e3) return `${delayMs}ms`;
4198
+ return `${(delayMs / 1e3).toFixed(2)}s`;
4199
+ }
4200
+ /** Colorize state names for terminal output. */
4201
+ function colorizeRunState(state, text) {
4202
+ const display = text ?? state;
4203
+ switch (state.toLowerCase()) {
4204
+ case "dispatched":
4205
+ case "success": return `\x1b[32m${display}\x1b[0m`;
4206
+ case "running": return `\x1b[36m${display}\x1b[0m`;
4207
+ case "pending":
4208
+ case "queued": return `\x1b[33m${display}\x1b[0m`;
4209
+ case "missed": return `\x1b[33m${display}\x1b[0m`;
4210
+ case "cancelled":
4211
+ case "skipped": return `\x1b[90m${display}\x1b[0m`;
4212
+ case "failed":
4213
+ case "lost":
4214
+ case "error": return `\x1b[31m${display}\x1b[0m`;
4215
+ default: return display;
4216
+ }
4217
+ }
4218
+ /**
4219
+ * Format a list of schedules for a single function.
4220
+ */
4221
+ function formatFunctionSchedules(funcName, appSlug, schedules) {
4222
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4223
+ if (schedules.length === 0) return [`[wawesome] No schedules found for '${displayTarget}'.`];
4224
+ const nameWidth = Math.max(8, ...schedules.map((s) => s.name.length));
4225
+ const exprWidth = Math.max(10, ...schedules.map((s) => s.expression.length));
4226
+ return [
4227
+ `\n🗓 \x1b[1mSchedules for '${displayTarget}'\x1b[0m\n`,
4228
+ ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(exprWidth)} ${whenItRuns(schedule)}`),
4229
+ ""
4230
+ ];
4231
+ }
4232
+ /**
4233
+ * Format schedules across all functions in an App.
4234
+ */
4235
+ function formatAppSchedules(appSlug, functionSchedules) {
4236
+ if (functionSchedules.reduce((acc, f) => acc + f.schedules.length, 0) === 0) return [`[wawesome] No schedules found for app '${appSlug}'.`];
4237
+ const allSchedules = functionSchedules.flatMap((f) => f.schedules);
4238
+ const nameWidth = Math.max(8, ...allSchedules.map((s) => s.name.length));
4239
+ const exprWidth = Math.max(10, ...allSchedules.map((s) => s.expression.length));
4240
+ const out = [`\n🗓 \x1b[1mSchedules for app '${appSlug}'\x1b[0m\n`];
4241
+ for (const fn of functionSchedules) {
4242
+ out.push(` Function: \x1b[1m${fn.functionName}\x1b[0m`);
4243
+ if (fn.schedules.length === 0) out.push(" (no schedules)");
4244
+ else for (const s of [...fn.schedules].sort((a, b) => a.name.localeCompare(b.name))) out.push(` ${s.name.padEnd(nameWidth)} ${s.expression.padEnd(exprWidth)} ${whenItRuns(s)}`);
4245
+ out.push("");
4246
+ }
4247
+ return out;
4248
+ }
4249
+ /**
4250
+ * Format background run history table.
4251
+ */
4252
+ function formatRunHistoryTable(runs) {
4253
+ if (runs.length === 0) return [];
4254
+ const scheduleColHeader = "SCHEDULE";
4255
+ const stateColHeader = "STATE";
4256
+ const dueColHeader = "DUE AT (UTC)";
4257
+ const startedColHeader = "STARTED AT (UTC)";
4258
+ const delayColHeader = "DELAY";
4259
+ const invColHeader = "INVOCATION ID";
4260
+ const rows = runs.map((run) => {
4261
+ const schedName = run.schedule_name || "manual";
4262
+ const stateDisplay = run.failure_reason ? `${run.state} (${run.failure_reason})` : run.state;
4263
+ const dueAt = asUtcWithSeconds(run.fire_at);
4264
+ const startedAt = run.claimed_at ? asUtcWithSeconds(run.claimed_at) : "-";
4265
+ const delay = formatDelay(run.delay_ms);
4266
+ const invId = run.invocation_id;
4267
+ return {
4268
+ schedName,
4269
+ stateDisplay,
4270
+ rawState: run.state,
4271
+ dueAt,
4272
+ startedAt,
4273
+ delay,
4274
+ invId
4275
+ };
4276
+ });
4277
+ const schedWidth = Math.max(8, ...rows.map((r) => r.schedName.length));
4278
+ const stateWidth = Math.max(5, ...rows.map((r) => r.stateDisplay.length));
4279
+ const dueWidth = Math.max(12, ...rows.map((r) => r.dueAt.length));
4280
+ const startedWidth = Math.max(16, ...rows.map((r) => r.startedAt.length));
4281
+ const delayWidth = Math.max(5, ...rows.map((r) => r.delay.length));
4282
+ const invWidth = Math.max(13, ...rows.map((r) => r.invId.length));
4283
+ return [
4284
+ `${scheduleColHeader.padEnd(schedWidth)} | ${stateColHeader.padEnd(stateWidth)} | ${dueColHeader.padEnd(dueWidth)} | ${startedColHeader.padEnd(startedWidth)} | ${delayColHeader.padEnd(delayWidth)} | ${invColHeader.padEnd(invWidth)}`,
4285
+ `${"-".repeat(schedWidth)}-|-${"-".repeat(stateWidth)}-|-${"-".repeat(dueWidth)}-|-${"-".repeat(startedWidth)}-|-${"-".repeat(delayWidth)}-|-${"-".repeat(invWidth)}`,
4286
+ ...rows.map((r) => {
4287
+ const schedPad = r.schedName.padEnd(schedWidth);
4288
+ const coloredState = colorizeRunState(r.rawState, r.stateDisplay);
4289
+ return `${schedPad} | ${r.stateDisplay.length < stateWidth ? coloredState + " ".repeat(stateWidth - r.stateDisplay.length) : coloredState} | ${r.dueAt.padEnd(dueWidth)} | ${r.startedAt.padEnd(startedWidth)} | ${r.delay.padEnd(delayWidth)} | ${r.invId.padEnd(invWidth)}`;
4290
+ })
4291
+ ];
4292
+ }
4293
+ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug, isVerbose) {
4294
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
4295
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
4296
+ let res = await authorizedFetch(scopedUrl, {
4297
+ method: "GET",
4298
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4299
+ });
4300
+ if (res.status === 404 && appSlug) {
4301
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
4302
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
4303
+ try {
4304
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4305
+ method: "GET",
4306
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4307
+ });
4308
+ if (fallbackRes.ok) res = fallbackRes;
4309
+ } catch {}
4310
+ }
4311
+ if (!res.ok) return null;
4312
+ const data = await res.json();
4313
+ return Array.isArray(data.schedules) ? data.schedules : [];
4314
+ }
4315
+ async function fetchAppFunctions(gatewayUrl, tenantJwt, appSlug, isVerbose) {
4316
+ const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions`;
4317
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
4318
+ const res = await authorizedFetch(url, {
4319
+ method: "GET",
4320
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4321
+ });
4322
+ if (!res.ok) return null;
4323
+ return await res.json();
4324
+ }
4325
+ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, reason, isVerbose) {
4326
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
4327
+ const body = reason?.trim() ? JSON.stringify({ reason: reason.trim() }) : void 0;
4328
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
4329
+ let res = await authorizedFetch(scopedUrl, {
4330
+ method: "POST",
4331
+ headers: {
4332
+ Authorization: `Bearer ${tenantJwt}`,
4333
+ ...body ? { "Content-Type": "application/json" } : {}
4334
+ },
4335
+ body
4336
+ });
4337
+ if (res.status === 404 && appSlug) {
4338
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
4339
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
4340
+ try {
4341
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4342
+ method: "POST",
4343
+ headers: {
4344
+ Authorization: `Bearer ${tenantJwt}`,
4345
+ ...body ? { "Content-Type": "application/json" } : {}
4346
+ },
4347
+ body
4348
+ });
4349
+ if (fallbackRes) res = fallbackRes;
4350
+ } catch {}
4351
+ }
4352
+ return res;
4353
+ }
4354
+ async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, isVerbose) {
4355
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
4356
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
4357
+ let res = await authorizedFetch(scopedUrl, {
4358
+ method: "POST",
4359
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4360
+ });
4361
+ if (res.status === 404 && appSlug) {
4362
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
4363
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
4364
+ try {
4365
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4366
+ method: "POST",
4367
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4368
+ });
4369
+ if (fallbackRes) res = fallbackRes;
4370
+ } catch {}
4371
+ }
4372
+ return res;
4373
+ }
4374
+ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug, queryParams, isVerbose) {
4375
+ const params = new URLSearchParams();
4376
+ if (queryParams?.limit) params.set("limit", String(queryParams.limit));
4377
+ if (queryParams?.page) params.set("page", String(queryParams.page));
4378
+ if (queryParams?.state && queryParams.state.toLowerCase() !== "all") params.set("state", queryParams.state.toLowerCase());
4379
+ const qs = params.toString() ? `?${params.toString()}` : "";
4380
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs${qs}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
4381
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
4382
+ let res = await authorizedFetch(scopedUrl, {
4383
+ method: "GET",
4384
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4385
+ });
4386
+ if (res.status === 404 && appSlug) {
4387
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
4388
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
4389
+ try {
4390
+ const fallbackRes = await authorizedFetch(fallbackUrl, {
4391
+ method: "GET",
4392
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4393
+ });
4394
+ if (fallbackRes) res = fallbackRes;
4395
+ } catch {}
4396
+ }
4397
+ return res;
4398
+ }
4399
+ function requireCredentials() {
4400
+ const creds = readCredentials();
4401
+ if (!creds) {
4402
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
4403
+ process.exit(1);
4404
+ }
4405
+ return creds;
4406
+ }
4407
+ async function listSchedulesCommand(target, options = {}) {
4408
+ const isVerbose = Boolean(options.verbose);
4409
+ const creds = requireCredentials();
4410
+ const config = readFunctionConfig();
4411
+ const explicitApp = options.app;
4412
+ const appSlug = explicitApp || config?.app;
4413
+ const funcName = target || options.function || (explicitApp ? void 0 : config?.function);
4414
+ if (funcName) {
4415
+ const schedules = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, isVerbose);
4416
+ if (schedules === null) {
4417
+ if (target && !options.app) {
4418
+ const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, target, isVerbose);
4419
+ if (appFuncs !== null) {
4420
+ const functionSchedules = [];
4421
+ for (const item of appFuncs) {
4422
+ const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, target, isVerbose);
4423
+ functionSchedules.push({
4424
+ functionName: item.function.name,
4425
+ schedules: scheds || []
4426
+ });
4427
+ }
4428
+ const lines = formatAppSchedules(target, functionSchedules);
4429
+ for (const line of lines) console.log(line);
4430
+ return;
4431
+ }
4432
+ }
4433
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
4434
+ process.exit(1);
4435
+ }
4436
+ const lines = formatFunctionSchedules(funcName, appSlug, schedules);
4437
+ for (const line of lines) console.log(line);
4438
+ return;
4439
+ }
4440
+ if (appSlug) {
4441
+ const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, appSlug, isVerbose);
4442
+ if (appFuncs === null) {
4443
+ console.error(`[wawesome] Error: App '${appSlug}' not found.`);
4444
+ process.exit(1);
4445
+ }
4446
+ const functionSchedules = [];
4447
+ for (const item of appFuncs) {
4448
+ const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, appSlug, isVerbose);
4449
+ functionSchedules.push({
4450
+ functionName: item.function.name,
4451
+ schedules: scheds || []
4452
+ });
4453
+ }
4454
+ const lines = formatAppSchedules(appSlug, functionSchedules);
4455
+ for (const line of lines) console.log(line);
4456
+ return;
4457
+ }
4458
+ console.error("[wawesome] Error: Missing function or app name.");
4459
+ console.error("[wawesome] Usage: wawesome cron list [function-or-app] or run inside a function directory with wawesome-function.json.");
4460
+ process.exit(1);
4461
+ }
4462
+ async function pauseScheduleCommand(target, subtarget, options = {}) {
4463
+ const isVerbose = Boolean(options.verbose);
4464
+ const creds = requireCredentials();
4465
+ const config = readFunctionConfig();
4466
+ let funcName;
4467
+ let scheduleName;
4468
+ if (target && subtarget) {
4469
+ funcName = target;
4470
+ scheduleName = subtarget;
4471
+ } else if (target) {
4472
+ scheduleName = target;
4473
+ funcName = options.function || config?.function;
4474
+ }
4475
+ if (options.function) funcName = options.function;
4476
+ const appSlug = options.app || config?.app;
4477
+ if (!scheduleName) {
4478
+ console.error("[wawesome] Error: Missing schedule name.");
4479
+ console.error("[wawesome] Usage: wawesome cron pause <schedule-name> [--reason <reason>] or wawesome cron pause <function> <schedule-name>");
4480
+ process.exit(1);
4481
+ }
4482
+ if (!funcName) {
4483
+ console.error("[wawesome] Error: Missing function name.");
4484
+ console.error("[wawesome] Usage: wawesome cron pause <function> <schedule-name> or specify -f/--function <name>.");
4485
+ process.exit(1);
4486
+ }
4487
+ const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
4488
+ if (!res.ok) {
4489
+ const errorBody = await res.text();
4490
+ if (res.status === 401) {
4491
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4492
+ process.exit(1);
4493
+ }
4494
+ if (res.status === 404) {
4495
+ console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
4496
+ process.exit(1);
4497
+ }
4498
+ const refusal = rejectionOf(errorBody, res.status, `Failed to pause schedule (HTTP ${res.status}).`);
4499
+ console.error(`[wawesome] Error: ${refusal.message}`);
4500
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4501
+ if (advice) console.error(`[wawesome] ${advice}`);
4502
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4503
+ process.exit(1);
4504
+ }
4505
+ const data = await res.json();
4506
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4507
+ console.log(`[wawesome] ✔ Paused schedule '${data.schedule.name}' on '${displayTarget}'.`);
4508
+ console.log(` State: ${whenItRuns(data.schedule)} (survives future deploys)`);
4509
+ if (data.schedule.pause_reason) console.log(` Reason: ${data.schedule.pause_reason}`);
4510
+ if (data.cancelled_runs && data.cancelled_runs.length > 0) {
4511
+ const count = data.cancelled_runs.length;
4512
+ console.log(` Cancelled ${count} queued run${count === 1 ? "" : "s"} (never started).`);
4513
+ }
4514
+ if (data.running_runs && data.running_runs.length > 0) {
4515
+ const count = data.running_runs.length;
4516
+ console.log(` Left ${count} in-flight run${count === 1 ? "" : "s"} to complete.`);
4517
+ }
4518
+ }
4519
+ async function resumeScheduleCommand(target, subtarget, options = {}) {
4520
+ const isVerbose = Boolean(options.verbose);
4521
+ const creds = requireCredentials();
4522
+ const config = readFunctionConfig();
4523
+ let funcName;
4524
+ let scheduleName;
4525
+ if (target && subtarget) {
4526
+ funcName = target;
4527
+ scheduleName = subtarget;
4528
+ } else if (target) {
4529
+ scheduleName = target;
4530
+ funcName = options.function || config?.function;
4531
+ }
4532
+ if (options.function) funcName = options.function;
4533
+ const appSlug = options.app || config?.app;
4534
+ if (!scheduleName) {
4535
+ console.error("[wawesome] Error: Missing schedule name.");
4536
+ console.error("[wawesome] Usage: wawesome cron resume <schedule-name> or wawesome cron resume <function> <schedule-name>");
4537
+ process.exit(1);
4538
+ }
4539
+ if (!funcName) {
4540
+ console.error("[wawesome] Error: Missing function name.");
4541
+ console.error("[wawesome] Usage: wawesome cron resume <function> <schedule-name> or specify -f/--function <name>.");
4542
+ process.exit(1);
4543
+ }
4544
+ const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
4545
+ if (!res.ok) {
4546
+ const errorBody = await res.text();
4547
+ if (res.status === 401) {
4548
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4549
+ process.exit(1);
4550
+ }
4551
+ if (res.status === 404) {
4552
+ console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
4553
+ process.exit(1);
4554
+ }
4555
+ const refusal = rejectionOf(errorBody, res.status, `Failed to resume schedule (HTTP ${res.status}).`);
4556
+ console.error(`[wawesome] Error: ${refusal.message}`);
4557
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4558
+ if (advice) console.error(`[wawesome] ${advice}`);
4559
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4560
+ process.exit(1);
4561
+ }
4562
+ const data = await res.json();
4563
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4564
+ console.log(`[wawesome] ✔ Resumed schedule '${data.schedule.name}' on '${displayTarget}'.`);
4565
+ console.log(` ${whenItRuns(data.schedule)} (no backfill)`);
4566
+ }
4567
+ async function runHistoryCommand(target, options = {}) {
4568
+ const isVerbose = Boolean(options.verbose);
4569
+ const creds = requireCredentials();
4570
+ const config = readFunctionConfig();
4571
+ const funcName = target || options.function || config?.function;
4572
+ const appSlug = options.app || config?.app;
4573
+ if (!funcName) {
4574
+ console.error("[wawesome] Error: Missing function name.");
4575
+ console.error("[wawesome] Usage: wawesome cron history [function-name] or run inside a function directory with wawesome-function.json.");
4576
+ process.exit(1);
4577
+ }
4578
+ const limit = options.limit ? Number(options.limit) : 50;
4579
+ const stateFilter = options.state || options.status;
4580
+ const res = await fetchBackgroundRunsApi(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, {
4581
+ state: stateFilter,
4582
+ limit
4583
+ }, isVerbose);
4584
+ if (!res.ok) {
4585
+ const errorBody = await res.text();
4586
+ if (res.status === 401) {
4587
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4588
+ process.exit(1);
4589
+ }
4590
+ if (res.status === 404) {
4591
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
4592
+ process.exit(1);
4593
+ }
4594
+ const refusal = rejectionOf(errorBody, res.status, `Failed to fetch run history (HTTP ${res.status}).`);
4595
+ console.error(`[wawesome] Error: ${refusal.message}`);
4596
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4597
+ if (advice) console.error(`[wawesome] ${advice}`);
4598
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4599
+ process.exit(1);
4600
+ }
4601
+ const data = await res.json();
4602
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4603
+ if (!data.runs || data.runs.length === 0) {
4604
+ console.log(`[wawesome] No run history found for '${displayTarget}'.`);
4605
+ return;
4606
+ }
4607
+ const total = data.total ?? data.runs.length;
4608
+ console.log(`\n📜 \x1b[1mRun history for '${displayTarget}' (Showing ${data.runs.length} of ${total} records)\x1b[0m\n`);
4609
+ const tableLines = formatRunHistoryTable(data.runs);
4610
+ for (const line of tableLines) console.log(line);
4611
+ console.log("");
4612
+ }
4613
+ async function cronCommand(action, target, subtarget, options = {}) {
4614
+ const normAction = (action || "").toLowerCase().trim();
4615
+ if (!normAction || normAction === "list" || normAction === "ls") return listSchedulesCommand(target, options);
4616
+ if (normAction === "pause") return pauseScheduleCommand(target, subtarget, options);
4617
+ if (normAction === "resume") return resumeScheduleCommand(target, subtarget, options);
4618
+ if (normAction === "history" || normAction === "runs" || normAction === "log" || normAction === "logs") return runHistoryCommand(target, options);
4619
+ return listSchedulesCommand(action, options);
4620
+ }
4621
+ //#endregion
3702
4622
  //#region src/index.ts
3703
4623
  const cli = cac("wawesome");
3704
4624
  cli.command("build [entry]", "Bundle a serverless function to an optimized JS file").option("-o, --out <path>", "Output JS bundle path", { default: "dist/index.js" }).option("-v, --verbose", "Enable verbose debug output").action((entry, options) => buildJs(entry, options));
@@ -3741,6 +4661,40 @@ cli.command("logs [function-name-or-invocation-id]", "View invocation history, f
3741
4661
  With a function name, --follow streams the function's output continuously across
3742
4662
  invocations — new output appears each time the function runs, no need to catch a
3743
4663
  specific invocation. Press Ctrl-C to stop at any time.`).option("-f, --follow", "Stream live output (tail -f style). Follows a running invocation or waits for the next one. Ctrl-C to stop").option("-i, --invocation <id>", "Fetch stdout/stderr log body for a specific invocation ID").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-s, --status <status>", "Filter invocations by status (success, error, timeout, running)").option("--success", "Shorthand for --status success").option("--error", "Shorthand for --status error").option("--timeout", "Shorthand for --status timeout").option("--running", "Shorthand for --status running").option("-v, --verbose", "Enable verbose debug output").action((target, options) => logsCommand(target, options));
4664
+ cli.command("invoke [function]", "Fire a background run of a serverless function").usage(`invoke [function] [options]
4665
+
4666
+ Fire a Function run now through the authenticated API, without deploying anything
4667
+ or waiting for a scheduled tick.
4668
+
4669
+ By default, follows the run's output live and prints the outcome with duration.
4670
+
4671
+ Examples:
4672
+ wawesome invoke # invoke function in current directory
4673
+ wawesome invoke my-function # invoke 'my-function'
4674
+ wawesome invoke --no-follow # fire and exit without following output
4675
+ wawesome invoke -m POST -d '{"k":"v"}' # send custom HTTP method and body`).option("-m, --method <method>", "HTTP method to send to the function (default: POST)").option("-b, --body <body>", "Request body to send to the function").option("-d, --data <data>", "Alias for --body").option("--no-follow", "Do not follow the run's output live").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-v, --verbose", "Enable verbose debug output").action((target, options) => invokeCommand(target, options));
4676
+ cli.command("cron [action] [target] [subtarget]", "Manage schedules and read run history").usage(`cron <action> [options]
4677
+
4678
+ Actions:
4679
+ list [function] List schedules for a function or app (default)
4680
+ pause <schedule> Pause a schedule by name
4681
+ resume <schedule> Resume a paused schedule
4682
+ history [function] Read run history for scheduled and manual runs
4683
+
4684
+ Examples:
4685
+ wawesome cron # list schedules for current function
4686
+ wawesome cron list my-function # list schedules for 'my-function'
4687
+ wawesome cron list --app my-app # list schedules across all functions in 'my-app'
4688
+ wawesome cron pause nightly-reconcile # pause a schedule
4689
+ wawesome cron pause nightly-reconcile -r "maint" # pause with reason
4690
+ wawesome cron resume nightly-reconcile # resume a schedule
4691
+ wawesome cron history # view run history
4692
+ wawesome cron history my-function --state failed # filter run history by state`).option("-r, --reason <reason>", "Reason for pausing a schedule").option("-f, --function <function>", "Function name override").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-s, --state <state>", "Filter run history by state (pending, running, dispatched, skipped, missed, cancelled, lost, failed)").option("--status <status>", "Alias for --state").option("-l, --limit <limit>", "Limit number of history rows (default: 50)").option("-v, --verbose", "Enable verbose debug output").action((action, target, subtarget, options) => cronCommand(action, target, subtarget, options));
4693
+ cli.command("cron list [target]", "List schedules for a function or app").alias("cron ls").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((target, options) => listSchedulesCommand(target, options));
4694
+ cli.command("cron pause <schedule>", "Pause a schedule by name").option("-r, --reason <reason>", "Reason for pausing a schedule").option("-f, --function <function>", "Function name override").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((schedule, options) => pauseScheduleCommand(schedule, void 0, options));
4695
+ cli.command("cron resume <schedule>", "Resume a paused schedule").option("-f, --function <function>", "Function name override").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((schedule, options) => resumeScheduleCommand(schedule, void 0, options));
4696
+ cli.command("cron history [target]", "Read background run history").alias("cron runs").option("-s, --state <state>", "Filter run history by state (pending, running, dispatched, skipped, missed, cancelled, lost, failed)").option("--status <status>", "Alias for --state").option("-l, --limit <limit>", "Limit number of history rows (default: 50)").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((target, options) => runHistoryCommand(target, options));
4697
+ cli.command("schedules [action] [target]", "Alias for 'cron'").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => cronCommand(action, target, void 0, options));
3744
4698
  cli.help();
3745
4699
  cli.version(CLI_VERSION);
3746
4700
  cli.parse();