wawesome 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -5
- package/dist/index.mjs +757 -187
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -12,12 +12,91 @@ import { Readable } from "node:stream";
|
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { confirm, select } from "@inquirer/prompts";
|
|
14
14
|
import zlib from "node:zlib";
|
|
15
|
+
//#region src/errors.ts
|
|
16
|
+
/** A rejection carrying the gateway's own prose and, where given, its reason. */
|
|
17
|
+
var GatewayError = class extends Error {
|
|
18
|
+
status;
|
|
19
|
+
reason;
|
|
20
|
+
/** The raw response, for `--verbose` to print when the prose is not enough. */
|
|
21
|
+
body;
|
|
22
|
+
constructor(message, status, reason, body = "") {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "GatewayError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
this.body = body;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Read a failed response into a `GatewayError`.
|
|
32
|
+
*
|
|
33
|
+
* `fallback` is used only when the response carries no prose of its own — a
|
|
34
|
+
* proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
|
|
35
|
+
* cased here: what to say about an expired session depends on what the caller
|
|
36
|
+
* was doing, so callers handle it before reaching this.
|
|
37
|
+
*/
|
|
38
|
+
async function asGatewayError(res, fallback) {
|
|
39
|
+
return rejectionOf(await res.text().catch(() => ""), res.status, fallback);
|
|
40
|
+
}
|
|
41
|
+
/** The same reading, for a caller that has already taken the body off the wire. */
|
|
42
|
+
function rejectionOf(body, status, fallback) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(body);
|
|
45
|
+
return new GatewayError(parsed.error || fallback, status, parsed.reason, body);
|
|
46
|
+
} catch {
|
|
47
|
+
return new GatewayError(fallback, status, void 0, body);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Why the gateway refused, where what was thrown might not be a refusal at all. */
|
|
51
|
+
function reasonOf(err) {
|
|
52
|
+
return err instanceof GatewayError ? err.reason : void 0;
|
|
53
|
+
}
|
|
54
|
+
/** What went wrong, as text, whatever was thrown. */
|
|
55
|
+
function errorText(err) {
|
|
56
|
+
return err instanceof Error ? err.message : String(err);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/credential.ts
|
|
60
|
+
const CREDENTIAL_ENV_VAR = "WAWESOME_DEPLOY_CREDENTIAL";
|
|
61
|
+
function credentialFromEnvironment() {
|
|
62
|
+
const secret = process.env[CREDENTIAL_ENV_VAR]?.trim();
|
|
63
|
+
return secret ? secret : null;
|
|
64
|
+
}
|
|
65
|
+
/** Enough to match a credential against the one in a CI settings page, and no more. */
|
|
66
|
+
function credentialPrefix(secret) {
|
|
67
|
+
return secret.slice(0, 11);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A credential has no session behind it, so the platform's own reason — unknown,
|
|
71
|
+
* revoked, expired, or a scope it does not carry — is the whole answer, and
|
|
72
|
+
* "sign in again" names a door a CI runner cannot open.
|
|
73
|
+
*/
|
|
74
|
+
function unauthorizedMessage(status, body) {
|
|
75
|
+
const secret = credentialFromEnvironment();
|
|
76
|
+
if (!secret) return status === 401 ? "Authentication expired. Run 'wawesome login' again." : rejectionOf(body, status, `The platform refused this request (HTTP ${status}).`).message;
|
|
77
|
+
return rejectionOf(body, status, `The deploy credential ${credentialPrefix(secret)} was refused (HTTP ${status}).`).message;
|
|
78
|
+
}
|
|
79
|
+
function reportSessionCommandRefused(secret, consequence) {
|
|
80
|
+
console.error(`[wawesome] Error: ${CREDENTIAL_ENV_VAR} is set, so this CLI authenticates as deploy credential ${credentialPrefix(secret)}.`);
|
|
81
|
+
console.error(`[wawesome] ${consequence} Unset ${CREDENTIAL_ENV_VAR} first.`);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
15
84
|
//#region src/config.ts
|
|
16
85
|
/**
|
|
17
86
|
* Hardcoded Supabase project config for the wawesome.io platform.
|
|
18
87
|
* These are public values — the anon key is designed to be embedded in clients.
|
|
19
88
|
*/
|
|
20
89
|
const SUPABASE_URL = "https://vclasavxxoufwymrutai.supabase.co";
|
|
90
|
+
/**
|
|
91
|
+
* Where the documents accepted at signup are published. Fixed rather than
|
|
92
|
+
* resolved per environment: there is one website, and a signup has to be able
|
|
93
|
+
* to link at something a person can actually read before they accept it.
|
|
94
|
+
*/
|
|
95
|
+
const LEGAL_URLS = {
|
|
96
|
+
terms: "https://wawesome.io/legal/terms-of-service",
|
|
97
|
+
privacy: "https://wawesome.io/legal/privacy-policy"
|
|
98
|
+
};
|
|
99
|
+
const SUPABASE_ANON_KEY = "sb_publishable_ZlgDAI9cNZkefEwVPKIngA_ktuvTpjD";
|
|
21
100
|
/** Path to the user-level settings file (~/.wawesome/settings.json) */
|
|
22
101
|
function getSettingsPath() {
|
|
23
102
|
return path.join(os.homedir(), ".wawesome", "settings.json");
|
|
@@ -50,11 +129,22 @@ function resolveAddress(overrideUrl, envNames, settingsKey, stored, fallback) {
|
|
|
50
129
|
return stored || fallback;
|
|
51
130
|
}
|
|
52
131
|
function getGatewayUrl(overrideUrl) {
|
|
53
|
-
return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url",
|
|
132
|
+
return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", readStoredCredentials()?.gateway_url, GATEWAY_URL_FALLBACK);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A tenant token is only valid at the gateway that issued it, so every other
|
|
136
|
+
* command has to follow the credentials. A login holds no token yet, and
|
|
137
|
+
* reading that rung here made a local gateway outlive the removal of every
|
|
138
|
+
* documented override, since only `logout` clears it. A **Deploy credential**
|
|
139
|
+
* reads the same rungs, and for a sharper reason: following a stale file would
|
|
140
|
+
* present it at a gateway that never minted it.
|
|
141
|
+
*/
|
|
142
|
+
function resolveGatewayUrlWithoutSession(overrideUrl) {
|
|
143
|
+
return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", void 0, GATEWAY_URL_FALLBACK);
|
|
54
144
|
}
|
|
55
145
|
getGatewayUrl();
|
|
56
146
|
function getDashboardUrl(overrideUrl) {
|
|
57
|
-
return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url",
|
|
147
|
+
return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", readStoredCredentials()?.dashboard_url, DASHBOARD_URL_FALLBACK);
|
|
58
148
|
}
|
|
59
149
|
/**
|
|
60
150
|
* The dashboard for the environment this login is going to.
|
|
@@ -65,7 +155,7 @@ function getDashboardUrl(overrideUrl) {
|
|
|
65
155
|
* so is dropping one where the gateway did not change at all.
|
|
66
156
|
*/
|
|
67
157
|
function resolveDashboardUrlForLogin(overrideUrl, gatewayUrl) {
|
|
68
|
-
const creds =
|
|
158
|
+
const creds = readStoredCredentials();
|
|
69
159
|
const sameEnvironment = creds?.gateway_url === gatewayUrl;
|
|
70
160
|
return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", sameEnvironment ? creds?.dashboard_url : void 0, DASHBOARD_URL_FALLBACK);
|
|
71
161
|
}
|
|
@@ -76,9 +166,31 @@ function getCredentialsPath() {
|
|
|
76
166
|
return path.join(os.homedir(), ".wawesome", "credentials.json");
|
|
77
167
|
}
|
|
78
168
|
/**
|
|
79
|
-
*
|
|
169
|
+
* What this process authenticates with: the **Deploy credential** in the
|
|
170
|
+
* environment where one is set, and otherwise the session `login` left on disk.
|
|
171
|
+
*
|
|
172
|
+
* The environment wins outright rather than filling a gap, so a CI runner
|
|
173
|
+
* carrying a stale cached home directory still authenticates as itself.
|
|
80
174
|
*/
|
|
81
175
|
function readCredentials() {
|
|
176
|
+
const secret = credentialFromEnvironment();
|
|
177
|
+
return secret ? environmentCredential(secret) : readStoredCredentials();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A credential wears the shape of a session so that every command reaches the
|
|
181
|
+
* platform the same way. What it does not carry is the point: no refresh token,
|
|
182
|
+
* because there is nothing behind it to renew, and no Tenant, because the
|
|
183
|
+
* platform resolves that from the credential.
|
|
184
|
+
*/
|
|
185
|
+
function environmentCredential(secret) {
|
|
186
|
+
return {
|
|
187
|
+
gateway_url: resolveGatewayUrlWithoutSession(void 0),
|
|
188
|
+
tenant_jwt: secret,
|
|
189
|
+
credential_prefix: credentialPrefix(secret)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** The session on disk, whatever the environment says. */
|
|
193
|
+
function readStoredCredentials() {
|
|
82
194
|
const credPath = getCredentialsPath();
|
|
83
195
|
if (!fs.existsSync(credPath)) return null;
|
|
84
196
|
try {
|
|
@@ -90,12 +202,24 @@ function readCredentials() {
|
|
|
90
202
|
}
|
|
91
203
|
/**
|
|
92
204
|
* Write credentials to disk, creating the directory if needed.
|
|
205
|
+
*
|
|
206
|
+
* Owner-only, because the file holds a refresh token. The `chmod` is what
|
|
207
|
+
* tightens a file an older CLI left world-readable; a mode passed to
|
|
208
|
+
* `writeFileSync` applies only where it creates the file.
|
|
93
209
|
*/
|
|
94
210
|
function writeCredentials(creds) {
|
|
211
|
+
if (credentialFromEnvironment()) return;
|
|
95
212
|
const credPath = getCredentialsPath();
|
|
96
213
|
const dir = path.dirname(credPath);
|
|
97
|
-
fs.mkdirSync(dir, {
|
|
98
|
-
|
|
214
|
+
fs.mkdirSync(dir, {
|
|
215
|
+
recursive: true,
|
|
216
|
+
mode: 448
|
|
217
|
+
});
|
|
218
|
+
fs.writeFileSync(credPath, JSON.stringify(creds, null, 2), {
|
|
219
|
+
encoding: "utf-8",
|
|
220
|
+
mode: 384
|
|
221
|
+
});
|
|
222
|
+
fs.chmodSync(credPath, 384);
|
|
99
223
|
}
|
|
100
224
|
/**
|
|
101
225
|
* Delete stored credentials.
|
|
@@ -759,7 +883,7 @@ async function buildJs(entryInput, options) {
|
|
|
759
883
|
* that has to name this version — `--version`, the dependency a scaffolded
|
|
760
884
|
* project pins — reads it here, so a release bumps one file.
|
|
761
885
|
*/
|
|
762
|
-
const CLI_VERSION = "0.
|
|
886
|
+
const CLI_VERSION = "0.9.0";
|
|
763
887
|
//#endregion
|
|
764
888
|
//#region src/prompt.ts
|
|
765
889
|
/**
|
|
@@ -816,53 +940,130 @@ function isInteractive() {
|
|
|
816
940
|
return Boolean(process.stdin.isTTY);
|
|
817
941
|
}
|
|
818
942
|
//#endregion
|
|
819
|
-
//#region src/
|
|
820
|
-
/** A rejection carrying the gateway's own prose and, where given, its reason. */
|
|
821
|
-
var GatewayError = class extends Error {
|
|
822
|
-
status;
|
|
823
|
-
reason;
|
|
824
|
-
/** The raw response, for `--verbose` to print when the prose is not enough. */
|
|
825
|
-
body;
|
|
826
|
-
constructor(message, status, reason, body = "") {
|
|
827
|
-
super(message);
|
|
828
|
-
this.name = "GatewayError";
|
|
829
|
-
this.status = status;
|
|
830
|
-
this.reason = reason;
|
|
831
|
-
this.body = body;
|
|
832
|
-
}
|
|
833
|
-
};
|
|
943
|
+
//#region src/session.ts
|
|
834
944
|
/**
|
|
835
|
-
*
|
|
945
|
+
* A gateway request, retried once against a renewed session if it comes back 401.
|
|
836
946
|
*
|
|
837
|
-
* `
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
947
|
+
* A caller's `Authorization` is a snapshot of what was on disk when the command
|
|
948
|
+
* started, so a renewal here replaces it. Otherwise every later request in the
|
|
949
|
+
* same command would spend a 401 learning what this already knows.
|
|
950
|
+
*
|
|
951
|
+
* A body that can only be read once, an asset streamed off disk, passes a
|
|
952
|
+
* function so the retry is built one of its own.
|
|
841
953
|
*/
|
|
842
|
-
async function
|
|
843
|
-
|
|
954
|
+
async function authorizedFetch(url, init = {}) {
|
|
955
|
+
const request = typeof init === "function" ? init : () => init;
|
|
956
|
+
const sentAt = Date.now();
|
|
957
|
+
const res = await fetch(url, renewedJwt ? withBearer(request(), renewedJwt) : request());
|
|
958
|
+
if (res.status !== 401) return res;
|
|
959
|
+
if (credentialFromEnvironment()) return res;
|
|
960
|
+
if (renewedJwt && renewedAt > sentAt) return retry(url, request, renewedJwt, res);
|
|
961
|
+
if (renewedJwt && Date.now() - renewedAt < JUST_RENEWED_MS) return res;
|
|
962
|
+
const renewed = await renewSession();
|
|
963
|
+
if (!renewed) return res;
|
|
964
|
+
return retry(url, request, renewed.tenant_jwt, res);
|
|
965
|
+
}
|
|
966
|
+
/** Thirty minutes of token life leaves this nowhere near a genuine expiry. */
|
|
967
|
+
const JUST_RENEWED_MS = 6e4;
|
|
968
|
+
async function retry(url, request, tenantJwt, refused) {
|
|
969
|
+
await refused.body?.cancel().catch(() => {});
|
|
970
|
+
return fetch(url, withBearer(request(), tenantJwt));
|
|
971
|
+
}
|
|
972
|
+
function withBearer(init, tenantJwt) {
|
|
973
|
+
return {
|
|
974
|
+
...init,
|
|
975
|
+
headers: {
|
|
976
|
+
...headerRecord(init.headers),
|
|
977
|
+
Authorization: `Bearer ${tenantJwt}`
|
|
978
|
+
}
|
|
979
|
+
};
|
|
844
980
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
981
|
+
function headerRecord(headers) {
|
|
982
|
+
if (!headers) return {};
|
|
983
|
+
if (headers instanceof Headers || Array.isArray(headers)) return Object.fromEntries(new Headers(headers).entries());
|
|
984
|
+
return { ...headers };
|
|
985
|
+
}
|
|
986
|
+
let renewal = null;
|
|
987
|
+
let renewedJwt = null;
|
|
988
|
+
let renewedAt = 0;
|
|
989
|
+
/** Drop what this process learned about the session, as ending one does. */
|
|
990
|
+
function forgetRenewedSession() {
|
|
991
|
+
renewal = null;
|
|
992
|
+
renewedJwt = null;
|
|
993
|
+
renewedAt = 0;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Supabase rotates the refresh token on every use and revokes the whole family
|
|
997
|
+
* if a spent one comes back, so requests that meet the 401 together share one
|
|
998
|
+
* renewal instead of racing with the same token.
|
|
999
|
+
*/
|
|
1000
|
+
function renewSession() {
|
|
1001
|
+
if (!renewal) {
|
|
1002
|
+
const inFlight = renewOnce().finally(() => {
|
|
1003
|
+
if (renewal === inFlight) renewal = null;
|
|
1004
|
+
});
|
|
1005
|
+
renewal = inFlight;
|
|
852
1006
|
}
|
|
1007
|
+
return renewal;
|
|
853
1008
|
}
|
|
854
|
-
/**
|
|
855
|
-
|
|
856
|
-
|
|
1009
|
+
/**
|
|
1010
|
+
* Null where there is nothing to renew from. Credentials written before refresh
|
|
1011
|
+
* tokens were stored still describe a session, so the 401 goes back to the
|
|
1012
|
+
* caller, whose own message for it is the right one.
|
|
1013
|
+
*/
|
|
1014
|
+
async function renewOnce() {
|
|
1015
|
+
const creds = readCredentials();
|
|
1016
|
+
if (!creds?.refresh_token) return null;
|
|
1017
|
+
const session = await refreshSupabaseSession(creds.refresh_token);
|
|
1018
|
+
const exchanged = await exchangeForTenantJwt(creds, session.access_token);
|
|
1019
|
+
const renewed = {
|
|
1020
|
+
...creds,
|
|
1021
|
+
tenant_jwt: exchanged.tenant_jwt,
|
|
1022
|
+
refresh_token: session.refresh_token || creds.refresh_token,
|
|
1023
|
+
tenant_slug: exchanged.tenant_slug || creds.tenant_slug,
|
|
1024
|
+
tenant_name: exchanged.tenant_name || creds.tenant_name
|
|
1025
|
+
};
|
|
1026
|
+
writeCredentials(renewed);
|
|
1027
|
+
renewedJwt = renewed.tenant_jwt;
|
|
1028
|
+
renewedAt = Date.now();
|
|
1029
|
+
return renewed;
|
|
857
1030
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1031
|
+
async function refreshSupabaseSession(refreshToken) {
|
|
1032
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
1033
|
+
method: "POST",
|
|
1034
|
+
headers: {
|
|
1035
|
+
apikey: SUPABASE_ANON_KEY,
|
|
1036
|
+
"Content-Type": "application/json"
|
|
1037
|
+
},
|
|
1038
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
1039
|
+
});
|
|
1040
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429) endSession("Your sign-in was revoked or has expired, so the session could not be renewed.");
|
|
1041
|
+
if (!res.ok) throw new Error(`Could not renew your session (Supabase HTTP ${res.status}).`);
|
|
1042
|
+
const session = await res.json();
|
|
1043
|
+
if (!session.access_token) throw new Error("Could not renew your session (Supabase returned no access token).");
|
|
1044
|
+
return session;
|
|
1045
|
+
}
|
|
1046
|
+
async function exchangeForTenantJwt(creds, supabaseAccessToken) {
|
|
1047
|
+
const res = await fetch(`${creds.gateway_url}/api/v1/auth/token-exchange`, {
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
headers: {
|
|
1050
|
+
Authorization: `Bearer ${supabaseAccessToken}`,
|
|
1051
|
+
"Content-Type": "application/json"
|
|
1052
|
+
},
|
|
1053
|
+
body: JSON.stringify({ tenant_id: creds.tenant_id })
|
|
1054
|
+
});
|
|
1055
|
+
if (res.status === 401 || res.status === 403) endSession("Your access to this workspace has been withdrawn.");
|
|
1056
|
+
if (!res.ok) throw new Error(`Could not renew your session (token exchange HTTP ${res.status}).`);
|
|
1057
|
+
return await res.json();
|
|
1058
|
+
}
|
|
1059
|
+
function endSession(reason) {
|
|
1060
|
+
console.error(`[wawesome] Error: ${reason} Run 'wawesome login' to sign in again.`);
|
|
1061
|
+
process.exit(1);
|
|
861
1062
|
}
|
|
862
1063
|
//#endregion
|
|
863
1064
|
//#region src/tenant.ts
|
|
864
1065
|
async function fetchTenantDetails(creds) {
|
|
865
|
-
const res = await
|
|
1066
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
866
1067
|
if (!res.ok) throw await asGatewayError(res, `Failed to read workspace (HTTP ${res.status}).`);
|
|
867
1068
|
return await res.json();
|
|
868
1069
|
}
|
|
@@ -928,7 +1129,7 @@ function renameAdvice(reason) {
|
|
|
928
1129
|
* unusable name comes back as a `malformed` rejection instead.
|
|
929
1130
|
*/
|
|
930
1131
|
async function renameTenantSlug(creds, slug) {
|
|
931
|
-
const res = await
|
|
1132
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/slug`, {
|
|
932
1133
|
method: "PUT",
|
|
933
1134
|
headers: {
|
|
934
1135
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -972,6 +1173,109 @@ async function promptForWorkspaceName(options) {
|
|
|
972
1173
|
throw new Error("No workspace name given. Nothing was created.");
|
|
973
1174
|
}
|
|
974
1175
|
/**
|
|
1176
|
+
* Ask the person to accept the terms, which is what creating a workspace
|
|
1177
|
+
* through this door means too.
|
|
1178
|
+
*
|
|
1179
|
+
* Declining is the default answer, not the one that needs typing: end of input
|
|
1180
|
+
* takes the default, so a piped login that says nothing has accepted nothing —
|
|
1181
|
+
* the terminal's version of an unticked box. The flag is a word from the person
|
|
1182
|
+
* running the command rather than a prompt, so a pipeline reads the same as a
|
|
1183
|
+
* terminal, and both documents are linked either way.
|
|
1184
|
+
*/
|
|
1185
|
+
async function confirmTermsAccepted(options) {
|
|
1186
|
+
if (options.acceptTerms) return;
|
|
1187
|
+
if (!isInteractive()) throw new Error(`Creating a workspace means accepting the terms of service and the privacy policy, and there is no terminal to ask in. Read ${LEGAL_URLS.terms} and ${LEGAL_URLS.privacy}, then re-run with --accept-terms.`);
|
|
1188
|
+
console.log("\n[wawesome] Creating a workspace means accepting:");
|
|
1189
|
+
console.log(` Terms of Service ${LEGAL_URLS.terms}`);
|
|
1190
|
+
console.log(` Privacy Policy ${LEGAL_URLS.privacy}`);
|
|
1191
|
+
const session = openPromptSession();
|
|
1192
|
+
try {
|
|
1193
|
+
const answer = await session.ask(" Accept both?", "n");
|
|
1194
|
+
if (/^y(es)?$/i.test(answer.trim())) return;
|
|
1195
|
+
} finally {
|
|
1196
|
+
session.close();
|
|
1197
|
+
}
|
|
1198
|
+
throw new Error("The terms were not accepted. Nothing was created.");
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Turn a finished OAuth callback into stored credentials.
|
|
1202
|
+
*
|
|
1203
|
+
* Split from the callback server so what login stores can be exercised without
|
|
1204
|
+
* a browser and a port.
|
|
1205
|
+
*/
|
|
1206
|
+
async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshToken, options }) {
|
|
1207
|
+
const isVerbose = Boolean(options.verbose);
|
|
1208
|
+
console.log("[wawesome] Fetching your workspaces...");
|
|
1209
|
+
const tenantsRes = await fetch(`${gatewayUrl}/api/v1/me/tenants`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
1210
|
+
if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
|
|
1211
|
+
const tenants = await tenantsRes.json();
|
|
1212
|
+
let primaryTenantId;
|
|
1213
|
+
let workspaceName;
|
|
1214
|
+
let workspaceSlug;
|
|
1215
|
+
if (tenants.length === 0) {
|
|
1216
|
+
const chosenName = await promptForWorkspaceName(options);
|
|
1217
|
+
await confirmTermsAccepted(options);
|
|
1218
|
+
console.log(`[wawesome] Creating workspace "${chosenName}"...`);
|
|
1219
|
+
const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
|
|
1220
|
+
method: "POST",
|
|
1221
|
+
headers: {
|
|
1222
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1223
|
+
"Content-Type": "application/json"
|
|
1224
|
+
},
|
|
1225
|
+
body: JSON.stringify({
|
|
1226
|
+
tenant_name: chosenName,
|
|
1227
|
+
accepted_terms: true
|
|
1228
|
+
})
|
|
1229
|
+
});
|
|
1230
|
+
if (!initRes.ok) throw await asGatewayError(initRes, `Failed to initialize workspace (HTTP ${initRes.status}).`);
|
|
1231
|
+
const initData = await initRes.json();
|
|
1232
|
+
primaryTenantId = initData.tenant_id;
|
|
1233
|
+
workspaceSlug = initData.tenant_slug;
|
|
1234
|
+
workspaceName = initData.tenant_name || chosenName;
|
|
1235
|
+
} else primaryTenantId = tenants[0].tenant_id;
|
|
1236
|
+
if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
|
|
1237
|
+
console.log("[wawesome] Exchanging for tenant-scoped credentials...");
|
|
1238
|
+
const exchangeRes = await fetch(`${gatewayUrl}/api/v1/auth/token-exchange`, {
|
|
1239
|
+
method: "POST",
|
|
1240
|
+
headers: {
|
|
1241
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1242
|
+
"Content-Type": "application/json"
|
|
1243
|
+
},
|
|
1244
|
+
body: JSON.stringify({ tenant_id: primaryTenantId })
|
|
1245
|
+
});
|
|
1246
|
+
if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
|
|
1247
|
+
const exchangeData = await exchangeRes.json();
|
|
1248
|
+
workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
|
|
1249
|
+
workspaceName = exchangeData.tenant_name || workspaceName;
|
|
1250
|
+
let userEmail = exchangeData.email || "unknown";
|
|
1251
|
+
try {
|
|
1252
|
+
const payloadPart = exchangeData.tenant_jwt.split(".")[1];
|
|
1253
|
+
const decoded = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf-8"));
|
|
1254
|
+
if (decoded.email) userEmail = decoded.email;
|
|
1255
|
+
} catch {}
|
|
1256
|
+
forgetRenewedSession();
|
|
1257
|
+
writeCredentials({
|
|
1258
|
+
gateway_url: gatewayUrl,
|
|
1259
|
+
dashboard_url: dashboardUrl,
|
|
1260
|
+
tenant_jwt: exchangeData.tenant_jwt,
|
|
1261
|
+
refresh_token: refreshToken,
|
|
1262
|
+
tenant_id: primaryTenantId,
|
|
1263
|
+
user_email: userEmail,
|
|
1264
|
+
tenant_slug: workspaceSlug,
|
|
1265
|
+
tenant_name: workspaceName
|
|
1266
|
+
});
|
|
1267
|
+
console.log("\n======================================================");
|
|
1268
|
+
console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
|
|
1269
|
+
console.log("======================================================");
|
|
1270
|
+
if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
|
|
1271
|
+
if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
|
|
1272
|
+
console.log(` Tenant: ${primaryTenantId}`);
|
|
1273
|
+
console.log(` Gateway: ${gatewayUrl}`);
|
|
1274
|
+
console.log(` Email: ${userEmail}`);
|
|
1275
|
+
console.log("\n Credentials saved to ~/.wawesome/credentials.json");
|
|
1276
|
+
console.log("======================================================\n");
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
975
1279
|
* OAuth login flow:
|
|
976
1280
|
* 1. Build Supabase OAuth URL
|
|
977
1281
|
* 2. Open browser
|
|
@@ -980,7 +1284,12 @@ async function promptForWorkspaceName(options) {
|
|
|
980
1284
|
* 5. Store credentials in ~/.wawesome/credentials.json
|
|
981
1285
|
*/
|
|
982
1286
|
async function login(options) {
|
|
983
|
-
const
|
|
1287
|
+
const credential = credentialFromEnvironment();
|
|
1288
|
+
if (credential) {
|
|
1289
|
+
reportSessionCommandRefused(credential, "A session signed in here would be read by nothing.");
|
|
1290
|
+
process.exit(1);
|
|
1291
|
+
}
|
|
1292
|
+
const gatewayUrl = resolveGatewayUrlWithoutSession(options.gateway || options.api);
|
|
984
1293
|
const dashboardUrl = resolveDashboardUrlForLogin(options.dashboard, gatewayUrl);
|
|
985
1294
|
const provider = options.provider || "github";
|
|
986
1295
|
const isVerbose = Boolean(options.verbose);
|
|
@@ -1025,6 +1334,7 @@ async function login(options) {
|
|
|
1025
1334
|
}
|
|
1026
1335
|
if (reqUrl.pathname === "/token") {
|
|
1027
1336
|
const accessToken = reqUrl.searchParams.get("access_token");
|
|
1337
|
+
const refreshToken = reqUrl.searchParams.get("refresh_token");
|
|
1028
1338
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
1029
1339
|
res.end("Token received by CLI.");
|
|
1030
1340
|
if (!accessToken) {
|
|
@@ -1035,69 +1345,13 @@ async function login(options) {
|
|
|
1035
1345
|
}
|
|
1036
1346
|
if (isVerbose) console.log("[wawesome:verbose] Supabase access token received.");
|
|
1037
1347
|
try {
|
|
1038
|
-
|
|
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
|
|
1348
|
+
await completeLogin({
|
|
1349
|
+
gatewayUrl,
|
|
1350
|
+
dashboardUrl,
|
|
1351
|
+
accessToken,
|
|
1352
|
+
refreshToken: refreshToken || void 0,
|
|
1353
|
+
options
|
|
1090
1354
|
});
|
|
1091
|
-
console.log("\n======================================================");
|
|
1092
|
-
console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
|
|
1093
|
-
console.log("======================================================");
|
|
1094
|
-
if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
|
|
1095
|
-
if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
|
|
1096
|
-
console.log(` Tenant: ${primaryTenantId}`);
|
|
1097
|
-
console.log(` Gateway: ${gatewayUrl}`);
|
|
1098
|
-
console.log(` Email: ${userEmail}`);
|
|
1099
|
-
console.log("\n Credentials saved to ~/.wawesome/credentials.json");
|
|
1100
|
-
console.log("======================================================\n");
|
|
1101
1355
|
server.close();
|
|
1102
1356
|
resolve();
|
|
1103
1357
|
} catch (err) {
|
|
@@ -1127,6 +1381,12 @@ async function login(options) {
|
|
|
1127
1381
|
* Clear stored credentials.
|
|
1128
1382
|
*/
|
|
1129
1383
|
function logout() {
|
|
1384
|
+
const credential = credentialFromEnvironment();
|
|
1385
|
+
if (credential) {
|
|
1386
|
+
reportSessionCommandRefused(credential, "The next command would authenticate all the same.");
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
}
|
|
1389
|
+
forgetRenewedSession();
|
|
1130
1390
|
if (deleteCredentials()) console.log("[wawesome] ✅ Logged out. Credentials removed.");
|
|
1131
1391
|
else console.log("[wawesome] Not logged in (no credentials found).");
|
|
1132
1392
|
}
|
|
@@ -1135,6 +1395,7 @@ function logout() {
|
|
|
1135
1395
|
*/
|
|
1136
1396
|
async function whoami() {
|
|
1137
1397
|
const creds = readCredentials();
|
|
1398
|
+
if (creds?.credential_prefix) return reportCredential(creds);
|
|
1138
1399
|
if (!creds) {
|
|
1139
1400
|
console.log("[wawesome] Not logged in. Run 'wawesome login' to authenticate.");
|
|
1140
1401
|
return;
|
|
@@ -1154,6 +1415,33 @@ async function whoami() {
|
|
|
1154
1415
|
console.log(` Tenant: ${creds.tenant_id}`);
|
|
1155
1416
|
console.log(` Gateway: ${creds.gateway_url}\n`);
|
|
1156
1417
|
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Name the credential the commands here run as.
|
|
1420
|
+
*
|
|
1421
|
+
* By its prefix, never the secret: what a developer needs is to match the
|
|
1422
|
+
* identity in front of them against the one in their CI settings, and the
|
|
1423
|
+
* output of `whoami` is the sort of thing that ends up pasted into an issue.
|
|
1424
|
+
* The workspace is asked for rather than read, because a credential names no
|
|
1425
|
+
* Tenant of its own — the platform resolves that from the credential.
|
|
1426
|
+
*/
|
|
1427
|
+
async function reportCredential(creds) {
|
|
1428
|
+
let workspace = null;
|
|
1429
|
+
let refusal = null;
|
|
1430
|
+
try {
|
|
1431
|
+
workspace = await resolveWorkspace(creds);
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
if (err instanceof GatewayError && (err.status === 401 || err.status === 403)) refusal = err.message;
|
|
1434
|
+
}
|
|
1435
|
+
console.log("\n[wawesome] Acting as a deploy credential:");
|
|
1436
|
+
console.log(` Credential: ${creds.credential_prefix} (from ${CREDENTIAL_ENV_VAR})`);
|
|
1437
|
+
if (workspace) {
|
|
1438
|
+
console.log(` Workspace: ${workspace.name}`);
|
|
1439
|
+
console.log(` Address: ${workspace.slug}`);
|
|
1440
|
+
}
|
|
1441
|
+
console.log(` Gateway: ${creds.gateway_url}`);
|
|
1442
|
+
if (refusal) console.log(` Refused: ${refusal}`);
|
|
1443
|
+
console.log("");
|
|
1444
|
+
}
|
|
1157
1445
|
//#endregion
|
|
1158
1446
|
//#region src/schedules.ts
|
|
1159
1447
|
/**
|
|
@@ -1164,15 +1452,18 @@ async function whoami() {
|
|
|
1164
1452
|
* press resume, and file a bug when neither starts it.
|
|
1165
1453
|
*/
|
|
1166
1454
|
function whenItRuns(schedule) {
|
|
1167
|
-
if (schedule.state === "suspended") return "suspended — the workspace owes payment";
|
|
1168
1455
|
const undeclared = schedule.declared === false;
|
|
1456
|
+
if (schedule.state === "suspended") {
|
|
1457
|
+
const why = "the workspace owes payment, its subscription ended, or it has spent past its plan's allowance";
|
|
1458
|
+
return undeclared ? `suspended, and not in this config file — ${why}; declare it again too` : `suspended — ${why}`;
|
|
1459
|
+
}
|
|
1169
1460
|
if (schedule.state === "paused") return undeclared ? "paused, and not in this config file — declare it again, then resume" : "paused — resume it to run again";
|
|
1170
1461
|
if (undeclared) return "not in this config file — disabled, kept";
|
|
1171
1462
|
if (schedule.state !== "active") return schedule.state;
|
|
1172
|
-
return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
|
|
1463
|
+
return schedule.next_fire_at ? `next run ${asUtc$1(schedule.next_fire_at)}` : "next run unknown";
|
|
1173
1464
|
}
|
|
1174
1465
|
/** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
|
|
1175
|
-
function asUtc(iso) {
|
|
1466
|
+
function asUtc$1(iso) {
|
|
1176
1467
|
const at = new Date(iso);
|
|
1177
1468
|
if (Number.isNaN(at.getTime())) return iso;
|
|
1178
1469
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -1201,7 +1492,7 @@ async function fetchTenantUsage(creds, timeoutMs = USAGE_TIMEOUT_MS) {
|
|
|
1201
1492
|
const controller = new AbortController();
|
|
1202
1493
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1203
1494
|
try {
|
|
1204
|
-
const res = await
|
|
1495
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/usage`, {
|
|
1205
1496
|
headers: { Authorization: `Bearer ${creds.tenant_jwt}` },
|
|
1206
1497
|
signal: controller.signal
|
|
1207
1498
|
});
|
|
@@ -1697,7 +1988,7 @@ async function deploy(entryInput, options) {
|
|
|
1697
1988
|
const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
|
|
1698
1989
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
|
|
1699
1990
|
const declaredOnTheWire = declaredFields(declared);
|
|
1700
|
-
const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await
|
|
1991
|
+
const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await authorizedFetch(uploadUrl, {
|
|
1701
1992
|
method: "POST",
|
|
1702
1993
|
headers: {
|
|
1703
1994
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1709,7 +2000,7 @@ async function deploy(entryInput, options) {
|
|
|
1709
2000
|
...declaresSchedules ? { schedules: config.schedules } : {},
|
|
1710
2001
|
...declaredOnTheWire
|
|
1711
2002
|
})
|
|
1712
|
-
}) : await
|
|
2003
|
+
}) : await authorizedFetch(uploadUrl, {
|
|
1713
2004
|
method: "POST",
|
|
1714
2005
|
headers: {
|
|
1715
2006
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1720,7 +2011,7 @@ async function deploy(entryInput, options) {
|
|
|
1720
2011
|
if (!uploadRes.ok) {
|
|
1721
2012
|
const errorBody = await uploadRes.text();
|
|
1722
2013
|
const refusal = rejectionOf(errorBody, uploadRes.status, "Version with this code bundle already exists.");
|
|
1723
|
-
if (uploadRes.status === 401) console.error(
|
|
2014
|
+
if (uploadRes.status === 401 || uploadRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(uploadRes.status, errorBody)}`);
|
|
1724
2015
|
else if (refusal.reason === "paid-plan-required") {
|
|
1725
2016
|
console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
|
|
1726
2017
|
console.error(`[wawesome] ${planLimitAdvice(refusal.reason)}\n`);
|
|
@@ -1755,7 +2046,7 @@ async function deploy(entryInput, options) {
|
|
|
1755
2046
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${deployUrl}`);
|
|
1756
2047
|
const deployBody = {};
|
|
1757
2048
|
if (version !== void 0) deployBody.version_number = version;
|
|
1758
|
-
const deployRes = await
|
|
2049
|
+
const deployRes = await authorizedFetch(deployUrl, {
|
|
1759
2050
|
method: "POST",
|
|
1760
2051
|
headers: {
|
|
1761
2052
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1765,7 +2056,7 @@ async function deploy(entryInput, options) {
|
|
|
1765
2056
|
});
|
|
1766
2057
|
if (!deployRes.ok) {
|
|
1767
2058
|
const errorBody = await deployRes.text();
|
|
1768
|
-
if (deployRes.status === 401) console.error(
|
|
2059
|
+
if (deployRes.status === 401 || deployRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(deployRes.status, errorBody)}`);
|
|
1769
2060
|
else {
|
|
1770
2061
|
console.error(`[wawesome] Error: Deployment failed (HTTP ${deployRes.status}).`);
|
|
1771
2062
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
@@ -1836,8 +2127,7 @@ async function deploy(entryInput, options) {
|
|
|
1836
2127
|
* changed nothing at all is refused here — before a byte of it has moved.
|
|
1837
2128
|
*/
|
|
1838
2129
|
async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVerbose, continueWhenUnchanged) {
|
|
1839
|
-
const
|
|
1840
|
-
const manifestRes = await fetch(manifestUrl, {
|
|
2130
|
+
const manifestRes = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/assets/manifest`, {
|
|
1841
2131
|
method: "POST",
|
|
1842
2132
|
headers: {
|
|
1843
2133
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1851,7 +2141,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
|
|
|
1851
2141
|
});
|
|
1852
2142
|
if (!manifestRes.ok) {
|
|
1853
2143
|
const errorBody = await manifestRes.text();
|
|
1854
|
-
if (manifestRes.status === 401) console.error(
|
|
2144
|
+
if (manifestRes.status === 401 || manifestRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(manifestRes.status, errorBody)}`);
|
|
1855
2145
|
else if (manifestRes.status === 409) {
|
|
1856
2146
|
if (continueWhenUnchanged) {
|
|
1857
2147
|
console.log("[wawesome] Code and files unchanged since the last deploy; applying schedules.");
|
|
@@ -1880,7 +2170,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
|
|
|
1880
2170
|
console.log(`[wawesome] Uploading ${toUpload.length} of ${assets.length} asset(s)...`);
|
|
1881
2171
|
for (const asset of toUpload) {
|
|
1882
2172
|
if (isVerbose) console.log(`[wawesome:verbose] PUT ${asset.path} (${asset.size_bytes} bytes)`);
|
|
1883
|
-
const res = await
|
|
2173
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/assets/${asset.content_hash}`, () => ({
|
|
1884
2174
|
method: "PUT",
|
|
1885
2175
|
headers: {
|
|
1886
2176
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1889,7 +2179,7 @@ async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVe
|
|
|
1889
2179
|
},
|
|
1890
2180
|
body: Readable.toWeb(fs.createReadStream(asset.source)),
|
|
1891
2181
|
duplex: "half"
|
|
1892
|
-
});
|
|
2182
|
+
}));
|
|
1893
2183
|
if (!res.ok) {
|
|
1894
2184
|
const errorBody = await res.text();
|
|
1895
2185
|
const refusal = rejectionOf(errorBody, res.status, `Upload of '${asset.path}' failed (HTTP ${res.status}).`);
|
|
@@ -1937,8 +2227,7 @@ function loadClientContext() {
|
|
|
1937
2227
|
* the time it does, so it needs to say what did land before it gives up.
|
|
1938
2228
|
*/
|
|
1939
2229
|
async function putEnvVar(creds, app, key, value, isSecret) {
|
|
1940
|
-
const
|
|
1941
|
-
const res = await fetch(url, {
|
|
2230
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`, {
|
|
1942
2231
|
method: "POST",
|
|
1943
2232
|
headers: {
|
|
1944
2233
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1951,7 +2240,7 @@ async function putEnvVar(creds, app, key, value, isSecret) {
|
|
|
1951
2240
|
})
|
|
1952
2241
|
});
|
|
1953
2242
|
if (!res.ok) {
|
|
1954
|
-
if (res.status === 401) throw new GatewayError(
|
|
2243
|
+
if (res.status === 401 || res.status === 403) throw new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
|
|
1955
2244
|
throw await asGatewayError(res, `Failed to set variable '${key}' (HTTP ${res.status}).`);
|
|
1956
2245
|
}
|
|
1957
2246
|
return await res.json();
|
|
@@ -2001,13 +2290,13 @@ async function listEnvVars(options) {
|
|
|
2001
2290
|
const { creds, app } = loadClientContext();
|
|
2002
2291
|
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
|
|
2003
2292
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
2004
|
-
const res = await
|
|
2293
|
+
const res = await authorizedFetch(url, {
|
|
2005
2294
|
method: "GET",
|
|
2006
2295
|
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
2007
2296
|
});
|
|
2008
2297
|
if (!res.ok) {
|
|
2009
2298
|
const errorBody = await res.text();
|
|
2010
|
-
if (res.status === 401) console.error(
|
|
2299
|
+
if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
2011
2300
|
else {
|
|
2012
2301
|
console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
|
|
2013
2302
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
@@ -2055,13 +2344,13 @@ async function removeEnvVar(key, options) {
|
|
|
2055
2344
|
const { creds, app } = loadClientContext();
|
|
2056
2345
|
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
|
|
2057
2346
|
if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
|
|
2058
|
-
const res = await
|
|
2347
|
+
const res = await authorizedFetch(url, {
|
|
2059
2348
|
method: "DELETE",
|
|
2060
2349
|
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
2061
2350
|
});
|
|
2062
2351
|
if (!res.ok) {
|
|
2063
2352
|
const errorBody = await res.text();
|
|
2064
|
-
if (res.status === 401) console.error(
|
|
2353
|
+
if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
2065
2354
|
else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
|
|
2066
2355
|
else {
|
|
2067
2356
|
console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
|
|
@@ -2137,7 +2426,7 @@ function installDependencies(dir, options = {}) {
|
|
|
2137
2426
|
//#region src/provision.ts
|
|
2138
2427
|
/** The gateway's own words for a failure, or a plain HTTP status if it gave none. */
|
|
2139
2428
|
async function rejected(res, fallback) {
|
|
2140
|
-
if (res.status === 401) return new GatewayError(
|
|
2429
|
+
if (res.status === 401 || res.status === 403) return new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
|
|
2141
2430
|
return asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
|
|
2142
2431
|
}
|
|
2143
2432
|
function authHeaders(creds) {
|
|
@@ -2158,10 +2447,10 @@ function authHeaders(creds) {
|
|
|
2158
2447
|
* between is still treated as the success it is.
|
|
2159
2448
|
*/
|
|
2160
2449
|
async function ensureApp(creds, appSlug) {
|
|
2161
|
-
const listRes = await
|
|
2450
|
+
const listRes = await authorizedFetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
2162
2451
|
if (!listRes.ok) throw await rejected(listRes, "Could not list the Tenant's apps");
|
|
2163
2452
|
if ((await listRes.json()).applications?.some((app) => app.slug === appSlug)) return;
|
|
2164
|
-
const createRes = await
|
|
2453
|
+
const createRes = await authorizedFetch(`${creds.gateway_url}/v1/apps`, {
|
|
2165
2454
|
method: "POST",
|
|
2166
2455
|
headers: authHeaders(creds),
|
|
2167
2456
|
body: JSON.stringify({
|
|
@@ -2182,8 +2471,7 @@ async function ensureApp(creds, appSlug) {
|
|
|
2182
2471
|
* scaffolded template and an outbound request that silently fails at runtime.
|
|
2183
2472
|
*/
|
|
2184
2473
|
async function enableEgressProvider(creds, appSlug, providerKey) {
|
|
2185
|
-
const
|
|
2186
|
-
const res = await fetch(url, {
|
|
2474
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`, {
|
|
2187
2475
|
method: "POST",
|
|
2188
2476
|
headers: authHeaders(creds),
|
|
2189
2477
|
body: JSON.stringify({ provider_key: providerKey })
|
|
@@ -2645,7 +2933,7 @@ function anArticle(label, capitalized = false) {
|
|
|
2645
2933
|
return `${capitalized ? article[0].toUpperCase() + article.slice(1) : article} ${label}`;
|
|
2646
2934
|
}
|
|
2647
2935
|
/** Report a failure the user can act on, and stop. */
|
|
2648
|
-
function fail(message, ...advice) {
|
|
2936
|
+
function fail$1(message, ...advice) {
|
|
2649
2937
|
console.error(`[wawesome] Error: ${message}`);
|
|
2650
2938
|
for (const line of advice) console.error(`[wawesome] ${line}`);
|
|
2651
2939
|
process.exit(1);
|
|
@@ -2825,7 +3113,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
|
|
|
2825
3113
|
try {
|
|
2826
3114
|
await ensureApp(creds, appSlug);
|
|
2827
3115
|
} catch (err) {
|
|
2828
|
-
fail(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
|
|
3116
|
+
fail$1(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
|
|
2829
3117
|
}
|
|
2830
3118
|
for (const { declared, value } of answers) {
|
|
2831
3119
|
if (!value) {
|
|
@@ -2836,7 +3124,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
|
|
|
2836
3124
|
try {
|
|
2837
3125
|
await putEnvVar(creds, appSlug, declared.key, value, declared.secret);
|
|
2838
3126
|
} catch (err) {
|
|
2839
|
-
fail(errorText(err), scaffolded);
|
|
3127
|
+
fail$1(errorText(err), scaffolded);
|
|
2840
3128
|
}
|
|
2841
3129
|
console.log(`[wawesome] ✅ Stored ${declared.key}${declared.secret ? " (secret)" : ""}.`);
|
|
2842
3130
|
}
|
|
@@ -2844,7 +3132,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
|
|
|
2844
3132
|
try {
|
|
2845
3133
|
await enableEgressProvider(creds, appSlug, provider);
|
|
2846
3134
|
} catch (err) {
|
|
2847
|
-
fail(errorText(err), scaffolded);
|
|
3135
|
+
fail$1(errorText(err), scaffolded);
|
|
2848
3136
|
}
|
|
2849
3137
|
console.log(`[wawesome] ✅ Enabled outbound calls to '${provider}'.`);
|
|
2850
3138
|
}
|
|
@@ -2866,7 +3154,7 @@ async function initFromTemplate(templateName, options) {
|
|
|
2866
3154
|
try {
|
|
2867
3155
|
template = await resolveTemplate(gatewayUrl, templateName);
|
|
2868
3156
|
} catch (err) {
|
|
2869
|
-
fail(errorText(err));
|
|
3157
|
+
fail$1(errorText(err));
|
|
2870
3158
|
}
|
|
2871
3159
|
console.log(`[wawesome] Fetching ${template.title} (${template.ref})...`);
|
|
2872
3160
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${template.archive_url}`);
|
|
@@ -2874,7 +3162,7 @@ async function initFromTemplate(templateName, options) {
|
|
|
2874
3162
|
try {
|
|
2875
3163
|
archive = await downloadTemplateArchive(template.archive_url);
|
|
2876
3164
|
} catch (err) {
|
|
2877
|
-
fail(errorText(err));
|
|
3165
|
+
fail$1(errorText(err));
|
|
2878
3166
|
}
|
|
2879
3167
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "wawesome-template-"));
|
|
2880
3168
|
const sweepStaging = () => fs.rmSync(staging, {
|
|
@@ -2896,10 +3184,10 @@ async function initFromTemplate(templateName, options) {
|
|
|
2896
3184
|
});
|
|
2897
3185
|
manifest = readTemplateManifest(staging);
|
|
2898
3186
|
} catch (err) {
|
|
2899
|
-
fail(errorText(err));
|
|
3187
|
+
fail$1(errorText(err));
|
|
2900
3188
|
}
|
|
2901
3189
|
const collisions = collidingPaths(cwd, files);
|
|
2902
|
-
if (collisions.length > 0) fail(`${collisions.join(", ")} already exist${collisions.length === 1 ? "s" : ""} here.`, "Run this in an empty directory, or move those files aside first. Nothing was written.");
|
|
3190
|
+
if (collisions.length > 0) fail$1(`${collisions.join(", ")} already exist${collisions.length === 1 ? "s" : ""} here.`, "Run this in an empty directory, or move those files aside first. Nothing was written.");
|
|
2903
3191
|
tenantSession = await ensureSession(options);
|
|
2904
3192
|
const session = openPromptSession();
|
|
2905
3193
|
try {
|
|
@@ -2915,7 +3203,7 @@ async function initFromTemplate(templateName, options) {
|
|
|
2915
3203
|
});
|
|
2916
3204
|
repinned = alignCliDependency(cwd, CLI_VERSION);
|
|
2917
3205
|
} catch (err) {
|
|
2918
|
-
fail(errorText(err), `Some of '${template.name}' may have been written to ${cwd}.`);
|
|
3206
|
+
fail$1(errorText(err), `Some of '${template.name}' may have been written to ${cwd}.`);
|
|
2919
3207
|
}
|
|
2920
3208
|
console.log(`\n[wawesome] ✅ Scaffolded ${files.length} files from '${template.name}'.`);
|
|
2921
3209
|
if (repinned) console.log(`[wawesome] Using wawesome ${repinned.to} — the template pinned ${repinned.from}.`);
|
|
@@ -3086,13 +3374,13 @@ const RECLAIMED = "⚠ [assets reclaimed]";
|
|
|
3086
3374
|
async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
|
|
3087
3375
|
const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
|
|
3088
3376
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
3089
|
-
const res = await
|
|
3377
|
+
const res = await authorizedFetch(url, {
|
|
3090
3378
|
method: "GET",
|
|
3091
3379
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
3092
3380
|
});
|
|
3093
3381
|
if (!res.ok) {
|
|
3094
3382
|
const errorBody = await res.text();
|
|
3095
|
-
if (res.status === 401) console.error(
|
|
3383
|
+
if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
3096
3384
|
else {
|
|
3097
3385
|
console.error(`[wawesome] Error: Failed to list versions (HTTP ${res.status}).`);
|
|
3098
3386
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
@@ -3199,8 +3487,7 @@ async function switchVersion(targetInput, options) {
|
|
|
3199
3487
|
}
|
|
3200
3488
|
}
|
|
3201
3489
|
console.log(`[wawesome] Promoting v${selectedVersionNum} to ${env}...`);
|
|
3202
|
-
const
|
|
3203
|
-
const deployRes = await fetch(deployUrl, {
|
|
3490
|
+
const deployRes = await authorizedFetch(`${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/deploy`, {
|
|
3204
3491
|
method: "POST",
|
|
3205
3492
|
headers: {
|
|
3206
3493
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -3290,13 +3577,13 @@ async function logsCommand(target, options = {}) {
|
|
|
3290
3577
|
async function fetchInvocationLogBody(gatewayUrl, tenantJwt, invocationId, isVerbose) {
|
|
3291
3578
|
const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
|
|
3292
3579
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
3293
|
-
const res = await
|
|
3580
|
+
const res = await authorizedFetch(url, {
|
|
3294
3581
|
method: "GET",
|
|
3295
3582
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
3296
3583
|
});
|
|
3297
3584
|
if (!res.ok) {
|
|
3298
3585
|
const errorText = await res.text();
|
|
3299
|
-
if (res.status === 401) console.error(
|
|
3586
|
+
if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorText)}`);
|
|
3300
3587
|
else if (res.status === 404) console.error(`[wawesome] Error: Invocation log '${invocationId}' not found or expired (logs are retained for 14 days).`);
|
|
3301
3588
|
else if (res.status === 400) console.error(`[wawesome] Error: Invalid invocation ID '${invocationId}'.`);
|
|
3302
3589
|
else {
|
|
@@ -3326,7 +3613,7 @@ async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride
|
|
|
3326
3613
|
const res = await fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, `?${queryParams.toString()}`, isVerbose);
|
|
3327
3614
|
if (!res.ok) {
|
|
3328
3615
|
const errorText = await res.text();
|
|
3329
|
-
if (res.status === 401) console.error(
|
|
3616
|
+
if (res.status === 401 || res.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorText)}`);
|
|
3330
3617
|
else if (res.status === 404) console.error(`[wawesome] Error: Function '${funcName}' not found.`);
|
|
3331
3618
|
else {
|
|
3332
3619
|
console.error(`[wawesome] Error: Failed to list invocations (HTTP ${res.status}).`);
|
|
@@ -3366,7 +3653,7 @@ async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride
|
|
|
3366
3653
|
async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, queryString, isVerbose) {
|
|
3367
3654
|
const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/invocations${queryString}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
|
|
3368
3655
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
3369
|
-
let res = await
|
|
3656
|
+
let res = await authorizedFetch(url, {
|
|
3370
3657
|
method: "GET",
|
|
3371
3658
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
3372
3659
|
});
|
|
@@ -3374,7 +3661,7 @@ async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug
|
|
|
3374
3661
|
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
|
|
3375
3662
|
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
|
|
3376
3663
|
try {
|
|
3377
|
-
const fallbackRes = await
|
|
3664
|
+
const fallbackRes = await authorizedFetch(fallbackUrl, {
|
|
3378
3665
|
method: "GET",
|
|
3379
3666
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
3380
3667
|
});
|
|
@@ -3486,7 +3773,7 @@ async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbos
|
|
|
3486
3773
|
while (true) {
|
|
3487
3774
|
let res = null;
|
|
3488
3775
|
try {
|
|
3489
|
-
res = await
|
|
3776
|
+
res = await authorizedFetch(url, {
|
|
3490
3777
|
method: "GET",
|
|
3491
3778
|
headers: {
|
|
3492
3779
|
Authorization: `Bearer ${tenantJwt}`,
|
|
@@ -3508,8 +3795,8 @@ async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbos
|
|
|
3508
3795
|
if (isVerbose) console.error(`[wawesome:verbose] ${String(err)}`);
|
|
3509
3796
|
process.exit(1);
|
|
3510
3797
|
}
|
|
3511
|
-
if (res.status === 401) {
|
|
3512
|
-
console.error(
|
|
3798
|
+
if (res.status === 401 || res.status === 403) {
|
|
3799
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, await res.text().catch(() => ""))}`);
|
|
3513
3800
|
process.exit(1);
|
|
3514
3801
|
}
|
|
3515
3802
|
if (res.status === 404) {
|
|
@@ -3618,18 +3905,18 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
|
|
|
3618
3905
|
let res = null;
|
|
3619
3906
|
try {
|
|
3620
3907
|
if (scopedUrl) {
|
|
3621
|
-
res = await
|
|
3908
|
+
res = await authorizedFetch(scopedUrl, {
|
|
3622
3909
|
headers,
|
|
3623
3910
|
signal: controller.signal
|
|
3624
3911
|
});
|
|
3625
3912
|
if (res.status === 404) {
|
|
3626
3913
|
if (isVerbose) console.error(`[wawesome:verbose] 404 on app-scoped route, retrying unscoped.`);
|
|
3627
|
-
res = await
|
|
3914
|
+
res = await authorizedFetch(unscopedUrl, {
|
|
3628
3915
|
headers,
|
|
3629
3916
|
signal: controller.signal
|
|
3630
3917
|
});
|
|
3631
3918
|
}
|
|
3632
|
-
} else res = await
|
|
3919
|
+
} else res = await authorizedFetch(unscopedUrl, {
|
|
3633
3920
|
headers,
|
|
3634
3921
|
signal: controller.signal
|
|
3635
3922
|
});
|
|
@@ -3646,8 +3933,8 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
|
|
|
3646
3933
|
console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
|
|
3647
3934
|
process.exit(1);
|
|
3648
3935
|
}
|
|
3649
|
-
if (res.status === 401) {
|
|
3650
|
-
console.error(
|
|
3936
|
+
if (res.status === 401 || res.status === 403) {
|
|
3937
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, await res.text().catch(() => ""))}`);
|
|
3651
3938
|
process.exit(1);
|
|
3652
3939
|
}
|
|
3653
3940
|
if (res.status === 404) {
|
|
@@ -3733,7 +4020,7 @@ function printOutcome(meta) {
|
|
|
3733
4020
|
async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, payload, isVerbose) {
|
|
3734
4021
|
const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
|
|
3735
4022
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${url}`);
|
|
3736
|
-
let res = await
|
|
4023
|
+
let res = await authorizedFetch(url, {
|
|
3737
4024
|
method: "POST",
|
|
3738
4025
|
headers: {
|
|
3739
4026
|
Authorization: `Bearer ${tenantJwt}`,
|
|
@@ -3745,7 +4032,7 @@ async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride,
|
|
|
3745
4032
|
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
|
|
3746
4033
|
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
|
|
3747
4034
|
try {
|
|
3748
|
-
const fallbackRes = await
|
|
4035
|
+
const fallbackRes = await authorizedFetch(fallbackUrl, {
|
|
3749
4036
|
method: "POST",
|
|
3750
4037
|
headers: {
|
|
3751
4038
|
Authorization: `Bearer ${tenantJwt}`,
|
|
@@ -3761,7 +4048,7 @@ async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride,
|
|
|
3761
4048
|
async function fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose) {
|
|
3762
4049
|
const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}`;
|
|
3763
4050
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
3764
|
-
const res = await
|
|
4051
|
+
const res = await authorizedFetch(url, {
|
|
3765
4052
|
method: "GET",
|
|
3766
4053
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
3767
4054
|
});
|
|
@@ -3771,7 +4058,7 @@ async function fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVe
|
|
|
3771
4058
|
async function fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose) {
|
|
3772
4059
|
const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
|
|
3773
4060
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
3774
|
-
const res = await
|
|
4061
|
+
const res = await authorizedFetch(url, {
|
|
3775
4062
|
method: "GET",
|
|
3776
4063
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
3777
4064
|
});
|
|
@@ -3795,7 +4082,7 @@ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollInt
|
|
|
3795
4082
|
if (controller.signal.aborted) return;
|
|
3796
4083
|
let res = null;
|
|
3797
4084
|
try {
|
|
3798
|
-
res = await
|
|
4085
|
+
res = await authorizedFetch(streamUrl, {
|
|
3799
4086
|
method: "GET",
|
|
3800
4087
|
headers: {
|
|
3801
4088
|
Authorization: `Bearer ${tenantJwt}`,
|
|
@@ -3834,8 +4121,8 @@ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollInt
|
|
|
3834
4121
|
console.error(`[wawesome] ✖ Run ${meta.status}${meta.failure_reason ? `: ${meta.failure_reason}` : ""}`);
|
|
3835
4122
|
return;
|
|
3836
4123
|
}
|
|
3837
|
-
if (res.status === 401) {
|
|
3838
|
-
console.error(
|
|
4124
|
+
if (res.status === 401 || res.status === 403) {
|
|
4125
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, await res.text().catch(() => ""))}`);
|
|
3839
4126
|
process.exit(1);
|
|
3840
4127
|
}
|
|
3841
4128
|
if (!res.ok && res.status >= 500) {
|
|
@@ -3920,8 +4207,8 @@ async function invokeCommand(target, options = {}) {
|
|
|
3920
4207
|
const res = await triggerRun(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, options.app, triggerPayload, isVerbose);
|
|
3921
4208
|
if (!res.ok) {
|
|
3922
4209
|
const errorBody = await res.text();
|
|
3923
|
-
if (res.status === 401) {
|
|
3924
|
-
console.error(
|
|
4210
|
+
if (res.status === 401 || res.status === 403) {
|
|
4211
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
3925
4212
|
process.exit(1);
|
|
3926
4213
|
}
|
|
3927
4214
|
if (res.status === 404) {
|
|
@@ -3943,7 +4230,7 @@ async function invokeCommand(target, options = {}) {
|
|
|
3943
4230
|
}
|
|
3944
4231
|
//#endregion
|
|
3945
4232
|
//#region src/workspace.ts
|
|
3946
|
-
function requireCredentials$
|
|
4233
|
+
function requireCredentials$2() {
|
|
3947
4234
|
const creds = readCredentials();
|
|
3948
4235
|
if (!creds) {
|
|
3949
4236
|
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
@@ -3956,7 +4243,7 @@ function requireCredentials$1() {
|
|
|
3956
4243
|
* change — the rename is offered here rather than discovered by attempting it.
|
|
3957
4244
|
*/
|
|
3958
4245
|
async function showWorkspace(options) {
|
|
3959
|
-
const creds = requireCredentials$
|
|
4246
|
+
const creds = requireCredentials$2();
|
|
3960
4247
|
let tenant;
|
|
3961
4248
|
try {
|
|
3962
4249
|
tenant = await fetchTenantDetails(creds);
|
|
@@ -3991,7 +4278,7 @@ async function showWorkspace(options) {
|
|
|
3991
4278
|
* gateway knows something this command could not.
|
|
3992
4279
|
*/
|
|
3993
4280
|
async function renameWorkspace(slug, options) {
|
|
3994
|
-
const creds = requireCredentials$
|
|
4281
|
+
const creds = requireCredentials$2();
|
|
3995
4282
|
if (!slug || !slug.trim()) {
|
|
3996
4283
|
console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
|
|
3997
4284
|
process.exit(1);
|
|
@@ -4035,6 +4322,267 @@ async function workspaceCommand(action, target, options) {
|
|
|
4035
4322
|
process.exit(1);
|
|
4036
4323
|
}
|
|
4037
4324
|
//#endregion
|
|
4325
|
+
//#region src/deploy-credentials.ts
|
|
4326
|
+
const SECRET_SHOWN_ONCE = "Copy it now. This is the only time it will be shown. If you lose it, revoke this credential and mint another.";
|
|
4327
|
+
function requireCredentials$1() {
|
|
4328
|
+
const creds = readCredentials();
|
|
4329
|
+
if (!creds) {
|
|
4330
|
+
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
4331
|
+
process.exit(1);
|
|
4332
|
+
}
|
|
4333
|
+
return creds;
|
|
4334
|
+
}
|
|
4335
|
+
function fail(message, ...rest) {
|
|
4336
|
+
console.error(`[wawesome] Error: ${message}`);
|
|
4337
|
+
for (const line of rest) console.error(`[wawesome] ${line}`);
|
|
4338
|
+
process.exit(1);
|
|
4339
|
+
}
|
|
4340
|
+
/** A repeated flag and a comma-separated one, read as the same list. */
|
|
4341
|
+
function asList(raw) {
|
|
4342
|
+
if (raw === void 0) return void 0;
|
|
4343
|
+
const words = (Array.isArray(raw) ? raw : [raw]).flatMap((word) => String(word).split(",")).map((word) => word.trim()).filter((word) => word.length > 0);
|
|
4344
|
+
return words.length > 0 ? words : void 0;
|
|
4345
|
+
}
|
|
4346
|
+
function parseExpiry(raw) {
|
|
4347
|
+
if (raw === void 0) return void 0;
|
|
4348
|
+
const asked = String(raw).trim().toLowerCase();
|
|
4349
|
+
if (asked === "never") return "never";
|
|
4350
|
+
const days = /^(\d+)\s*d(ays?)?$/.exec(asked) ?? /^(\d+)$/.exec(asked);
|
|
4351
|
+
if (!days) fail(`'${raw}' is not an expiry. Name a number of days (--expires 30) or 'never' (--expires never).`);
|
|
4352
|
+
return { days: Number(days[1]) };
|
|
4353
|
+
}
|
|
4354
|
+
/**
|
|
4355
|
+
* What a target may be repeated back as.
|
|
4356
|
+
*
|
|
4357
|
+
* Somebody revoking a leak pastes the whole secret as often as the prefix, and
|
|
4358
|
+
* a refusal that echoed it would write it into a shell history and a CI log —
|
|
4359
|
+
* for the one feature built on the secret being shown exactly once.
|
|
4360
|
+
*/
|
|
4361
|
+
function asShown(target) {
|
|
4362
|
+
if (!target.startsWith("wawe_")) return target;
|
|
4363
|
+
const shown = credentialPrefix(target);
|
|
4364
|
+
return shown === target ? target : `${shown}…`;
|
|
4365
|
+
}
|
|
4366
|
+
/** The Apps a credential names, or `null` where it names none and reaches all. */
|
|
4367
|
+
function namedApps(apps) {
|
|
4368
|
+
return apps && apps.scope === "only" ? apps.apps.join(", ") : null;
|
|
4369
|
+
}
|
|
4370
|
+
/** `2026-11-30 12:00 UTC`. Minutes, because nothing here is read to the second. */
|
|
4371
|
+
function asUtc(iso) {
|
|
4372
|
+
if (!iso) return "never";
|
|
4373
|
+
const at = new Date(iso);
|
|
4374
|
+
if (Number.isNaN(at.getTime())) return iso;
|
|
4375
|
+
return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
4376
|
+
}
|
|
4377
|
+
function isExpired(credential, now) {
|
|
4378
|
+
if (!credential.expires_at) return false;
|
|
4379
|
+
const at = new Date(credential.expires_at).getTime();
|
|
4380
|
+
return !Number.isNaN(at) && at <= now;
|
|
4381
|
+
}
|
|
4382
|
+
function statusOf(credential, now) {
|
|
4383
|
+
if (credential.revoked_at) return "revoked";
|
|
4384
|
+
if (isExpired(credential, now)) return "expired";
|
|
4385
|
+
return "active";
|
|
4386
|
+
}
|
|
4387
|
+
async function refusal(res, fallback) {
|
|
4388
|
+
if (res.status === 401 || res.status === 403) return new GatewayError(unauthorizedMessage(res.status, await res.text().catch(() => "")), res.status);
|
|
4389
|
+
return asGatewayError(res, fallback);
|
|
4390
|
+
}
|
|
4391
|
+
async function mintCredential(creds, payload) {
|
|
4392
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, {
|
|
4393
|
+
method: "POST",
|
|
4394
|
+
headers: {
|
|
4395
|
+
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
4396
|
+
"Content-Type": "application/json"
|
|
4397
|
+
},
|
|
4398
|
+
body: JSON.stringify(payload)
|
|
4399
|
+
});
|
|
4400
|
+
if (!res.ok) throw await refusal(res, `Failed to mint the credential (HTTP ${res.status}).`);
|
|
4401
|
+
return await res.json();
|
|
4402
|
+
}
|
|
4403
|
+
async function fetchCredentials(creds) {
|
|
4404
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
4405
|
+
if (!res.ok) throw await refusal(res, `Failed to read the credentials (HTTP ${res.status}).`);
|
|
4406
|
+
return (await res.json()).credentials ?? [];
|
|
4407
|
+
}
|
|
4408
|
+
async function revokeCredential(creds, credentialId) {
|
|
4409
|
+
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant/credentials/${encodeURIComponent(credentialId)}/revoke`, {
|
|
4410
|
+
method: "POST",
|
|
4411
|
+
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
4412
|
+
});
|
|
4413
|
+
if (!res.ok) throw await refusal(res, `Failed to revoke the credential (HTTP ${res.status}).`);
|
|
4414
|
+
return await res.json();
|
|
4415
|
+
}
|
|
4416
|
+
/**
|
|
4417
|
+
* Mint one credential.
|
|
4418
|
+
*
|
|
4419
|
+
* The secret goes to stdout on a line of its own and everything a person reads
|
|
4420
|
+
* goes to stderr, so `wawesome credentials mint ci > secret` writes the secret
|
|
4421
|
+
* and not one character besides.
|
|
4422
|
+
*/
|
|
4423
|
+
async function mintCredentialCommand(name, options) {
|
|
4424
|
+
if (!name || !name.trim()) fail("No name given. Usage: wawesome credentials mint <name> [--capability deploy] [--app <app>] [--expires 90|never]", "The name is what tells the CI one from the agent one a year from now.");
|
|
4425
|
+
const creds = requireCredentials$1();
|
|
4426
|
+
const payload = { name: name.trim() };
|
|
4427
|
+
const capabilities = asList(options.capability);
|
|
4428
|
+
if (capabilities) payload.capabilities = capabilities;
|
|
4429
|
+
const apps = asList(options.app);
|
|
4430
|
+
if (apps) payload.apps = apps;
|
|
4431
|
+
const expiry = parseExpiry(options.expires);
|
|
4432
|
+
if (expiry !== void 0) payload.expiry = expiry;
|
|
4433
|
+
if (options.verbose) {
|
|
4434
|
+
console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials`);
|
|
4435
|
+
console.error(`[wawesome:verbose] Payload: ${JSON.stringify({ ...payload })}`);
|
|
4436
|
+
}
|
|
4437
|
+
let credential;
|
|
4438
|
+
try {
|
|
4439
|
+
credential = await mintCredential(creds, payload);
|
|
4440
|
+
} catch (err) {
|
|
4441
|
+
fail(errorText(err));
|
|
4442
|
+
}
|
|
4443
|
+
console.error(`\n[wawesome] ✔ Minted deploy credential '${credential.name}'.\n`);
|
|
4444
|
+
console.error(` Prefix: ${credential.prefix}`);
|
|
4445
|
+
console.error(` Capabilities: ${credential.capabilities.join(", ")}`);
|
|
4446
|
+
console.error(` Apps: ${namedApps(credential.apps) ?? "every App in the workspace"}`);
|
|
4447
|
+
console.error(` Expires: ${asUtc(credential.expires_at)}`);
|
|
4448
|
+
console.error(`\n ${SECRET_SHOWN_ONCE}\n`);
|
|
4449
|
+
console.log(credential.secret);
|
|
4450
|
+
console.error("");
|
|
4451
|
+
}
|
|
4452
|
+
async function listCredentialsCommand(options) {
|
|
4453
|
+
const creds = requireCredentials$1();
|
|
4454
|
+
if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
|
|
4455
|
+
let credentials;
|
|
4456
|
+
try {
|
|
4457
|
+
credentials = await fetchCredentials(creds);
|
|
4458
|
+
} catch (err) {
|
|
4459
|
+
fail(errorText(err));
|
|
4460
|
+
}
|
|
4461
|
+
if (credentials.length === 0) {
|
|
4462
|
+
console.log("\n[wawesome] This workspace has no deploy credentials.");
|
|
4463
|
+
console.log("[wawesome] Mint one: \x1B[36mwawesome credentials mint <name>\x1B[0m\n");
|
|
4464
|
+
return;
|
|
4465
|
+
}
|
|
4466
|
+
console.log("\n🔑 \x1B[1mDeploy credentials\x1B[0m\n");
|
|
4467
|
+
for (const line of formatCredentials(credentials, Date.now())) console.log(line);
|
|
4468
|
+
console.log("");
|
|
4469
|
+
}
|
|
4470
|
+
/** Never the secret: no read after minting carries one. */
|
|
4471
|
+
function formatCredentials(credentials, now) {
|
|
4472
|
+
const rows = credentials.map((credential) => ({
|
|
4473
|
+
name: credential.name,
|
|
4474
|
+
prefix: credential.prefix,
|
|
4475
|
+
capabilities: credential.capabilities.join(", "),
|
|
4476
|
+
apps: namedApps(credential.apps) ?? "every App",
|
|
4477
|
+
lastUsed: asUtc(credential.last_used_at),
|
|
4478
|
+
expires: asUtc(credential.expires_at),
|
|
4479
|
+
status: statusOf(credential, now)
|
|
4480
|
+
}));
|
|
4481
|
+
const columns = [
|
|
4482
|
+
{
|
|
4483
|
+
header: "NAME",
|
|
4484
|
+
of: (row) => row.name
|
|
4485
|
+
},
|
|
4486
|
+
{
|
|
4487
|
+
header: "PREFIX",
|
|
4488
|
+
of: (row) => row.prefix
|
|
4489
|
+
},
|
|
4490
|
+
{
|
|
4491
|
+
header: "CAPABILITIES",
|
|
4492
|
+
of: (row) => row.capabilities
|
|
4493
|
+
},
|
|
4494
|
+
{
|
|
4495
|
+
header: "APPS",
|
|
4496
|
+
of: (row) => row.apps
|
|
4497
|
+
},
|
|
4498
|
+
{
|
|
4499
|
+
header: "LAST USED",
|
|
4500
|
+
of: (row) => row.lastUsed
|
|
4501
|
+
},
|
|
4502
|
+
{
|
|
4503
|
+
header: "EXPIRES",
|
|
4504
|
+
of: (row) => row.expires
|
|
4505
|
+
},
|
|
4506
|
+
{
|
|
4507
|
+
header: "STATUS",
|
|
4508
|
+
of: (row) => row.status
|
|
4509
|
+
}
|
|
4510
|
+
];
|
|
4511
|
+
const widths = columns.map((column) => Math.max(column.header.length, ...rows.map((row) => column.of(row).length)));
|
|
4512
|
+
const line = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i])).join(" | ")}`.trimEnd();
|
|
4513
|
+
return [
|
|
4514
|
+
line(columns.map((column) => column.header)),
|
|
4515
|
+
` ${widths.map((width) => "-".repeat(width)).join("-|-")}`,
|
|
4516
|
+
...rows.map((row) => line(columns.map((column) => column.of(row))))
|
|
4517
|
+
];
|
|
4518
|
+
}
|
|
4519
|
+
/**
|
|
4520
|
+
* Which credential a user meant by a name or a prefix.
|
|
4521
|
+
*
|
|
4522
|
+
* A partial prefix matches, because the point of the displayable prefix is
|
|
4523
|
+
* matching one against a CI settings page by eye — and a partial that names
|
|
4524
|
+
* more than one is refused rather than guessed at, since the guess would be a
|
|
4525
|
+
* revocation.
|
|
4526
|
+
*/
|
|
4527
|
+
function matchCredentials(credentials, target) {
|
|
4528
|
+
const typed = target.trim();
|
|
4529
|
+
const named = credentials.filter((credential) => credential.name === typed);
|
|
4530
|
+
if (named.length > 0) return named;
|
|
4531
|
+
const wanted = typed.startsWith("wawe_") ? credentialPrefix(typed) : typed;
|
|
4532
|
+
return credentials.filter((credential) => credential.prefix.startsWith(wanted));
|
|
4533
|
+
}
|
|
4534
|
+
async function revokeCredentialCommand(target, options) {
|
|
4535
|
+
if (!target || !target.trim()) fail("No credential given. Usage: wawesome credentials revoke <name-or-prefix>");
|
|
4536
|
+
const creds = requireCredentials$1();
|
|
4537
|
+
if (options.verbose) console.error(`[wawesome:verbose] GET ${creds.gateway_url}/v1/tenant/credentials`);
|
|
4538
|
+
let credentials;
|
|
4539
|
+
try {
|
|
4540
|
+
credentials = await fetchCredentials(creds);
|
|
4541
|
+
} catch (err) {
|
|
4542
|
+
fail(errorText(err));
|
|
4543
|
+
}
|
|
4544
|
+
const matched = matchCredentials(credentials, target);
|
|
4545
|
+
if (matched.length === 0) fail(`No deploy credential named or prefixed '${asShown(target)}' in this workspace.`, "Run 'wawesome credentials list' to see what this workspace has.");
|
|
4546
|
+
if (matched.length > 1) fail(`'${asShown(target)}' names more than one credential:`, ...matched.map((credential) => ` ${credential.name} (${credential.prefix})`), "Name one of them, or give more of its prefix.");
|
|
4547
|
+
const credential = matched[0];
|
|
4548
|
+
if (credential.revoked_at) {
|
|
4549
|
+
console.log(`[wawesome] '${credential.name}' (${credential.prefix}) was already revoked at ${asUtc(credential.revoked_at)}. Nothing to do.`);
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
if (!options.yes) {
|
|
4553
|
+
if (!isInteractive()) fail(`Revoking asks for confirmation, and there is nobody to ask.`, `Pass --yes to revoke '${credential.name}' (${credential.prefix}) without being asked.`);
|
|
4554
|
+
let confirmed;
|
|
4555
|
+
try {
|
|
4556
|
+
confirmed = await confirm({
|
|
4557
|
+
message: `Revoke '${credential.name}' (${credential.prefix})? Nothing un-revokes it.`,
|
|
4558
|
+
default: false
|
|
4559
|
+
});
|
|
4560
|
+
} catch {
|
|
4561
|
+
confirmed = false;
|
|
4562
|
+
}
|
|
4563
|
+
if (!confirmed) {
|
|
4564
|
+
console.log(`[wawesome] Cancelled. '${credential.name}' still works.`);
|
|
4565
|
+
return;
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
if (options.verbose) console.error(`[wawesome:verbose] POST ${creds.gateway_url}/v1/tenant/credentials/${credential.id}/revoke`);
|
|
4569
|
+
let revoked;
|
|
4570
|
+
try {
|
|
4571
|
+
revoked = await revokeCredential(creds, credential.id);
|
|
4572
|
+
} catch (err) {
|
|
4573
|
+
if (err instanceof GatewayError && err.status === 404) fail(`'${credential.name}' (${credential.prefix}) no longer exists in this workspace.`);
|
|
4574
|
+
fail(errorText(err));
|
|
4575
|
+
}
|
|
4576
|
+
console.log(`\n[wawesome] ✔ Revoked '${revoked.name}' (${revoked.prefix}).`);
|
|
4577
|
+
console.log("[wawesome] Anything still presenting it is refused from its very next request.\n");
|
|
4578
|
+
}
|
|
4579
|
+
async function credentialsCommand(action, target, options) {
|
|
4580
|
+
if (!action || action === "list" || action === "ls") return listCredentialsCommand(options);
|
|
4581
|
+
if (action === "mint") return mintCredentialCommand(target, options);
|
|
4582
|
+
if (action === "revoke") return revokeCredentialCommand(target, options);
|
|
4583
|
+
fail(`Unknown credentials action '${action}'.`, "Usage: wawesome credentials [list|mint <name>|revoke <name-or-prefix>]");
|
|
4584
|
+
}
|
|
4585
|
+
//#endregion
|
|
4038
4586
|
//#region src/cron.ts
|
|
4039
4587
|
/** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
|
|
4040
4588
|
function asUtcWithSeconds(iso) {
|
|
@@ -4145,7 +4693,7 @@ function formatRunHistoryTable(runs) {
|
|
|
4145
4693
|
async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug, isVerbose) {
|
|
4146
4694
|
const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
|
|
4147
4695
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
|
|
4148
|
-
let res = await
|
|
4696
|
+
let res = await authorizedFetch(scopedUrl, {
|
|
4149
4697
|
method: "GET",
|
|
4150
4698
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4151
4699
|
});
|
|
@@ -4153,7 +4701,7 @@ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug,
|
|
|
4153
4701
|
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
|
|
4154
4702
|
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
|
|
4155
4703
|
try {
|
|
4156
|
-
const fallbackRes = await
|
|
4704
|
+
const fallbackRes = await authorizedFetch(fallbackUrl, {
|
|
4157
4705
|
method: "GET",
|
|
4158
4706
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4159
4707
|
});
|
|
@@ -4167,7 +4715,7 @@ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug,
|
|
|
4167
4715
|
async function fetchAppFunctions(gatewayUrl, tenantJwt, appSlug, isVerbose) {
|
|
4168
4716
|
const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions`;
|
|
4169
4717
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
4170
|
-
const res = await
|
|
4718
|
+
const res = await authorizedFetch(url, {
|
|
4171
4719
|
method: "GET",
|
|
4172
4720
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4173
4721
|
});
|
|
@@ -4178,7 +4726,7 @@ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, a
|
|
|
4178
4726
|
const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
|
|
4179
4727
|
const body = reason?.trim() ? JSON.stringify({ reason: reason.trim() }) : void 0;
|
|
4180
4728
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
|
|
4181
|
-
let res = await
|
|
4729
|
+
let res = await authorizedFetch(scopedUrl, {
|
|
4182
4730
|
method: "POST",
|
|
4183
4731
|
headers: {
|
|
4184
4732
|
Authorization: `Bearer ${tenantJwt}`,
|
|
@@ -4190,7 +4738,7 @@ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, a
|
|
|
4190
4738
|
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
|
|
4191
4739
|
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
|
|
4192
4740
|
try {
|
|
4193
|
-
const fallbackRes = await
|
|
4741
|
+
const fallbackRes = await authorizedFetch(fallbackUrl, {
|
|
4194
4742
|
method: "POST",
|
|
4195
4743
|
headers: {
|
|
4196
4744
|
Authorization: `Bearer ${tenantJwt}`,
|
|
@@ -4206,7 +4754,7 @@ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, a
|
|
|
4206
4754
|
async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, isVerbose) {
|
|
4207
4755
|
const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
|
|
4208
4756
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
|
|
4209
|
-
let res = await
|
|
4757
|
+
let res = await authorizedFetch(scopedUrl, {
|
|
4210
4758
|
method: "POST",
|
|
4211
4759
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4212
4760
|
});
|
|
@@ -4214,7 +4762,7 @@ async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName,
|
|
|
4214
4762
|
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
|
|
4215
4763
|
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
|
|
4216
4764
|
try {
|
|
4217
|
-
const fallbackRes = await
|
|
4765
|
+
const fallbackRes = await authorizedFetch(fallbackUrl, {
|
|
4218
4766
|
method: "POST",
|
|
4219
4767
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4220
4768
|
});
|
|
@@ -4231,7 +4779,7 @@ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug,
|
|
|
4231
4779
|
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
4232
4780
|
const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs${qs}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
|
|
4233
4781
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
|
|
4234
|
-
let res = await
|
|
4782
|
+
let res = await authorizedFetch(scopedUrl, {
|
|
4235
4783
|
method: "GET",
|
|
4236
4784
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4237
4785
|
});
|
|
@@ -4239,7 +4787,7 @@ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug,
|
|
|
4239
4787
|
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
|
|
4240
4788
|
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
|
|
4241
4789
|
try {
|
|
4242
|
-
const fallbackRes = await
|
|
4790
|
+
const fallbackRes = await authorizedFetch(fallbackUrl, {
|
|
4243
4791
|
method: "GET",
|
|
4244
4792
|
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
4245
4793
|
});
|
|
@@ -4339,8 +4887,8 @@ async function pauseScheduleCommand(target, subtarget, options = {}) {
|
|
|
4339
4887
|
const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
|
|
4340
4888
|
if (!res.ok) {
|
|
4341
4889
|
const errorBody = await res.text();
|
|
4342
|
-
if (res.status === 401) {
|
|
4343
|
-
console.error(
|
|
4890
|
+
if (res.status === 401 || res.status === 403) {
|
|
4891
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
4344
4892
|
process.exit(1);
|
|
4345
4893
|
}
|
|
4346
4894
|
if (res.status === 404) {
|
|
@@ -4396,8 +4944,8 @@ async function resumeScheduleCommand(target, subtarget, options = {}) {
|
|
|
4396
4944
|
const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
|
|
4397
4945
|
if (!res.ok) {
|
|
4398
4946
|
const errorBody = await res.text();
|
|
4399
|
-
if (res.status === 401) {
|
|
4400
|
-
console.error(
|
|
4947
|
+
if (res.status === 401 || res.status === 403) {
|
|
4948
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
4401
4949
|
process.exit(1);
|
|
4402
4950
|
}
|
|
4403
4951
|
if (res.status === 404) {
|
|
@@ -4435,8 +4983,8 @@ async function runHistoryCommand(target, options = {}) {
|
|
|
4435
4983
|
}, isVerbose);
|
|
4436
4984
|
if (!res.ok) {
|
|
4437
4985
|
const errorBody = await res.text();
|
|
4438
|
-
if (res.status === 401) {
|
|
4439
|
-
console.error(
|
|
4986
|
+
if (res.status === 401 || res.status === 403) {
|
|
4987
|
+
console.error(`[wawesome] Error: ${unauthorizedMessage(res.status, errorBody)}`);
|
|
4440
4988
|
process.exit(1);
|
|
4441
4989
|
}
|
|
4442
4990
|
if (res.status === 404) {
|
|
@@ -4486,11 +5034,33 @@ cli.command("env [action] [key] [value]", "Manage environment variables (set, li
|
|
|
4486
5034
|
cli.command("env set <key> <value>", "Set or overwrite an environment variable on the current app").option("-s, --secret", "Flag variable as secret (write-only)").option("-v, --verbose", "Enable verbose debug output").action((key, value, options) => setEnvVar(key, value, options));
|
|
4487
5035
|
cli.command("env list", "List environment variables for the current app").alias("env ls").option("-v, --verbose", "Enable verbose debug output").action((options) => listEnvVars(options));
|
|
4488
5036
|
cli.command("env rm <key>", "Delete an environment variable from the current app").alias("env remove").alias("env delete").alias("env unset").option("-v, --verbose", "Enable verbose debug output").action((key, options) => removeEnvVar(key, options));
|
|
4489
|
-
cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--provider <name>", "OAuth provider (default: github)").option("--workspace <name>", "Name for the workspace, when signing up without a terminal to prompt").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
|
|
5037
|
+
cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--provider <name>", "OAuth provider (default: github)").option("--workspace <name>", "Name for the workspace, when signing up without a terminal to prompt").option("--accept-terms", "Accept the terms of service and privacy policy, when signing up without a terminal to prompt").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
|
|
4490
5038
|
cli.command("logout", "Clear stored authentication credentials").action(() => logout());
|
|
4491
5039
|
cli.command("whoami", "Show current login session info").action(() => whoami());
|
|
4492
5040
|
cli.command("workspace [action] [name]", "Show the workspace, or rename its public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n rename <name> Change the public address, while nothing live depends on it").example("wawesome workspace").example("wawesome workspace rename northwind").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
|
|
4493
5041
|
cli.command("templates [action]", "Browse the template catalog").usage("templates [action]\n\nActions:\n list (ls) Show every available template (default)").example("wawesome templates").example("wawesome templates list").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("-v, --verbose", "Enable verbose debug output").action((action, options) => templatesCommand(action, options));
|
|
5042
|
+
cli.command("credentials [action] [target]", "Mint, list and revoke deploy credentials").alias("credential").usage(`credentials <action> [options]
|
|
5043
|
+
|
|
5044
|
+
A deploy credential is what a CI pipeline or an agent authenticates with, where
|
|
5045
|
+
there is nobody at a keyboard to log in. Minting is the only time the secret is
|
|
5046
|
+
shown. It is printed on its own line, so a pipe catches it and nothing else.
|
|
5047
|
+
|
|
5048
|
+
Actions:
|
|
5049
|
+
list (ls) Show every credential: name, prefix, capabilities, Apps, last use, expiry (default)
|
|
5050
|
+
mint <name> Mint one, printing the secret exactly once
|
|
5051
|
+
revoke <name-or-prefix> Revoke one, immediately and for good
|
|
5052
|
+
|
|
5053
|
+
Examples:
|
|
5054
|
+
wawesome credentials
|
|
5055
|
+
wawesome credentials mint ci-pipeline
|
|
5056
|
+
wawesome credentials mint agent -c logs:read --expires never
|
|
5057
|
+
wawesome credentials mint prod-deploy -c deploy -a prod --expires 30
|
|
5058
|
+
wawesome credentials mint ci-pipeline > /tmp/secret
|
|
5059
|
+
wawesome credentials revoke ci-pipeline
|
|
5060
|
+
wawesome credentials revoke wawe_ab3k9x --yes`).option("-c, --capability <capability>", "Capability to grant: deploy, logs:read (repeatable or comma-separated, default: deploy)").option("-a, --app <app>", "Restrict the credential to this App (repeatable or comma-separated, default: every App)").option("--expires <days|never>", "Days until the credential expires, or 'never' (default: 90)").option("-y, --yes", "Skip the revoke confirmation").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => credentialsCommand(action, target, options));
|
|
5061
|
+
cli.command("credentials mint <name>", "Mint a deploy credential, printing its secret exactly once").option("-c, --capability <capability>", "Capability to grant: deploy, logs:read (repeatable or comma-separated)").option("-a, --app <app>", "Restrict the credential to this App (repeatable or comma-separated)").option("--expires <days|never>", "Days until the credential expires, or 'never' (default: 90)").option("-v, --verbose", "Enable verbose debug output").action((name, options) => mintCredentialCommand(name, options));
|
|
5062
|
+
cli.command("credentials list", "List deploy credentials — never their secrets").alias("credentials ls").option("-v, --verbose", "Enable verbose debug output").action((options) => listCredentialsCommand(options));
|
|
5063
|
+
cli.command("credentials revoke <name-or-prefix>", "Revoke a deploy credential, immediately and for good").option("-y, --yes", "Revoke without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => revokeCredentialCommand(target, options));
|
|
4494
5064
|
cli.command("init", "Scaffold a new function project in the current directory").usage("init [options]\n\nWith --template, the project is fetched from the template catalog, wired up\nfrom what the template declares it needs, and deployed. Run 'wawesome templates'\nto see what is available.").example("wawesome init").example("wawesome init --template stripe-webhook").option("-t, --template <name>", "Scaffold from a catalog template and deploy it").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--no-install", "Skip installing dependencies after scaffolding").option("--root", "Generate a root function router template").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
|
|
4495
5065
|
cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
|
|
4496
5066
|
|