wawesome 0.18.1 → 0.20.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/dist/{guest-parity-Df4rlLDW.mjs → guest-parity-W61MH7Fb.mjs} +131 -8
- package/dist/guest-parity.d.mts +2 -0
- package/dist/guest-parity.mjs +1 -1
- package/dist/guest-surface-D5X-IMAN.mjs +208 -0
- package/dist/index.mjs +741 -346
- package/dist/vite.mjs +1 -1
- package/dist/vitest-setup.mjs +1 -1
- package/package.json +1 -1
- package/dist/guest-surface-CXON0L5V.mjs +0 -107
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as unsupportedGlobals, n as methodRemedy, r as polyfilledGlobals, t as declaredMethods } from "./guest-surface-
|
|
1
|
+
import { a as unsupportedGlobals, n as methodRemedy, r as polyfilledGlobals, t as declaredMethods } from "./guest-surface-D5X-IMAN.mjs";
|
|
2
2
|
import cac from "cac";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
@@ -6,10 +6,10 @@ import { build } from "esbuild";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import { parse } from "acorn";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
+
import crypto from "node:crypto";
|
|
9
10
|
import http from "node:http";
|
|
10
|
-
import readline from "node:readline";
|
|
11
11
|
import { Readable } from "node:stream";
|
|
12
|
-
import
|
|
12
|
+
import readline from "node:readline";
|
|
13
13
|
import { confirm, select } from "@inquirer/prompts";
|
|
14
14
|
import zlib from "node:zlib";
|
|
15
15
|
//#region src/errors.ts
|
|
@@ -82,21 +82,6 @@ function reportSessionCommandRefused(secret, consequence) {
|
|
|
82
82
|
}
|
|
83
83
|
//#endregion
|
|
84
84
|
//#region src/config.ts
|
|
85
|
-
/**
|
|
86
|
-
* Hardcoded Supabase project config for the wawesome.io platform.
|
|
87
|
-
* These are public values — the anon key is designed to be embedded in clients.
|
|
88
|
-
*/
|
|
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";
|
|
100
85
|
/** Path to the user-level settings file (~/.wawesome/settings.json) */
|
|
101
86
|
function getSettingsPath() {
|
|
102
87
|
return path.join(os.homedir(), ".wawesome", "settings.json");
|
|
@@ -149,18 +134,23 @@ function getDashboardUrl(overrideUrl) {
|
|
|
149
134
|
/**
|
|
150
135
|
* The dashboard for the environment this login is going to.
|
|
151
136
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*/
|
|
157
|
-
function resolveDashboardUrlForLogin(overrideUrl,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
137
|
+
* Taken from the address the platform handed back to open, so a login that
|
|
138
|
+
* configures nothing stores the dashboard belonging to the gateway it signed
|
|
139
|
+
* into. An explicit flag, `DASHBOARD_URL` or a settings file still outrank it,
|
|
140
|
+
* on the rung they outrank every other address on.
|
|
141
|
+
*/
|
|
142
|
+
function resolveDashboardUrlForLogin(overrideUrl, signInUrl) {
|
|
143
|
+
return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", originOf(signInUrl), DASHBOARD_URL_FALLBACK);
|
|
144
|
+
}
|
|
145
|
+
/** Undefined rather than throwing: an unreadable address falls to the rung below. */
|
|
146
|
+
function originOf(url) {
|
|
147
|
+
try {
|
|
148
|
+
return new URL(url).origin;
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const LOGIN_CALLBACK_PORT = 9999;
|
|
164
154
|
/** Path to the user-level credentials file */
|
|
165
155
|
function getCredentialsPath() {
|
|
166
156
|
return path.join(os.homedir(), ".wawesome", "credentials.json");
|
|
@@ -905,61 +895,198 @@ async function buildHandler(entryInput, config, options) {
|
|
|
905
895
|
* that has to name this version — `--version`, the dependency a scaffolded
|
|
906
896
|
* project pins — reads it here, so a release bumps one file.
|
|
907
897
|
*/
|
|
908
|
-
const CLI_VERSION = "0.
|
|
898
|
+
const CLI_VERSION = "0.20.0";
|
|
909
899
|
//#endregion
|
|
910
|
-
//#region src/
|
|
900
|
+
//#region src/cli-login.ts
|
|
901
|
+
const START_PATH = "/v1/cli/login/start";
|
|
902
|
+
const EXCHANGE_PATH = "/v1/cli/login/exchange";
|
|
903
|
+
function mintProof() {
|
|
904
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
905
|
+
return {
|
|
906
|
+
verifier,
|
|
907
|
+
challenge: crypto.createHash("sha256").update(verifier).digest("base64url")
|
|
908
|
+
};
|
|
909
|
+
}
|
|
911
910
|
/**
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
* chunk, so every line after the first is emitted with no question pending.
|
|
916
|
-
* End of input answers the remaining questions with their defaults.
|
|
911
|
+
* The address to open comes back from the platform rather than being composed
|
|
912
|
+
* here: which dashboard belongs to a gateway is the gateway's own fact, and a
|
|
913
|
+
* CLI that spelled it would be a CLI to reinstall whenever it moved.
|
|
917
914
|
*/
|
|
918
|
-
function
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
915
|
+
async function startSignIn(gatewayUrl, asked) {
|
|
916
|
+
const started = await post(gatewayUrl, START_PATH, {
|
|
917
|
+
redirect_uri: asked.redirectUri,
|
|
918
|
+
code_challenge: asked.challenge,
|
|
919
|
+
code_challenge_method: "S256",
|
|
920
|
+
state: asked.state
|
|
921
|
+
}, "The platform could not start a sign-in");
|
|
922
|
+
if (!started.sign_in_url) throw new Error("The platform started a sign-in without saying where to finish it.");
|
|
923
|
+
return started;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* The verifier travels here and nowhere else: it is what proves this terminal
|
|
927
|
+
* is the one that started the sign-in.
|
|
928
|
+
*/
|
|
929
|
+
async function exchangeCode(gatewayUrl, code, verifier) {
|
|
930
|
+
const session = await post(gatewayUrl, EXCHANGE_PATH, {
|
|
931
|
+
code,
|
|
932
|
+
code_verifier: verifier
|
|
933
|
+
}, "This sign-in could not be finished");
|
|
934
|
+
if (!session.tenant_jwt || !session.refresh_token) throw new Error("The platform answered this sign-in with half a session.");
|
|
935
|
+
return session;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* The code, or the sign-in running out first.
|
|
939
|
+
*
|
|
940
|
+
* Without the deadline, a tab somebody walked away from leaves the terminal
|
|
941
|
+
* waiting on a sign-in the platform has already forgotten.
|
|
942
|
+
*/
|
|
943
|
+
function waitForCode(answer, expiresAt) {
|
|
944
|
+
const runsOutIn = new Date(expiresAt).getTime() - Date.now();
|
|
945
|
+
if (!Number.isFinite(runsOutIn)) return answer;
|
|
946
|
+
let timer;
|
|
947
|
+
const ranOut = new Promise((_, reject) => {
|
|
948
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("This sign-in ran out before the browser came back. Run 'wawesome login' again.")), Math.max(runsOutIn, 0));
|
|
922
949
|
});
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
950
|
+
return Promise.race([answer, ranOut]).finally(() => clearTimeout(timer));
|
|
951
|
+
}
|
|
952
|
+
async function post(gatewayUrl, path, body, fallback) {
|
|
953
|
+
const res = await fetch(`${gatewayUrl}${path}`, {
|
|
954
|
+
method: "POST",
|
|
955
|
+
headers: { "Content-Type": "application/json" },
|
|
956
|
+
body: JSON.stringify(body)
|
|
930
957
|
});
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
958
|
+
if (!res.ok) throw await asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
|
|
959
|
+
return await res.json();
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/login-callback.ts
|
|
963
|
+
/**
|
|
964
|
+
* Where a browser hands a finished sign-in back to this terminal.
|
|
965
|
+
*
|
|
966
|
+
* Two things make the answer this server takes the developer's own. It binds
|
|
967
|
+
* loopback, so nothing on the network can reach it. And it mints a `state` that
|
|
968
|
+
* travels out with the sign-in and has to come back with the answer, so a page
|
|
969
|
+
* open in another tab cannot hand this CLI a code belonging to somebody else.
|
|
970
|
+
* Everything that fails either test is turned away in prose, and the login
|
|
971
|
+
* stays open for the tab it did open — a stray request is not a reason to end a
|
|
972
|
+
* sign-in a developer is in the middle of.
|
|
973
|
+
*
|
|
974
|
+
* What arrives is a single-use code bound to a verifier this process never
|
|
975
|
+
* sent anywhere but the exchange, which is why it may ride in a URL where an
|
|
976
|
+
* access token never could.
|
|
977
|
+
*/
|
|
978
|
+
/** The address is `localhost` so the platform's loopback rule still matches it. */
|
|
979
|
+
const CALLBACK_HOST = "localhost";
|
|
980
|
+
const LOOPBACK = "127.0.0.1";
|
|
981
|
+
const REFUSED_ELSEWHERE = "This sign-in did not come from the login running in your terminal, so it was refused. Nothing was signed in. Run 'wawesome login' again and finish in the tab it opens.";
|
|
982
|
+
const NO_CODE = "The browser came back from the sign-in without a code. Nothing was signed in — run 'wawesome login' again.";
|
|
983
|
+
async function listenForLoginCallback({ port = LOGIN_CALLBACK_PORT } = {}) {
|
|
984
|
+
const state = crypto.randomBytes(32).toString("base64url");
|
|
985
|
+
let settle = () => {};
|
|
986
|
+
let refuse = () => {};
|
|
987
|
+
const answer = new Promise((resolve, reject) => {
|
|
988
|
+
settle = resolve;
|
|
989
|
+
refuse = reject;
|
|
934
990
|
});
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
991
|
+
const server = http.createServer((req, res) => {
|
|
992
|
+
try {
|
|
993
|
+
handle(req, res, {
|
|
994
|
+
state,
|
|
995
|
+
settle,
|
|
996
|
+
refuse
|
|
997
|
+
});
|
|
998
|
+
} catch {
|
|
999
|
+
if (res.headersSent || res.destroyed) return;
|
|
1000
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
1001
|
+
res.end("This login could not read what the browser sent. Nothing was signed in.");
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
const listening = await listen(server, port);
|
|
940
1005
|
return {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
return defaultVal;
|
|
948
|
-
}
|
|
949
|
-
return line.trim() || defaultVal;
|
|
950
|
-
},
|
|
951
|
-
close() {
|
|
952
|
-
rl.close();
|
|
1006
|
+
redirectUrl: `http://${CALLBACK_HOST}:${listening}/callback`,
|
|
1007
|
+
state,
|
|
1008
|
+
answer,
|
|
1009
|
+
close: () => {
|
|
1010
|
+
server.closeAllConnections();
|
|
1011
|
+
server.close();
|
|
953
1012
|
}
|
|
954
1013
|
};
|
|
955
1014
|
}
|
|
1015
|
+
function handle(req, res, login) {
|
|
1016
|
+
const url = new URL(req.url || "/", `http://${LOOPBACK}`);
|
|
1017
|
+
if (url.pathname !== "/callback") {
|
|
1018
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1019
|
+
res.end("Not Found");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (!isThisLogin(url.searchParams.get("state"), login.state)) {
|
|
1023
|
+
reportStranger();
|
|
1024
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1025
|
+
res.end(page("Sign-in refused", REFUSED_ELSEWHERE));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const refused = url.searchParams.get("error_description") || url.searchParams.get("error");
|
|
1029
|
+
if (refused) return report(res, login, `The sign-in was refused: ${refused}`);
|
|
1030
|
+
const code = url.searchParams.get("code");
|
|
1031
|
+
if (!code) return report(res, login, NO_CODE);
|
|
1032
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1033
|
+
res.end(page("Signed in", "You can close this tab and go back to your terminal."));
|
|
1034
|
+
login.settle(code);
|
|
1035
|
+
}
|
|
956
1036
|
/**
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1037
|
+
* Say, in the terminal, that a request nobody asked for was turned away.
|
|
1038
|
+
*
|
|
1039
|
+
* The login itself is left open on purpose. Ending it here would mean any page
|
|
1040
|
+
* that can reach loopback could end a developer's sign-in by asking once, and
|
|
1041
|
+
* the tab this login did open is still coming.
|
|
1042
|
+
*/
|
|
1043
|
+
function reportStranger() {
|
|
1044
|
+
console.error("[wawesome] Refused a sign-in that did not carry this login's state. Nothing was signed in, and this login is still waiting for the tab it opened.");
|
|
1045
|
+
}
|
|
1046
|
+
/** A failure of this login: said on the page, and thrown in the terminal. */
|
|
1047
|
+
function report(res, login, said) {
|
|
1048
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1049
|
+
res.end(page("Sign-in refused", said));
|
|
1050
|
+
login.refuse(new Error(said));
|
|
1051
|
+
}
|
|
1052
|
+
function isThisLogin(given, expected) {
|
|
1053
|
+
if (!given) return false;
|
|
1054
|
+
const a = Buffer.from(given);
|
|
1055
|
+
const b = Buffer.from(expected);
|
|
1056
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
1057
|
+
}
|
|
1058
|
+
function listen(server, port) {
|
|
1059
|
+
return new Promise((resolve, reject) => {
|
|
1060
|
+
server.once("error", (err) => {
|
|
1061
|
+
if (err.code === "EADDRINUSE") return reject(/* @__PURE__ */ new Error(`Port ${port} is already in use, and that is where the browser hands the sign-in back. Stop whatever is holding it — often an earlier 'wawesome login' that never finished — then run 'wawesome login' again.`));
|
|
1062
|
+
reject(err);
|
|
1063
|
+
});
|
|
1064
|
+
server.listen(port, LOOPBACK, () => {
|
|
1065
|
+
const address = server.address();
|
|
1066
|
+
if (typeof address === "string" || address === null) return reject(/* @__PURE__ */ new Error("The callback server started without an address."));
|
|
1067
|
+
resolve(address.port);
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
const STYLE = "font-family: system-ui; text-align: center; padding: 50px; background: #0f172a; color: #f8fafc;";
|
|
1072
|
+
function page(title, said) {
|
|
1073
|
+
return `<!DOCTYPE html>
|
|
1074
|
+
<html>
|
|
1075
|
+
<head><title>wawesome — ${escapeHtml(title.toLowerCase())}</title></head>
|
|
1076
|
+
<body style="${STYLE}">
|
|
1077
|
+
<h2>${escapeHtml(title)}</h2>
|
|
1078
|
+
<p>${escapeHtml(said)}</p>
|
|
1079
|
+
</body>
|
|
1080
|
+
</html>`;
|
|
1081
|
+
}
|
|
1082
|
+
function escapeHtml(text) {
|
|
1083
|
+
return text.replace(/[&<>"']/g, (char) => ({
|
|
1084
|
+
"&": "&",
|
|
1085
|
+
"<": "<",
|
|
1086
|
+
">": ">",
|
|
1087
|
+
"\"": """,
|
|
1088
|
+
"'": "'"
|
|
1089
|
+
})[char] || char);
|
|
963
1090
|
}
|
|
964
1091
|
//#endregion
|
|
965
1092
|
//#region src/session.ts
|
|
@@ -1015,9 +1142,9 @@ function forgetRenewedSession() {
|
|
|
1015
1142
|
renewedAt = 0;
|
|
1016
1143
|
}
|
|
1017
1144
|
/**
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
* renewal instead of racing with the same token.
|
|
1145
|
+
* The platform rotates the refresh token on every use, and a token from a
|
|
1146
|
+
* generation it has moved past ends the session, so requests that meet the 401
|
|
1147
|
+
* together share one renewal instead of racing with the same token.
|
|
1021
1148
|
*/
|
|
1022
1149
|
function renewSession() {
|
|
1023
1150
|
if (!renewal) {
|
|
@@ -1036,50 +1163,114 @@ function renewSession() {
|
|
|
1036
1163
|
async function renewOnce() {
|
|
1037
1164
|
const creds = readCredentials();
|
|
1038
1165
|
if (!creds?.refresh_token) return null;
|
|
1039
|
-
|
|
1040
|
-
|
|
1166
|
+
return storeRenewed(creds, await renewedSession(creds));
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* What this process has learned about the session is written down beside it, so
|
|
1170
|
+
* a request still holding the old token does not spend a second 401 finding
|
|
1171
|
+
* out.
|
|
1172
|
+
*/
|
|
1173
|
+
function storeRenewed(creds, session) {
|
|
1041
1174
|
const renewed = {
|
|
1042
1175
|
...creds,
|
|
1043
|
-
tenant_jwt:
|
|
1044
|
-
refresh_token: session.refresh_token
|
|
1045
|
-
|
|
1046
|
-
|
|
1176
|
+
tenant_jwt: session.tenant_jwt,
|
|
1177
|
+
refresh_token: session.refresh_token,
|
|
1178
|
+
tenant_id: session.tenant_id || creds.tenant_id,
|
|
1179
|
+
tenant_slug: session.tenant_slug || creds.tenant_slug,
|
|
1180
|
+
tenant_name: session.tenant_name || creds.tenant_name,
|
|
1181
|
+
user_email: session.email || creds.user_email
|
|
1047
1182
|
};
|
|
1048
1183
|
writeCredentials(renewed);
|
|
1049
1184
|
renewedJwt = renewed.tenant_jwt;
|
|
1050
1185
|
renewedAt = Date.now();
|
|
1051
1186
|
return renewed;
|
|
1052
1187
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1188
|
+
/** Where a terminal renews, and where `logout` ends its session on the server. */
|
|
1189
|
+
const SESSION_REFRESH_PATH = "/v1/cli/session/refresh";
|
|
1190
|
+
const SESSION_REVOKE_PATH = "/v1/cli/session/revoke";
|
|
1191
|
+
/** Where the workspaces this session could move to are read. */
|
|
1192
|
+
const SESSION_WORKSPACES_PATH = "/v1/cli/session/workspaces";
|
|
1193
|
+
/**
|
|
1194
|
+
* Renew against this platform. The refresh token is what authenticates the
|
|
1195
|
+
* request, and the only other thing it ever carries is the workspace a
|
|
1196
|
+
* `workspace switch` is moving the session to.
|
|
1197
|
+
*/
|
|
1198
|
+
async function renewedSession(creds, tenantId) {
|
|
1199
|
+
const res = await fetch(`${creds.gateway_url}${SESSION_REFRESH_PATH}`, {
|
|
1055
1200
|
method: "POST",
|
|
1056
|
-
headers: {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1201
|
+
headers: { "Content-Type": "application/json" },
|
|
1202
|
+
body: JSON.stringify({
|
|
1203
|
+
refresh_token: creds.refresh_token,
|
|
1204
|
+
tenant_id: tenantId
|
|
1205
|
+
})
|
|
1061
1206
|
});
|
|
1062
|
-
if (res.status
|
|
1063
|
-
if (
|
|
1207
|
+
if (res.status === 429) throw await asGatewayError(res, "The platform is rate limiting this session's renewals.");
|
|
1208
|
+
if (res.status >= 400 && res.status < 500) {
|
|
1209
|
+
const refused = await asGatewayError(res, "Your session could not be renewed.");
|
|
1210
|
+
if (tenantId && refused.reason === "not-a-member") throw refused;
|
|
1211
|
+
endSession(refused.message);
|
|
1212
|
+
}
|
|
1213
|
+
if (!res.ok) throw new Error(`Could not renew your session (HTTP ${res.status}).`);
|
|
1064
1214
|
const session = await res.json();
|
|
1065
|
-
if (!session.
|
|
1215
|
+
if (!session.tenant_jwt || !session.refresh_token) throw new Error("Could not renew your session (the platform answered half a session).");
|
|
1066
1216
|
return session;
|
|
1067
1217
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1218
|
+
/**
|
|
1219
|
+
* The workspaces this session could move to. On the refresh token, which is
|
|
1220
|
+
* what makes the question answerable at all: the token belongs to the person,
|
|
1221
|
+
* where the tenant token names one workspace.
|
|
1222
|
+
*/
|
|
1223
|
+
async function readWorkspaces(creds) {
|
|
1224
|
+
const res = await fetch(`${creds.gateway_url}${SESSION_WORKSPACES_PATH}`, {
|
|
1070
1225
|
method: "POST",
|
|
1071
|
-
headers: {
|
|
1072
|
-
|
|
1073
|
-
"Content-Type": "application/json"
|
|
1074
|
-
},
|
|
1075
|
-
body: JSON.stringify({ tenant_id: creds.tenant_id })
|
|
1226
|
+
headers: { "Content-Type": "application/json" },
|
|
1227
|
+
body: JSON.stringify({ refresh_token: creds.refresh_token })
|
|
1076
1228
|
});
|
|
1077
|
-
if (res.
|
|
1078
|
-
if (!res.ok) throw new Error(`Could not renew your session (token exchange HTTP ${res.status}).`);
|
|
1229
|
+
if (!res.ok) throw await asGatewayError(res, `Your workspaces could not be read (HTTP ${res.status}).`);
|
|
1079
1230
|
return await res.json();
|
|
1080
1231
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1232
|
+
/**
|
|
1233
|
+
* Move the session to another workspace, which is a renewal that names it. No
|
|
1234
|
+
* browser: the refresh token is the person's, and the **Membership** the
|
|
1235
|
+
* platform checks is theirs too.
|
|
1236
|
+
*/
|
|
1237
|
+
async function switchWorkspaceTo(creds, tenantId) {
|
|
1238
|
+
return storeRenewed(creds, await renewedSession(creds, tenantId));
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* End the session on the server, which is what makes a copy of the credentials
|
|
1242
|
+
* file worthless once its owner has signed out.
|
|
1243
|
+
*
|
|
1244
|
+
* Answers why the session may still be open, and `null` where it is over. A
|
|
1245
|
+
* refusal is not a session left open — a token the platform will not take names
|
|
1246
|
+
* a session that is already gone — but a platform that could not answer at all
|
|
1247
|
+
* leaves one standing, and the file holding the only copy of the token that
|
|
1248
|
+
* could end it is then worth keeping.
|
|
1249
|
+
*/
|
|
1250
|
+
async function revokeSession(creds) {
|
|
1251
|
+
if (!creds.refresh_token) return null;
|
|
1252
|
+
try {
|
|
1253
|
+
const res = await fetch(`${creds.gateway_url}${SESSION_REVOKE_PATH}`, {
|
|
1254
|
+
method: "POST",
|
|
1255
|
+
headers: { "Content-Type": "application/json" },
|
|
1256
|
+
body: JSON.stringify({ refresh_token: creds.refresh_token })
|
|
1257
|
+
});
|
|
1258
|
+
if (res.ok || res.status < 500) return null;
|
|
1259
|
+
return (await asGatewayError(res, `The platform answered HTTP ${res.status}.`)).message;
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
return err instanceof Error ? err.message : String(err);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* What the platform said, and what to do about it.
|
|
1266
|
+
*
|
|
1267
|
+
* The next action is added only where the platform did not already name one:
|
|
1268
|
+
* its own refusals for a dead session end by saying to sign in again, and
|
|
1269
|
+
* repeating that reads like a stutter.
|
|
1270
|
+
*/
|
|
1271
|
+
function endSession(said) {
|
|
1272
|
+
const next = said.includes("wawesome login") ? "" : " Run 'wawesome login' to sign in again.";
|
|
1273
|
+
console.error(`[wawesome] Error: ${said}${next}`);
|
|
1083
1274
|
process.exit(1);
|
|
1084
1275
|
}
|
|
1085
1276
|
//#endregion
|
|
@@ -1169,141 +1360,37 @@ async function renameTenantSlug(creds, slug) {
|
|
|
1169
1360
|
}
|
|
1170
1361
|
//#endregion
|
|
1171
1362
|
//#region src/auth.ts
|
|
1172
|
-
|
|
1173
|
-
* Ask what to call the workspace being created.
|
|
1174
|
-
*
|
|
1175
|
-
* There is no default worth offering: a name nobody typed is exactly what this
|
|
1176
|
-
* replaced. A non-interactive login has nobody to ask, so it says what flag to
|
|
1177
|
-
* pass rather than inventing one and minting a workspace under it.
|
|
1178
|
-
*/
|
|
1179
|
-
async function promptForWorkspaceName(options) {
|
|
1180
|
-
const supplied = options.workspace?.trim();
|
|
1181
|
-
if (supplied) return supplied;
|
|
1182
|
-
if (!isInteractive()) throw new Error("No workspace found, and no name to create one with. Re-run with --workspace <name>.");
|
|
1183
|
-
console.log("\n[wawesome] You don't have a workspace yet. Let's create one.");
|
|
1184
|
-
console.log(" (this is the name you'll see in the dashboard; its public address is set separately)");
|
|
1185
|
-
const session = openPromptSession();
|
|
1186
|
-
try {
|
|
1187
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1188
|
-
const answer = await session.ask(" Workspace name?", "");
|
|
1189
|
-
if (answer.trim()) return answer.trim();
|
|
1190
|
-
console.log("[wawesome] A workspace needs a name.");
|
|
1191
|
-
}
|
|
1192
|
-
} finally {
|
|
1193
|
-
session.close();
|
|
1194
|
-
}
|
|
1195
|
-
throw new Error("No workspace name given. Nothing was created.");
|
|
1196
|
-
}
|
|
1197
|
-
/**
|
|
1198
|
-
* Ask the person to accept the terms, which is what creating a workspace
|
|
1199
|
-
* through this door means too.
|
|
1200
|
-
*
|
|
1201
|
-
* Declining is the default answer, not the one that needs typing: end of input
|
|
1202
|
-
* takes the default, so a piped login that says nothing has accepted nothing —
|
|
1203
|
-
* the terminal's version of an unticked box. The flag is a word from the person
|
|
1204
|
-
* running the command rather than a prompt, so a pipeline reads the same as a
|
|
1205
|
-
* terminal, and both documents are linked either way.
|
|
1206
|
-
*/
|
|
1207
|
-
async function confirmTermsAccepted(options) {
|
|
1208
|
-
if (options.acceptTerms) return;
|
|
1209
|
-
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.`);
|
|
1210
|
-
console.log("\n[wawesome] Creating a workspace means accepting:");
|
|
1211
|
-
console.log(` Terms of Service ${LEGAL_URLS.terms}`);
|
|
1212
|
-
console.log(` Privacy Policy ${LEGAL_URLS.privacy}`);
|
|
1213
|
-
const session = openPromptSession();
|
|
1214
|
-
try {
|
|
1215
|
-
const answer = await session.ask(" Accept both?", "n");
|
|
1216
|
-
if (/^y(es)?$/i.test(answer.trim())) return;
|
|
1217
|
-
} finally {
|
|
1218
|
-
session.close();
|
|
1219
|
-
}
|
|
1220
|
-
throw new Error("The terms were not accepted. Nothing was created.");
|
|
1221
|
-
}
|
|
1222
|
-
/**
|
|
1223
|
-
* Turn a finished OAuth callback into stored credentials.
|
|
1224
|
-
*
|
|
1225
|
-
* Split from the callback server so what login stores can be exercised without
|
|
1226
|
-
* a browser and a port.
|
|
1227
|
-
*/
|
|
1228
|
-
async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshToken, options }) {
|
|
1229
|
-
const isVerbose = Boolean(options.verbose);
|
|
1230
|
-
console.log("[wawesome] Fetching your workspaces...");
|
|
1231
|
-
const tenantsRes = await fetch(`${gatewayUrl}/api/v1/me/tenants`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
1232
|
-
if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
|
|
1233
|
-
const tenants = await tenantsRes.json();
|
|
1234
|
-
let primaryTenantId;
|
|
1235
|
-
let workspaceName;
|
|
1236
|
-
let workspaceSlug;
|
|
1237
|
-
if (tenants.length === 0) {
|
|
1238
|
-
const chosenName = await promptForWorkspaceName(options);
|
|
1239
|
-
await confirmTermsAccepted(options);
|
|
1240
|
-
console.log(`[wawesome] Creating workspace "${chosenName}"...`);
|
|
1241
|
-
const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
|
|
1242
|
-
method: "POST",
|
|
1243
|
-
headers: {
|
|
1244
|
-
Authorization: `Bearer ${accessToken}`,
|
|
1245
|
-
"Content-Type": "application/json"
|
|
1246
|
-
},
|
|
1247
|
-
body: JSON.stringify({
|
|
1248
|
-
tenant_name: chosenName,
|
|
1249
|
-
accepted_terms: true
|
|
1250
|
-
})
|
|
1251
|
-
});
|
|
1252
|
-
if (!initRes.ok) throw await asGatewayError(initRes, `Failed to initialize workspace (HTTP ${initRes.status}).`);
|
|
1253
|
-
const initData = await initRes.json();
|
|
1254
|
-
primaryTenantId = initData.tenant_id;
|
|
1255
|
-
workspaceSlug = initData.tenant_slug;
|
|
1256
|
-
workspaceName = initData.tenant_name || chosenName;
|
|
1257
|
-
} else primaryTenantId = tenants[0].tenant_id;
|
|
1258
|
-
if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
|
|
1259
|
-
console.log("[wawesome] Exchanging for tenant-scoped credentials...");
|
|
1260
|
-
const exchangeRes = await fetch(`${gatewayUrl}/api/v1/auth/token-exchange`, {
|
|
1261
|
-
method: "POST",
|
|
1262
|
-
headers: {
|
|
1263
|
-
Authorization: `Bearer ${accessToken}`,
|
|
1264
|
-
"Content-Type": "application/json"
|
|
1265
|
-
},
|
|
1266
|
-
body: JSON.stringify({ tenant_id: primaryTenantId })
|
|
1267
|
-
});
|
|
1268
|
-
if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
|
|
1269
|
-
const exchangeData = await exchangeRes.json();
|
|
1270
|
-
workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
|
|
1271
|
-
workspaceName = exchangeData.tenant_name || workspaceName;
|
|
1272
|
-
let userEmail = exchangeData.email || "unknown";
|
|
1273
|
-
try {
|
|
1274
|
-
const payloadPart = exchangeData.tenant_jwt.split(".")[1];
|
|
1275
|
-
const decoded = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf-8"));
|
|
1276
|
-
if (decoded.email) userEmail = decoded.email;
|
|
1277
|
-
} catch {}
|
|
1363
|
+
function storeSession({ gatewayUrl, dashboardUrl, session }) {
|
|
1278
1364
|
forgetRenewedSession();
|
|
1279
1365
|
writeCredentials({
|
|
1280
1366
|
gateway_url: gatewayUrl,
|
|
1281
1367
|
dashboard_url: dashboardUrl,
|
|
1282
|
-
tenant_jwt:
|
|
1283
|
-
refresh_token:
|
|
1284
|
-
tenant_id:
|
|
1285
|
-
user_email:
|
|
1286
|
-
tenant_slug:
|
|
1287
|
-
tenant_name:
|
|
1368
|
+
tenant_jwt: session.tenant_jwt,
|
|
1369
|
+
refresh_token: session.refresh_token,
|
|
1370
|
+
tenant_id: session.tenant_id,
|
|
1371
|
+
user_email: session.email,
|
|
1372
|
+
tenant_slug: session.tenant_slug,
|
|
1373
|
+
tenant_name: session.tenant_name
|
|
1288
1374
|
});
|
|
1289
1375
|
console.log("\n======================================================");
|
|
1290
1376
|
console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
|
|
1291
1377
|
console.log("======================================================");
|
|
1292
|
-
if (
|
|
1293
|
-
if (
|
|
1294
|
-
console.log(` Tenant: ${
|
|
1378
|
+
if (session.tenant_name) console.log(`\n Workspace: ${session.tenant_name}`);
|
|
1379
|
+
if (session.tenant_slug) console.log(` Address: ${session.tenant_slug}`);
|
|
1380
|
+
console.log(` Tenant: ${session.tenant_id}`);
|
|
1295
1381
|
console.log(` Gateway: ${gatewayUrl}`);
|
|
1296
|
-
console.log(` Email: ${
|
|
1382
|
+
console.log(` Email: ${session.email}`);
|
|
1297
1383
|
console.log("\n Credentials saved to ~/.wawesome/credentials.json");
|
|
1298
1384
|
console.log("======================================================\n");
|
|
1299
1385
|
}
|
|
1300
1386
|
/**
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1387
|
+
* Sign in: open the browser at the page built for it, and wait on loopback.
|
|
1388
|
+
*
|
|
1389
|
+
* Everything a person chooses happens there — which identity they sign in with,
|
|
1390
|
+
* which workspace the terminal acts in, and making one where they hold none —
|
|
1391
|
+
* so this end asks for nothing and knows no identity provider (#1113). What the
|
|
1392
|
+
* browser hands back is a single-use code, spent here against a verifier that
|
|
1393
|
+
* left this process only at the exchange.
|
|
1307
1394
|
*/
|
|
1308
1395
|
async function login(options) {
|
|
1309
1396
|
const credential = credentialFromEnvironment();
|
|
@@ -1312,102 +1399,63 @@ async function login(options) {
|
|
|
1312
1399
|
process.exit(1);
|
|
1313
1400
|
}
|
|
1314
1401
|
const gatewayUrl = resolveGatewayUrlWithoutSession(options.gateway || options.api);
|
|
1315
|
-
const dashboardUrl = resolveDashboardUrlForLogin(options.dashboard, gatewayUrl);
|
|
1316
|
-
const provider = options.provider || "github";
|
|
1317
1402
|
const isVerbose = Boolean(options.verbose);
|
|
1318
|
-
const authUrl = `${SUPABASE_URL}/auth/v1/authorize?provider=${provider}&redirect_to=${encodeURIComponent(`http://localhost:${OAUTH_CALLBACK_PORT}/callback`)}`;
|
|
1319
|
-
console.log(`[wawesome] Starting ${provider} OAuth login...`);
|
|
1320
1403
|
console.log(`[wawesome] Gateway: ${gatewayUrl}`);
|
|
1321
|
-
|
|
1322
|
-
console.
|
|
1323
|
-
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
<!DOCTYPE html>
|
|
1332
|
-
<html>
|
|
1333
|
-
<head><title>wawesome - Authentication</title></head>
|
|
1334
|
-
<body style="font-family: system-ui; text-align: center; padding: 50px; background: #0f172a; color: #f8fafc;">
|
|
1335
|
-
<h2>🔐 Authenticating with ${provider}...</h2>
|
|
1336
|
-
<p>Transferring auth tokens back to CLI terminal...</p>
|
|
1337
|
-
<script>
|
|
1338
|
-
const hash = window.location.hash.substring(1);
|
|
1339
|
-
const params = new URLSearchParams(hash);
|
|
1340
|
-
const accessToken = params.get('access_token');
|
|
1341
|
-
const refreshToken = params.get('refresh_token');
|
|
1342
|
-
|
|
1343
|
-
if (accessToken) {
|
|
1344
|
-
fetch('/token?access_token=' + encodeURIComponent(accessToken) + '&refresh_token=' + encodeURIComponent(refreshToken || ''))
|
|
1345
|
-
.then(() => {
|
|
1346
|
-
document.body.innerHTML = '<h1>✅ Success!</h1><p>You can close this tab and return to your terminal.</p>';
|
|
1347
|
-
});
|
|
1348
|
-
} else {
|
|
1349
|
-
document.body.innerHTML = '<h1>❌ Error</h1><p>No access token found in URL hash.</p>';
|
|
1350
|
-
}
|
|
1351
|
-
<\/script>
|
|
1352
|
-
</body>
|
|
1353
|
-
</html>
|
|
1354
|
-
`);
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
if (reqUrl.pathname === "/token") {
|
|
1358
|
-
const accessToken = reqUrl.searchParams.get("access_token");
|
|
1359
|
-
const refreshToken = reqUrl.searchParams.get("refresh_token");
|
|
1360
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
1361
|
-
res.end("Token received by CLI.");
|
|
1362
|
-
if (!accessToken) {
|
|
1363
|
-
console.error("[wawesome] Error: No access token received.");
|
|
1364
|
-
server.close();
|
|
1365
|
-
reject(/* @__PURE__ */ new Error("No access token received"));
|
|
1366
|
-
return;
|
|
1367
|
-
}
|
|
1368
|
-
if (isVerbose) console.log("[wawesome:verbose] Supabase access token received.");
|
|
1369
|
-
try {
|
|
1370
|
-
await completeLogin({
|
|
1371
|
-
gatewayUrl,
|
|
1372
|
-
dashboardUrl,
|
|
1373
|
-
accessToken,
|
|
1374
|
-
refreshToken: refreshToken || void 0,
|
|
1375
|
-
options
|
|
1376
|
-
});
|
|
1377
|
-
server.close();
|
|
1378
|
-
resolve();
|
|
1379
|
-
} catch (err) {
|
|
1380
|
-
console.error(`[wawesome] Login failed: ${err instanceof Error ? err.message : err}`);
|
|
1381
|
-
server.close();
|
|
1382
|
-
reject(err);
|
|
1383
|
-
}
|
|
1384
|
-
} else {
|
|
1385
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1386
|
-
res.end("Not Found");
|
|
1387
|
-
}
|
|
1404
|
+
const callback = await listenForLoginCallback().catch((err) => {
|
|
1405
|
+
console.error(`[wawesome] Error: ${errorText(err)}`);
|
|
1406
|
+
process.exit(1);
|
|
1407
|
+
});
|
|
1408
|
+
const proof = mintProof();
|
|
1409
|
+
try {
|
|
1410
|
+
const started = await startSignIn(gatewayUrl, {
|
|
1411
|
+
redirectUri: callback.redirectUrl,
|
|
1412
|
+
challenge: proof.challenge,
|
|
1413
|
+
state: callback.state
|
|
1388
1414
|
});
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1415
|
+
if (isVerbose) console.log(`[wawesome:verbose] Sign-in: ${started.request}`);
|
|
1416
|
+
console.log(`\n🌐 Waiting for the browser to come back to ${callback.redirectUrl} ...`);
|
|
1417
|
+
console.log("🚀 Opening your browser to finish signing in...");
|
|
1418
|
+
console.log(`👉 ${started.sign_in_url}\n`);
|
|
1419
|
+
try {
|
|
1420
|
+
const open = (await import("open")).default;
|
|
1421
|
+
await open(started.sign_in_url);
|
|
1422
|
+
} catch {
|
|
1423
|
+
console.log("[wawesome] Could not open browser automatically. Please visit the URL above manually.");
|
|
1424
|
+
}
|
|
1425
|
+
const session = await exchangeCode(gatewayUrl, await waitForCode(callback.answer, started.expires_at), proof.verifier);
|
|
1426
|
+
storeSession({
|
|
1427
|
+
gatewayUrl,
|
|
1428
|
+
dashboardUrl: resolveDashboardUrlForLogin(options.dashboard, started.sign_in_url),
|
|
1429
|
+
session
|
|
1399
1430
|
});
|
|
1400
|
-
})
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
console.error(`[wawesome] Login failed: ${errorText(err)}`);
|
|
1433
|
+
throw err;
|
|
1434
|
+
} finally {
|
|
1435
|
+
callback.close();
|
|
1436
|
+
}
|
|
1401
1437
|
}
|
|
1402
1438
|
/**
|
|
1403
|
-
*
|
|
1439
|
+
* End the session, on the server and then on disk.
|
|
1440
|
+
*
|
|
1441
|
+
* The server first, because that is the half that makes a copy of the
|
|
1442
|
+
* credentials file worthless. Where the platform could not answer at all, the
|
|
1443
|
+
* file stays: it holds the only copy of the token that could end the session,
|
|
1444
|
+
* and deleting it would leave one open with nothing able to close it.
|
|
1404
1445
|
*/
|
|
1405
|
-
function logout() {
|
|
1446
|
+
async function logout() {
|
|
1406
1447
|
const credential = credentialFromEnvironment();
|
|
1407
1448
|
if (credential) {
|
|
1408
1449
|
reportSessionCommandRefused(credential, "The next command would authenticate all the same.");
|
|
1409
1450
|
process.exit(1);
|
|
1410
1451
|
}
|
|
1452
|
+
const creds = readCredentials();
|
|
1453
|
+
const stillOpen = creds ? await revokeSession(creds) : null;
|
|
1454
|
+
if (stillOpen) {
|
|
1455
|
+
console.error(`[wawesome] Error: your session could not be ended: ${stillOpen}`);
|
|
1456
|
+
console.error("[wawesome] You are still signed in, and your credentials were kept. Run 'wawesome logout' again once you can reach the platform.");
|
|
1457
|
+
process.exit(1);
|
|
1458
|
+
}
|
|
1411
1459
|
forgetRenewedSession();
|
|
1412
1460
|
if (deleteCredentials()) console.log("[wawesome] ✅ Logged out. Credentials removed.");
|
|
1413
1461
|
else console.log("[wawesome] Not logged in (no credentials found).");
|
|
@@ -1544,6 +1592,17 @@ function wrapped(said) {
|
|
|
1544
1592
|
function indented(said) {
|
|
1545
1593
|
return wrapped(said).map((line) => `${VALUE_COLUMN}${line}`);
|
|
1546
1594
|
}
|
|
1595
|
+
/**
|
|
1596
|
+
* What fits in `room` characters, with an ellipsis where it does not. Cut
|
|
1597
|
+
* rather than wrapped: a row that took two lines would push the row above it
|
|
1598
|
+
* out of sight. A room too small for even that shows nothing, so nothing ever
|
|
1599
|
+
* runs past the width it was given.
|
|
1600
|
+
*/
|
|
1601
|
+
function truncate(said, room) {
|
|
1602
|
+
if (said.length <= room) return said;
|
|
1603
|
+
if (room <= 1) return "";
|
|
1604
|
+
return `${said.slice(0, room - 1)}…`;
|
|
1605
|
+
}
|
|
1547
1606
|
//#endregion
|
|
1548
1607
|
//#region src/domains.ts
|
|
1549
1608
|
/**
|
|
@@ -2281,6 +2340,61 @@ function manifestOf(assets) {
|
|
|
2281
2340
|
}));
|
|
2282
2341
|
}
|
|
2283
2342
|
//#endregion
|
|
2343
|
+
//#region src/prompt.ts
|
|
2344
|
+
/**
|
|
2345
|
+
* Open a prompt session on stdin, queueing lines as they arrive.
|
|
2346
|
+
*
|
|
2347
|
+
* Reading one `rl.question` at a time drops piped input: it lands as a single
|
|
2348
|
+
* chunk, so every line after the first is emitted with no question pending.
|
|
2349
|
+
* End of input answers the remaining questions with their defaults.
|
|
2350
|
+
*/
|
|
2351
|
+
function openPromptSession() {
|
|
2352
|
+
const rl = readline.createInterface({
|
|
2353
|
+
input: process.stdin,
|
|
2354
|
+
output: process.stdout
|
|
2355
|
+
});
|
|
2356
|
+
const queued = [];
|
|
2357
|
+
const waiting = [];
|
|
2358
|
+
let ended = false;
|
|
2359
|
+
rl.on("line", (line) => {
|
|
2360
|
+
const next = waiting.shift();
|
|
2361
|
+
if (next) next(line);
|
|
2362
|
+
else queued.push(line);
|
|
2363
|
+
});
|
|
2364
|
+
rl.on("close", () => {
|
|
2365
|
+
ended = true;
|
|
2366
|
+
while (waiting.length > 0) waiting.shift()?.(null);
|
|
2367
|
+
});
|
|
2368
|
+
function nextLine() {
|
|
2369
|
+
if (queued.length > 0) return Promise.resolve(queued.shift());
|
|
2370
|
+
if (ended) return Promise.resolve(null);
|
|
2371
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
2372
|
+
}
|
|
2373
|
+
return {
|
|
2374
|
+
async ask(question, defaultVal) {
|
|
2375
|
+
const offered = defaultVal ? ` (${defaultVal})` : "";
|
|
2376
|
+
process.stdout.write(`${question}${offered}: `);
|
|
2377
|
+
const line = await nextLine();
|
|
2378
|
+
if (line === null) {
|
|
2379
|
+
process.stdout.write("\n");
|
|
2380
|
+
return defaultVal;
|
|
2381
|
+
}
|
|
2382
|
+
return line.trim() || defaultVal;
|
|
2383
|
+
},
|
|
2384
|
+
close() {
|
|
2385
|
+
rl.close();
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Whether a question can actually be put to somebody. A piped or redirected
|
|
2391
|
+
* stdin still answers — with end-of-input — so this only tells a caller whether
|
|
2392
|
+
* a *default* is going to be what the user gets.
|
|
2393
|
+
*/
|
|
2394
|
+
function isInteractive() {
|
|
2395
|
+
return Boolean(process.stdin.isTTY);
|
|
2396
|
+
}
|
|
2397
|
+
//#endregion
|
|
2284
2398
|
//#region ../shared/public-address.ts
|
|
2285
2399
|
const INVOCATION_PREFIX = "/x";
|
|
2286
2400
|
const SLUG_SEPARATOR = "--";
|
|
@@ -2370,6 +2484,20 @@ async function invocationSurfaceOrNone(gatewayUrl) {
|
|
|
2370
2484
|
function pathFormBase(origin, tenantSlug) {
|
|
2371
2485
|
return `${trimTrailingSlashes(origin)}${INVOCATION_PREFIX}/${encodeURIComponent(tenantSlug)}`;
|
|
2372
2486
|
}
|
|
2487
|
+
/**
|
|
2488
|
+
* The legal DNS label closest to what somebody typed, empty where nothing legal
|
|
2489
|
+
* survives. What a workspace name suggests as an address, and what the CLI
|
|
2490
|
+
* offers as a default — never applied to a typed address, which is refused
|
|
2491
|
+
* rather than rewritten into a name nobody chose.
|
|
2492
|
+
*/
|
|
2493
|
+
function slugifyLabel(raw) {
|
|
2494
|
+
let label = "";
|
|
2495
|
+
for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z" || c >= "0" && c <= "9") label += c;
|
|
2496
|
+
else if (label.length > 0 && !label.endsWith("-")) label += "-";
|
|
2497
|
+
label = label.slice(0, 63);
|
|
2498
|
+
while (label.endsWith("-")) label = label.slice(0, -1);
|
|
2499
|
+
return label;
|
|
2500
|
+
}
|
|
2373
2501
|
/** Must match `dns_label::is_legal` in `server/crates/core/src/value_objects.rs`. */
|
|
2374
2502
|
function isLegalDnsLabel(candidate) {
|
|
2375
2503
|
return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
|
|
@@ -2910,7 +3038,8 @@ async function cronCommand(action, target, subtarget, options = {}) {
|
|
|
2910
3038
|
* The address a **Document** answers at, relative to its Function's mount: a
|
|
2911
3039
|
* request path whose last segment carries no extension resolves to that path,
|
|
2912
3040
|
* to it with `.html` appended and to it with `/index.html` appended, so both
|
|
2913
|
-
* `about.html` and `about/index.html` are the address `about`.
|
|
3041
|
+
* `about.html` and `about/index.html` are the address `about`. A last segment
|
|
3042
|
+
* spelled `index` skips the middle one, which is what this strips first.
|
|
2914
3043
|
*/
|
|
2915
3044
|
function documentAddressPath(assetPath) {
|
|
2916
3045
|
return assetPath.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "");
|
|
@@ -3037,6 +3166,17 @@ function declaredFields(declared) {
|
|
|
3037
3166
|
};
|
|
3038
3167
|
}
|
|
3039
3168
|
/**
|
|
3169
|
+
* What this deploy says it carries.
|
|
3170
|
+
*
|
|
3171
|
+
* Trimmed here so that a flag whose value came out of a shell variable nobody
|
|
3172
|
+
* set is a deploy that said nothing rather than one the gateway turns down. How
|
|
3173
|
+
* long a message may be and whether it is one line are the platform's rule, and
|
|
3174
|
+
* holding them here as well is two places for them to drift.
|
|
3175
|
+
*/
|
|
3176
|
+
function declaredMessage(stated) {
|
|
3177
|
+
return typeof stated === "string" ? stated.trim() || void 0 : void 0;
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3040
3180
|
* The fallback this project declares.
|
|
3041
3181
|
*
|
|
3042
3182
|
* A declaration missing either half is refused here rather than at the gateway,
|
|
@@ -3107,7 +3247,8 @@ async function deploy(entryInput, options) {
|
|
|
3107
3247
|
visibility: declaredVisibility(config.visibility),
|
|
3108
3248
|
confirmPublish: Boolean(options.publish),
|
|
3109
3249
|
confirmAssetDrop: Boolean(options.dropAssets),
|
|
3110
|
-
fallback: declaredFallback(config.fallback)
|
|
3250
|
+
fallback: declaredFallback(config.fallback),
|
|
3251
|
+
message: declaredMessage(options.message)
|
|
3111
3252
|
};
|
|
3112
3253
|
const domainName = declaredDomain(config.domain);
|
|
3113
3254
|
if (isVerbose) {
|
|
@@ -3166,7 +3307,10 @@ async function deploy(entryInput, options) {
|
|
|
3166
3307
|
console.log(`[wawesome] Uploading code for ${app}/${funcName}...`);
|
|
3167
3308
|
const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
|
|
3168
3309
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
|
|
3169
|
-
const declaredOnTheWire =
|
|
3310
|
+
const declaredOnTheWire = {
|
|
3311
|
+
...declaredFields(declared),
|
|
3312
|
+
...declared.message ? { message: declared.message } : {}
|
|
3313
|
+
};
|
|
3170
3314
|
const uploadRes = assets.length > 0 || declaresSchedules || declaresFallback || Object.keys(declaredOnTheWire).length > 0 || jsCode === null ? await authorizedFetch(uploadUrl, {
|
|
3171
3315
|
method: "POST",
|
|
3172
3316
|
headers: {
|
|
@@ -3190,7 +3334,7 @@ async function deploy(entryInput, options) {
|
|
|
3190
3334
|
});
|
|
3191
3335
|
if (!uploadRes.ok) {
|
|
3192
3336
|
const errorBody = await uploadRes.text();
|
|
3193
|
-
const refusal = rejectionOf(errorBody, uploadRes.status, "Version with this code bundle already exists.");
|
|
3337
|
+
const refusal = rejectionOf(errorBody, uploadRes.status, uploadRes.status === 409 ? "Version with this code bundle already exists." : `Code upload failed (HTTP ${uploadRes.status}).`);
|
|
3194
3338
|
if (uploadRes.status === 401 || uploadRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(uploadRes.status, errorBody)}`);
|
|
3195
3339
|
else if (refusal.reason === "paid-plan-required") {
|
|
3196
3340
|
console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
|
|
@@ -3212,7 +3356,7 @@ async function deploy(entryInput, options) {
|
|
|
3212
3356
|
console.error("[wawesome] To switch active version, run: \x1B[36mwawesome version switch\x1B[0m\n");
|
|
3213
3357
|
}
|
|
3214
3358
|
} else {
|
|
3215
|
-
console.error(
|
|
3359
|
+
console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m\n`);
|
|
3216
3360
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
3217
3361
|
}
|
|
3218
3362
|
process.exit(1);
|
|
@@ -3483,11 +3627,6 @@ async function setEnvVar(key, value, options) {
|
|
|
3483
3627
|
console.log("");
|
|
3484
3628
|
} else console.log(`[wawesome] ✅ Set variable '${key}' on app '${app}'.`);
|
|
3485
3629
|
}
|
|
3486
|
-
function truncate(str, maxLen) {
|
|
3487
|
-
if (str.length <= maxLen) return str;
|
|
3488
|
-
if (maxLen <= 3) return str.slice(0, maxLen);
|
|
3489
|
-
return str.slice(0, maxLen - 1) + "…";
|
|
3490
|
-
}
|
|
3491
3630
|
/**
|
|
3492
3631
|
* List environment variables on the current app.
|
|
3493
3632
|
*/
|
|
@@ -3808,25 +3947,32 @@ function renderPostDeploy(postDeploy, address) {
|
|
|
3808
3947
|
if (!address) return postDeploy;
|
|
3809
3948
|
return postDeploy.split("{{url}}").join(address);
|
|
3810
3949
|
}
|
|
3950
|
+
//#endregion
|
|
3951
|
+
//#region src/slug.ts
|
|
3952
|
+
/**
|
|
3953
|
+
* The character rules every slug on the platform obeys, mirrored from the
|
|
3954
|
+
* `dns_label` module in `server/crates/core/src/value_objects.rs`.
|
|
3955
|
+
*
|
|
3956
|
+
* A typed name is checked while the user is still standing there, rather than
|
|
3957
|
+
* accepted, sent, and rewritten by the gateway into something else that ends up
|
|
3958
|
+
* in a client's dashboard. The rules live beside the address they build, so the
|
|
3959
|
+
* CLI and the dashboard cannot come to disagree about what a legal name is.
|
|
3960
|
+
*/
|
|
3961
|
+
/** Maximum length of a single DNS label (RFC 1035 §2.3.4). */
|
|
3962
|
+
const MAX_SLUG_LENGTH = 63;
|
|
3811
3963
|
/**
|
|
3812
3964
|
* Normalise arbitrary input into a legal DNS label, returning an empty string
|
|
3813
3965
|
* when nothing legal survives — callers decide whether that is an error.
|
|
3814
3966
|
*/
|
|
3815
3967
|
function sanitizeSlug(raw) {
|
|
3816
|
-
|
|
3817
|
-
for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z") label += c;
|
|
3818
|
-
else if (c >= "0" && c <= "9") label += c;
|
|
3819
|
-
else if (label.length > 0 && !label.endsWith("-")) label += "-";
|
|
3820
|
-
label = label.slice(0, 63);
|
|
3821
|
-
while (label.endsWith("-")) label = label.slice(0, -1);
|
|
3822
|
-
return label;
|
|
3968
|
+
return slugifyLabel(raw);
|
|
3823
3969
|
}
|
|
3824
3970
|
/**
|
|
3825
3971
|
* Whether `candidate` is already a legal DNS label — i.e. whether
|
|
3826
3972
|
* {@link sanitizeSlug} would leave it untouched.
|
|
3827
3973
|
*/
|
|
3828
3974
|
function isLegalSlug(candidate) {
|
|
3829
|
-
return candidate.length > 0 && candidate.length <=
|
|
3975
|
+
return candidate.length > 0 && candidate.length <= MAX_SLUG_LENGTH && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
|
|
3830
3976
|
}
|
|
3831
3977
|
//#endregion
|
|
3832
3978
|
//#region src/tarball.ts
|
|
@@ -4577,6 +4723,24 @@ export default {
|
|
|
4577
4723
|
//#region src/version.ts
|
|
4578
4724
|
/** How both surfaces name a version nobody can switch to. */
|
|
4579
4725
|
const RECLAIMED = "⚠ [assets reclaimed]";
|
|
4726
|
+
/** Where the message sits, under the row rather than in a column of its own. */
|
|
4727
|
+
const MESSAGE_INDENT = " ".repeat(10);
|
|
4728
|
+
const ASSUMED_WIDTH = 80;
|
|
4729
|
+
function terminalWidth() {
|
|
4730
|
+
const columns = process.stdout.columns;
|
|
4731
|
+
return columns && columns > 40 ? columns : ASSUMED_WIDTH;
|
|
4732
|
+
}
|
|
4733
|
+
/**
|
|
4734
|
+
* The lines one version takes in the listing: its row, and the words its deploy
|
|
4735
|
+
* stated beneath it where it stated any.
|
|
4736
|
+
*/
|
|
4737
|
+
function versionLines(v, activeNumber, width) {
|
|
4738
|
+
const isActive = v.version_number === activeNumber;
|
|
4739
|
+
const row = `${`#${v.version_number}`.padEnd(7)} | ${v.cwasm_hash.slice(0, 8).padEnd(8)} | ${new Date(v.created_at).toISOString().replace("T", " ").slice(0, 19).padEnd(20)} | ${v.status.padEnd(9)} | ${isActive ? "\x1B[32m★ [active]\x1B[0m" : v.rollbackable === false ? `\x1b[33m${RECLAIMED}\x1b[0m` : ""}`;
|
|
4740
|
+
const message = v.message?.trim();
|
|
4741
|
+
if (!message) return [row];
|
|
4742
|
+
return [row, `${MESSAGE_INDENT}\x1b[2m${truncate(message, width - MESSAGE_INDENT.length)}\x1b[0m`];
|
|
4743
|
+
}
|
|
4580
4744
|
async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
|
|
4581
4745
|
const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
|
|
4582
4746
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
@@ -4620,15 +4784,8 @@ async function listVersions(options) {
|
|
|
4620
4784
|
console.log(`\n📦 \x1b[1mVersions for '${app}/${funcName}' (environment: ${env})\x1b[0m\n`);
|
|
4621
4785
|
console.log("VERSION | HASH | CREATED AT | STATUS | ACTIVE");
|
|
4622
4786
|
console.log("--------|----------|----------------------|-----------|-------");
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
const verStr = `#${v.version_number}`.padEnd(7);
|
|
4626
|
-
const hashStr = v.cwasm_hash.slice(0, 8).padEnd(8);
|
|
4627
|
-
const dateStr = new Date(v.created_at).toISOString().replace("T", " ").slice(0, 19).padEnd(20);
|
|
4628
|
-
const statusStr = v.status.padEnd(9);
|
|
4629
|
-
const badge = isActive ? "\x1B[32m★ [active]\x1B[0m" : v.rollbackable === false ? `\x1b[33m${RECLAIMED}\x1b[0m` : "";
|
|
4630
|
-
console.log(`${verStr} | ${hashStr} | ${dateStr} | ${statusStr} | ${badge}`);
|
|
4631
|
-
}
|
|
4787
|
+
const width = terminalWidth();
|
|
4788
|
+
for (const v of data.versions) for (const line of versionLines(v, data.active_version_number, width)) console.log(line);
|
|
4632
4789
|
console.log("");
|
|
4633
4790
|
}
|
|
4634
4791
|
/**
|
|
@@ -4676,8 +4833,11 @@ async function switchVersion(targetInput, options) {
|
|
|
4676
4833
|
const hashShort = v.cwasm_hash.slice(0, 8);
|
|
4677
4834
|
const dateStr = new Date(v.created_at).toISOString().replace("T", " ").slice(0, 19);
|
|
4678
4835
|
const badge = isActive ? " ★ [active]" : v.rollbackable === false ? ` ${RECLAIMED}` : "";
|
|
4836
|
+
const said = v.message?.trim();
|
|
4837
|
+
const row = `v${v.version_number} (${hashShort}) • ${dateStr}${badge}`;
|
|
4838
|
+
const room = terminalWidth() - row.length - 3 - 2;
|
|
4679
4839
|
return {
|
|
4680
|
-
name:
|
|
4840
|
+
name: said ? `${row} • ${truncate(said, room)}` : row,
|
|
4681
4841
|
value: v.version_number
|
|
4682
4842
|
};
|
|
4683
4843
|
});
|
|
@@ -4717,6 +4877,8 @@ async function switchVersion(targetInput, options) {
|
|
|
4717
4877
|
console.log(` Function: ${funcName}`);
|
|
4718
4878
|
console.log(` Environment: ${env}`);
|
|
4719
4879
|
console.log(` Active Ver: v${selectedVersionNum}`);
|
|
4880
|
+
const restored = data.versions.find((v) => v.version_number === selectedVersionNum)?.message?.trim();
|
|
4881
|
+
if (restored) console.log(` Message: ${truncate(restored, terminalWidth() - 15)}`);
|
|
4720
4882
|
console.log(`======================================================\n`);
|
|
4721
4883
|
}
|
|
4722
4884
|
//#endregion
|
|
@@ -5504,7 +5666,7 @@ async function renameWorkspace(slug, options) {
|
|
|
5504
5666
|
console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
|
|
5505
5667
|
console.log("======================================================");
|
|
5506
5668
|
console.log(`\n Address: ${result.tenant_slug}`);
|
|
5507
|
-
console.log(` Previous: ${result.previous_slug} (
|
|
5669
|
+
console.log(` Previous: ${result.previous_slug} (no longer resolves)`);
|
|
5508
5670
|
console.log("======================================================\n");
|
|
5509
5671
|
} catch (err) {
|
|
5510
5672
|
if (err instanceof GatewayError) {
|
|
@@ -5519,12 +5681,117 @@ async function renameWorkspace(slug, options) {
|
|
|
5519
5681
|
process.exit(1);
|
|
5520
5682
|
}
|
|
5521
5683
|
}
|
|
5684
|
+
/**
|
|
5685
|
+
* The workspaces this person belongs to, with the one the session is in
|
|
5686
|
+
* marked.
|
|
5687
|
+
*
|
|
5688
|
+
* Which one that is comes from the platform rather than from the credentials
|
|
5689
|
+
* file: a switch in another terminal moved the session without this one
|
|
5690
|
+
* hearing about it.
|
|
5691
|
+
*/
|
|
5692
|
+
async function listWorkspaces(options) {
|
|
5693
|
+
const read = await requireWorkspaces(requireSession("Your workspaces would be read for that credential's Tenant alone."));
|
|
5694
|
+
console.log("\n[wawesome] Your workspaces:\n");
|
|
5695
|
+
const width = Math.max(...read.workspaces.map((workspace) => workspace.name.length));
|
|
5696
|
+
for (const workspace of read.workspaces) {
|
|
5697
|
+
const mark = workspace.tenant_id === read.current_tenant_id ? "\x1B[32m*\x1B[0m" : " ";
|
|
5698
|
+
const tenant = options.verbose ? ` ${workspace.tenant_id}` : "";
|
|
5699
|
+
console.log(` ${mark} ${workspace.name.padEnd(width)} ${workspace.tenant_slug} (${workspace.role})${tenant}`);
|
|
5700
|
+
}
|
|
5701
|
+
if (read.workspaces.length > 1) console.log("\n Move with: \x1B[36mwawesome workspace switch <name>\x1B[0m");
|
|
5702
|
+
console.log("");
|
|
5703
|
+
}
|
|
5704
|
+
/**
|
|
5705
|
+
* Move the session to another workspace.
|
|
5706
|
+
*
|
|
5707
|
+
* The list is read first, so a name is resolved against what the person
|
|
5708
|
+
* actually belongs to and a workspace that is none of theirs is answered with
|
|
5709
|
+
* the ones that are. The platform checks the **Membership** again either way,
|
|
5710
|
+
* so this is where somebody is told rather than where it is decided.
|
|
5711
|
+
*/
|
|
5712
|
+
async function switchWorkspace(target, options) {
|
|
5713
|
+
const creds = requireSession("A switch would move a session that credential does not have.");
|
|
5714
|
+
if (!target || !target.trim()) {
|
|
5715
|
+
console.error("[wawesome] Error: No workspace given. Usage: wawesome workspace switch <name>");
|
|
5716
|
+
process.exit(1);
|
|
5717
|
+
return;
|
|
5718
|
+
}
|
|
5719
|
+
const read = await requireWorkspaces(creds);
|
|
5720
|
+
const asked = target.trim();
|
|
5721
|
+
const matched = read.workspaces.filter((workspace) => workspace.tenant_slug === asked || workspace.tenant_id === asked || workspace.name.toLowerCase() === asked.toLowerCase());
|
|
5722
|
+
if (matched.length === 0) return notYours(asked, read);
|
|
5723
|
+
if (matched.length > 1) return sharedName(asked, matched);
|
|
5724
|
+
const [moving] = matched;
|
|
5725
|
+
if (moving.tenant_id === read.current_tenant_id) {
|
|
5726
|
+
console.log(`[wawesome] Already in ${moving.name} (${moving.tenant_slug}). Nothing changed.`);
|
|
5727
|
+
return;
|
|
5728
|
+
}
|
|
5729
|
+
if (options.verbose) console.log(`[wawesome:verbose] Switching to ${moving.tenant_id}`);
|
|
5730
|
+
try {
|
|
5731
|
+
await switchWorkspaceTo(creds, moving.tenant_id);
|
|
5732
|
+
} catch (err) {
|
|
5733
|
+
console.error(`[wawesome] Error: ${errorText(err)}`);
|
|
5734
|
+
process.exit(1);
|
|
5735
|
+
return;
|
|
5736
|
+
}
|
|
5737
|
+
console.log("\n======================================================");
|
|
5738
|
+
console.log("✅ \x1B[32mWORKSPACE SWITCHED\x1B[0m");
|
|
5739
|
+
console.log("======================================================");
|
|
5740
|
+
console.log(`\n Workspace: ${moving.name}`);
|
|
5741
|
+
console.log(` Address: ${moving.tenant_slug}`);
|
|
5742
|
+
console.log(` Role: ${moving.role}`);
|
|
5743
|
+
console.log("======================================================\n");
|
|
5744
|
+
}
|
|
5522
5745
|
/** Dispatch for `wawesome workspace [action]`. */
|
|
5523
5746
|
async function workspaceCommand(action, target, options) {
|
|
5524
5747
|
if (!action || action === "show" || action === "info") return showWorkspace(options);
|
|
5525
5748
|
if (action === "rename") return renameWorkspace(target, options);
|
|
5749
|
+
if (action === "list" || action === "ls") return listWorkspaces(options);
|
|
5750
|
+
if (action === "switch") return switchWorkspace(target, options);
|
|
5526
5751
|
console.error(`[wawesome] Error: Unknown workspace action '${action}'.`);
|
|
5527
|
-
console.error("[wawesome] Usage: wawesome workspace [show|rename <name>]");
|
|
5752
|
+
console.error("[wawesome] Usage: wawesome workspace [show|list|switch <name>|rename <name>]");
|
|
5753
|
+
process.exit(1);
|
|
5754
|
+
}
|
|
5755
|
+
/**
|
|
5756
|
+
* The session these two commands act on, which is a person's and never a
|
|
5757
|
+
* **Deploy credential**: a credential names one Tenant and holds no
|
|
5758
|
+
* **Membership** anywhere, so there is nothing for it to list or move.
|
|
5759
|
+
*/
|
|
5760
|
+
function requireSession(consequence) {
|
|
5761
|
+
const credential = credentialFromEnvironment();
|
|
5762
|
+
if (credential) {
|
|
5763
|
+
reportSessionCommandRefused(credential, consequence);
|
|
5764
|
+
process.exit(1);
|
|
5765
|
+
}
|
|
5766
|
+
const creds = requireCredentials$1();
|
|
5767
|
+
if (!creds.refresh_token) {
|
|
5768
|
+
console.error("[wawesome] Error: This session predates workspace switching.");
|
|
5769
|
+
console.error("[wawesome] Run 'wawesome login' to sign in again.");
|
|
5770
|
+
process.exit(1);
|
|
5771
|
+
}
|
|
5772
|
+
return creds;
|
|
5773
|
+
}
|
|
5774
|
+
async function requireWorkspaces(creds) {
|
|
5775
|
+
try {
|
|
5776
|
+
return await readWorkspaces(creds);
|
|
5777
|
+
} catch (err) {
|
|
5778
|
+
console.error(`[wawesome] Error: ${errorText(err)}`);
|
|
5779
|
+
process.exit(1);
|
|
5780
|
+
}
|
|
5781
|
+
}
|
|
5782
|
+
/** Refused here rather than at the platform, and answered with the ones that are theirs. */
|
|
5783
|
+
function notYours(asked, read) {
|
|
5784
|
+
refuse(`You are not a member of '${asked}'.`, "The workspaces you are in:", read.workspaces.map((workspace) => `${workspace.name} (${workspace.tenant_slug})`));
|
|
5785
|
+
}
|
|
5786
|
+
/** Two workspaces may be called the same thing; their addresses cannot. */
|
|
5787
|
+
function sharedName(asked, matched) {
|
|
5788
|
+
refuse(`More than one of your workspaces is called '${asked}'.`, "Name one by its address instead:", matched.map((workspace) => `wawesome workspace switch ${workspace.tenant_slug}`));
|
|
5789
|
+
}
|
|
5790
|
+
function refuse(headline, next, lines) {
|
|
5791
|
+
console.error(`\n[wawesome] \x1b[31m${headline}\x1b[0m`);
|
|
5792
|
+
console.error(`[wawesome] ${next}`);
|
|
5793
|
+
for (const line of lines) console.error(` ${line}`);
|
|
5794
|
+
console.error("");
|
|
5528
5795
|
process.exit(1);
|
|
5529
5796
|
}
|
|
5530
5797
|
//#endregion
|
|
@@ -5585,16 +5852,144 @@ const MINT_PRESETS = [
|
|
|
5585
5852
|
"write:apps",
|
|
5586
5853
|
"write:functions",
|
|
5587
5854
|
"write:env",
|
|
5855
|
+
"write:domains",
|
|
5588
5856
|
"write:runs",
|
|
5589
5857
|
"write:schedules",
|
|
5590
5858
|
"write:previews"
|
|
5591
5859
|
],
|
|
5592
|
-
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, run Functions, pause and resume schedules and create previews.
|
|
5860
|
+
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, attach domains, run Functions, pause and resume schedules and create previews."
|
|
5593
5861
|
}
|
|
5594
5862
|
];
|
|
5595
5863
|
function mintPreset(name) {
|
|
5596
5864
|
return MINT_PRESETS.find((preset) => preset.name === name);
|
|
5597
5865
|
}
|
|
5866
|
+
[
|
|
5867
|
+
{
|
|
5868
|
+
code: "owner",
|
|
5869
|
+
job: "Own the workspace",
|
|
5870
|
+
capabilities: [
|
|
5871
|
+
"read:tenant",
|
|
5872
|
+
"write:tenant",
|
|
5873
|
+
"read:apps",
|
|
5874
|
+
"write:apps",
|
|
5875
|
+
"read:functions",
|
|
5876
|
+
"write:functions",
|
|
5877
|
+
"read:env",
|
|
5878
|
+
"write:env",
|
|
5879
|
+
"read:domains",
|
|
5880
|
+
"write:domains",
|
|
5881
|
+
"delete:domains",
|
|
5882
|
+
"read:invocations",
|
|
5883
|
+
"read:runs",
|
|
5884
|
+
"write:runs",
|
|
5885
|
+
"read:schedules",
|
|
5886
|
+
"write:schedules",
|
|
5887
|
+
"read:previews",
|
|
5888
|
+
"write:previews",
|
|
5889
|
+
"read:egress",
|
|
5890
|
+
"write:egress",
|
|
5891
|
+
"write:billing",
|
|
5892
|
+
"write:credentials",
|
|
5893
|
+
"write:members"
|
|
5894
|
+
],
|
|
5895
|
+
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can rename the workspace, create Apps, deploy Functions, set environment variables, attach domains, detach domains, run Functions, pause and resume schedules, create previews, widen the egress allowlist, change billing, manage credentials and manage members.",
|
|
5896
|
+
withheldFromAPerson: "A workspace has one owner, so 'owner' is not a standing anybody is put at."
|
|
5897
|
+
},
|
|
5898
|
+
{
|
|
5899
|
+
code: "admin",
|
|
5900
|
+
job: "Administer the workspace",
|
|
5901
|
+
capabilities: [
|
|
5902
|
+
"read:tenant",
|
|
5903
|
+
"read:apps",
|
|
5904
|
+
"write:apps",
|
|
5905
|
+
"read:functions",
|
|
5906
|
+
"write:functions",
|
|
5907
|
+
"read:env",
|
|
5908
|
+
"write:env",
|
|
5909
|
+
"read:domains",
|
|
5910
|
+
"write:domains",
|
|
5911
|
+
"delete:domains",
|
|
5912
|
+
"read:invocations",
|
|
5913
|
+
"read:runs",
|
|
5914
|
+
"write:runs",
|
|
5915
|
+
"read:schedules",
|
|
5916
|
+
"write:schedules",
|
|
5917
|
+
"read:previews",
|
|
5918
|
+
"write:previews",
|
|
5919
|
+
"read:egress",
|
|
5920
|
+
"write:egress",
|
|
5921
|
+
"write:credentials",
|
|
5922
|
+
"write:members"
|
|
5923
|
+
],
|
|
5924
|
+
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, attach domains, detach domains, run Functions, pause and resume schedules, create previews, widen the egress allowlist, manage credentials and manage members. It does not rename the workspace or change billing.",
|
|
5925
|
+
withheldFromAPerson: null
|
|
5926
|
+
},
|
|
5927
|
+
{
|
|
5928
|
+
code: "member",
|
|
5929
|
+
job: "Build in the workspace",
|
|
5930
|
+
capabilities: [
|
|
5931
|
+
"read:tenant",
|
|
5932
|
+
"read:apps",
|
|
5933
|
+
"read:functions",
|
|
5934
|
+
"read:env",
|
|
5935
|
+
"read:domains",
|
|
5936
|
+
"read:invocations",
|
|
5937
|
+
"read:runs",
|
|
5938
|
+
"read:schedules",
|
|
5939
|
+
"read:previews",
|
|
5940
|
+
"read:egress",
|
|
5941
|
+
"write:apps",
|
|
5942
|
+
"write:functions",
|
|
5943
|
+
"write:env",
|
|
5944
|
+
"write:domains",
|
|
5945
|
+
"write:runs",
|
|
5946
|
+
"write:schedules",
|
|
5947
|
+
"write:previews"
|
|
5948
|
+
],
|
|
5949
|
+
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, attach domains, run Functions, pause and resume schedules and create previews. It does not rename the workspace, detach domains, widen the egress allowlist, change billing, manage credentials or manage members.",
|
|
5950
|
+
withheldFromAPerson: null
|
|
5951
|
+
},
|
|
5952
|
+
{
|
|
5953
|
+
code: "deployer",
|
|
5954
|
+
job: "Ship and read back",
|
|
5955
|
+
capabilities: [
|
|
5956
|
+
"read:tenant",
|
|
5957
|
+
"read:apps",
|
|
5958
|
+
"read:functions",
|
|
5959
|
+
"read:env",
|
|
5960
|
+
"read:domains",
|
|
5961
|
+
"read:invocations",
|
|
5962
|
+
"read:runs",
|
|
5963
|
+
"read:schedules",
|
|
5964
|
+
"read:previews",
|
|
5965
|
+
"read:egress",
|
|
5966
|
+
"write:apps",
|
|
5967
|
+
"write:functions",
|
|
5968
|
+
"write:env",
|
|
5969
|
+
"write:runs"
|
|
5970
|
+
],
|
|
5971
|
+
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables and run Functions. It does not rename the workspace, attach domains, detach domains, pause and resume schedules, create previews, widen the egress allowlist, change billing, manage credentials or manage members.",
|
|
5972
|
+
withheldFromAPerson: "'deployer' is the recipe a Deploy credential is cut from. A pipeline gets a credential of its own rather than somebody's account."
|
|
5973
|
+
},
|
|
5974
|
+
{
|
|
5975
|
+
code: "viewer",
|
|
5976
|
+
job: "Read and investigate",
|
|
5977
|
+
capabilities: [
|
|
5978
|
+
"read:tenant",
|
|
5979
|
+
"read:apps",
|
|
5980
|
+
"read:functions",
|
|
5981
|
+
"read:env",
|
|
5982
|
+
"read:domains",
|
|
5983
|
+
"read:invocations",
|
|
5984
|
+
"read:runs",
|
|
5985
|
+
"read:schedules",
|
|
5986
|
+
"read:previews",
|
|
5987
|
+
"read:egress"
|
|
5988
|
+
],
|
|
5989
|
+
summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist. It writes nothing.",
|
|
5990
|
+
withheldFromAPerson: "'viewer' reaches every read in the workspace: the plan, what it has spent, the environment variables and every App. Nobody is put at it."
|
|
5991
|
+
}
|
|
5992
|
+
].filter((role) => role.withheldFromAPerson === null);
|
|
5598
5993
|
//#endregion
|
|
5599
5994
|
//#region src/deploy-credentials.ts
|
|
5600
5995
|
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.";
|
|
@@ -6040,7 +6435,7 @@ async function credentialsCommand(action, target, options) {
|
|
|
6040
6435
|
//#region src/index.ts
|
|
6041
6436
|
const cli = cac("wawesome");
|
|
6042
6437
|
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));
|
|
6043
|
-
cli.command("deploy [entry]", "Build, upload, and promote a serverless function").option("-o, --out <path>", "Output JS bundle path", { default: "dist/index.js" }).option("-v, --verbose", "Enable verbose debug output").option("--skip-build", "Skip the build step, deploy an already-built bundle").option("--publish", "Confirm putting a private function back on its public address").option("--drop-assets", "Confirm a deploy carrying no static files where the live version serves some").action((entry, options) => deploy(entry, options));
|
|
6438
|
+
cli.command("deploy [entry]", "Build, upload, and promote a serverless function").option("-o, --out <path>", "Output JS bundle path", { default: "dist/index.js" }).option("-v, --verbose", "Enable verbose debug output").option("--skip-build", "Skip the build step, deploy an already-built bundle").option("--publish", "Confirm putting a private function back on its public address").option("--drop-assets", "Confirm a deploy carrying no static files where the live version serves some").option("-m, --message <text>", "One line about what this deploy carries, kept on the version it makes").action((entry, options) => deploy(entry, options));
|
|
6044
6439
|
cli.command("version [action] [target]", "Manage versions (e.g. 'version list', 'version switch [version]')").option("-e, --env <environment>", "Target environment (default: production)").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => {
|
|
6045
6440
|
if (!action || action === "list" || action === "ls") return listVersions(options);
|
|
6046
6441
|
if (action === "switch" || action === "use" || action === "select") return switchVersion(target, options);
|
|
@@ -6055,10 +6450,10 @@ cli.command("env rm <key>", "Delete an environment variable from the current app
|
|
|
6055
6450
|
cli.command("domains [action] [hostname]", "Show the custom domain on the current app, its state and its DNS records").usage("domains [action] [hostname]\n\nWith no action, prints the app's domain, every half of it, where each got to, when the platform last checked, and the records to add.\n\nActions:\n adopt Write the attached domain into wawesome-function.json\n detach <hostname> Stop serving the app at a domain you attached\n\nA domain is attached by declaring it in wawesome-function.json and deploying, from the dashboard, or by an agent.").example("wawesome domains").example("wawesome domains adopt").example("wawesome domains detach shop.client.com").option("-a, --app <slug>", "App to act on (default: the app in wawesome-function.json)").action((action, hostname, options) => domainsCommand(action, hostname, options));
|
|
6056
6451
|
cli.command("domains adopt", "Write the domain the current app answers at into wawesome-function.json").option("-a, --app <slug>", "App to act on (default: the app in wawesome-function.json)").action((options) => adoptDomainCommand(void 0, options));
|
|
6057
6452
|
cli.command("domains detach <hostname>", "Stop serving the current app at a domain you attached").option("-a, --app <slug>", "App to act on (default: the app in wawesome-function.json)").action((hostname, options) => detachDomainCommand(hostname, options));
|
|
6058
|
-
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
|
|
6059
|
-
cli.command("logout", "Clear stored authentication credentials").action(() => logout());
|
|
6453
|
+
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 to record (default: the one the platform names)").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
|
|
6454
|
+
cli.command("logout", "Clear stored authentication credentials").action(async () => await logout());
|
|
6060
6455
|
cli.command("whoami", "Show current login session info").action(() => whoami());
|
|
6061
|
-
cli.command("workspace [action] [name]", "Show the workspace, or rename
|
|
6456
|
+
cli.command("workspace [action] [name]", "Show the workspace, move between workspaces, or rename the public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n list (ls) Show the workspaces you belong to, and which one you are in\n switch <name> Move this terminal to another workspace you belong to\n rename <name> Change the public address, while nothing live depends on it").example("wawesome workspace").example("wawesome workspace list").example("wawesome workspace switch northwind").example("wawesome workspace rename northwind").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
|
|
6062
6457
|
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));
|
|
6063
6458
|
cli.command("credentials [action] [target]", "Mint, list, regenerate, revoke and delete deploy credentials").alias("credential").usage(`credentials <action> [options]
|
|
6064
6459
|
|
|
@@ -6094,7 +6489,7 @@ cli.command("credentials list", "List deploy credentials, never their secrets").
|
|
|
6094
6489
|
cli.command("credentials regenerate <name-or-prefix>", "Replace a deploy credential's secret, keeping its name, capabilities and Apps").option("--expires <days|never>", "Days until the regenerated credential expires, or 'never' (default: 90)").option("-y, --yes", "Regenerate without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => regenerateCredentialCommand(target, options));
|
|
6095
6490
|
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));
|
|
6096
6491
|
cli.command("credentials delete <name-or-prefix>", "Delete a revoked or expired deploy credential, freeing its name").option("-y, --yes", "Delete without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => deleteCredentialCommand(target, options));
|
|
6097
|
-
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:
|
|
6492
|
+
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 to record (default: the one the platform names)").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));
|
|
6098
6493
|
cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
|
|
6099
6494
|
|
|
6100
6495
|
The target argument determines what the command does:
|