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.
- package/README.md +85 -2
- package/dist/index.mjs +1063 -109
- 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, {
|
|
98
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
1163
|
-
*
|
|
1164
|
-
* and
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1635
|
-
console.error(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3775
|
+
res = await authorizedFetch(unscopedUrl, {
|
|
3521
3776
|
headers,
|
|
3522
3777
|
signal: controller.signal
|
|
3523
3778
|
});
|
|
3524
3779
|
}
|
|
3525
|
-
} else res = await
|
|
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();
|