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/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as unsupportedGlobals, n as methodRemedy, r as polyfilledGlobals, t as declaredMethods } from "./guest-surface-CXON0L5V.mjs";
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 crypto from "node:crypto";
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
- * The two addresses describe one environment, so the stored rung is read only
153
- * where the gateway being logged into is the one the credentials already name.
154
- * Carrying a dashboard across a change of gateway is the wrong workspace, and
155
- * so is dropping one where the gateway did not change at all.
156
- */
157
- function resolveDashboardUrlForLogin(overrideUrl, gatewayUrl) {
158
- const creds = readStoredCredentials();
159
- const sameEnvironment = creds?.gateway_url === gatewayUrl;
160
- return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", sameEnvironment ? creds?.dashboard_url : void 0, DASHBOARD_URL_FALLBACK);
161
- }
162
- /** Localhost port used during OAuth callback */
163
- const OAUTH_CALLBACK_PORT = 9999;
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.18.1";
898
+ const CLI_VERSION = "0.20.0";
909
899
  //#endregion
910
- //#region src/prompt.ts
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
- * Open a prompt session on stdin, queueing lines as they arrive.
913
- *
914
- * Reading one `rl.question` at a time drops piped input: it lands as a single
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 openPromptSession() {
919
- const rl = readline.createInterface({
920
- input: process.stdin,
921
- output: process.stdout
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
- const queued = [];
924
- const waiting = [];
925
- let ended = false;
926
- rl.on("line", (line) => {
927
- const next = waiting.shift();
928
- if (next) next(line);
929
- else queued.push(line);
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
- rl.on("close", () => {
932
- ended = true;
933
- while (waiting.length > 0) waiting.shift()?.(null);
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
- function nextLine() {
936
- if (queued.length > 0) return Promise.resolve(queued.shift());
937
- if (ended) return Promise.resolve(null);
938
- return new Promise((resolve) => waiting.push(resolve));
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
- async ask(question, defaultVal) {
942
- const offered = defaultVal ? ` (${defaultVal})` : "";
943
- process.stdout.write(`${question}${offered}: `);
944
- const line = await nextLine();
945
- if (line === null) {
946
- process.stdout.write("\n");
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
- * Whether a question can actually be put to somebody. A piped or redirected
958
- * stdin still answers — with end-of-input — so this only tells a caller whether
959
- * a *default* is going to be what the user gets.
960
- */
961
- function isInteractive() {
962
- return Boolean(process.stdin.isTTY);
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
+ "&": "&amp;",
1085
+ "<": "&lt;",
1086
+ ">": "&gt;",
1087
+ "\"": "&quot;",
1088
+ "'": "&#39;"
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
- * Supabase rotates the refresh token on every use and revokes the whole family
1019
- * if a spent one comes back, so requests that meet the 401 together share one
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
- const session = await refreshSupabaseSession(creds.refresh_token);
1040
- const exchanged = await exchangeForTenantJwt(creds, session.access_token);
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: exchanged.tenant_jwt,
1044
- refresh_token: session.refresh_token || creds.refresh_token,
1045
- tenant_slug: exchanged.tenant_slug || creds.tenant_slug,
1046
- tenant_name: exchanged.tenant_name || creds.tenant_name
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
- async function refreshSupabaseSession(refreshToken) {
1054
- const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
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
- apikey: SUPABASE_ANON_KEY,
1058
- "Content-Type": "application/json"
1059
- },
1060
- body: JSON.stringify({ refresh_token: refreshToken })
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 >= 400 && res.status < 500 && res.status !== 429) endSession("Your sign-in was revoked or has expired, so the session could not be renewed.");
1063
- if (!res.ok) throw new Error(`Could not renew your session (Supabase HTTP ${res.status}).`);
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.access_token) throw new Error("Could not renew your session (Supabase returned no access token).");
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
- async function exchangeForTenantJwt(creds, supabaseAccessToken) {
1069
- const res = await fetch(`${creds.gateway_url}/api/v1/auth/token-exchange`, {
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
- Authorization: `Bearer ${supabaseAccessToken}`,
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.status === 401 || res.status === 403) endSession("Your access to this workspace has been withdrawn.");
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
- function endSession(reason) {
1082
- console.error(`[wawesome] Error: ${reason} Run 'wawesome login' to sign in again.`);
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: exchangeData.tenant_jwt,
1283
- refresh_token: refreshToken,
1284
- tenant_id: primaryTenantId,
1285
- user_email: userEmail,
1286
- tenant_slug: workspaceSlug,
1287
- tenant_name: workspaceName
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 (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
1293
- if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
1294
- console.log(` Tenant: ${primaryTenantId}`);
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: ${userEmail}`);
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
- * OAuth login flow:
1302
- * 1. Build Supabase OAuth URL
1303
- * 2. Open browser
1304
- * 3. Listen on localhost for callback with access_token
1305
- * 4. Exchange Supabase JWT for tenant-scoped JWT via gateway
1306
- * 5. Store credentials in ~/.wawesome/credentials.json
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
- if (isVerbose) {
1322
- console.log(`[wawesome:verbose] Supabase URL: ${SUPABASE_URL}`);
1323
- console.log(`[wawesome:verbose] Auth URL: ${authUrl}`);
1324
- }
1325
- return new Promise((resolve, reject) => {
1326
- const server = http.createServer(async (req, res) => {
1327
- const reqUrl = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`);
1328
- if (reqUrl.pathname === "/callback") {
1329
- res.writeHead(200, { "Content-Type": "text/html" });
1330
- res.end(`
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
- server.listen(OAUTH_CALLBACK_PORT, async () => {
1390
- console.log(`\n🌐 Listening for OAuth callback on http://localhost:${OAUTH_CALLBACK_PORT}/callback ...`);
1391
- console.log(`🚀 Opening browser for ${provider} authentication...`);
1392
- console.log(`👉 ${authUrl}\n`);
1393
- try {
1394
- const open = (await import("open")).default;
1395
- await open(authUrl);
1396
- } catch {
1397
- console.log("[wawesome] Could not open browser automatically. Please visit the URL above manually.");
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
- * Clear stored credentials.
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 = declaredFields(declared);
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(`[wawesome] Error: Code upload failed (HTTP ${uploadRes.status}).`);
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
- let label = "";
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 <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
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
- for (const v of data.versions) {
4624
- const isActive = v.version_number === data.active_version_number;
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: `v${v.version_number} (${hashShort}) • ${dateStr}${badge}`,
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} (still resolves)`);
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. It does not attach domains."
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 (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));
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 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));
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: 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));
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: