wawesome 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/index.mjs +1143 -205
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { build } from "esbuild";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import http from "node:http";
|
|
7
7
|
import readline from "node:readline";
|
|
8
|
+
import zlib from "node:zlib";
|
|
8
9
|
import { select } from "@inquirer/prompts";
|
|
9
10
|
//#region src/config.ts
|
|
10
11
|
/**
|
|
@@ -124,6 +125,7 @@ async function buildJs(entryInput, options) {
|
|
|
124
125
|
try {
|
|
125
126
|
await build({
|
|
126
127
|
entryPoints: [entry],
|
|
128
|
+
absWorkingDir: process.cwd(),
|
|
127
129
|
bundle: true,
|
|
128
130
|
format: "esm",
|
|
129
131
|
outfile: outPath,
|
|
@@ -148,8 +150,183 @@ async function buildJs(entryInput, options) {
|
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
//#endregion
|
|
153
|
+
//#region src/prompt.ts
|
|
154
|
+
/**
|
|
155
|
+
* Open a prompt session on stdin, queueing lines as they arrive.
|
|
156
|
+
*
|
|
157
|
+
* Reading one `rl.question` at a time drops piped input: it lands as a single
|
|
158
|
+
* chunk, so every line after the first is emitted with no question pending.
|
|
159
|
+
* End of input answers the remaining questions with their defaults.
|
|
160
|
+
*/
|
|
161
|
+
function openPromptSession() {
|
|
162
|
+
const rl = readline.createInterface({
|
|
163
|
+
input: process.stdin,
|
|
164
|
+
output: process.stdout
|
|
165
|
+
});
|
|
166
|
+
const queued = [];
|
|
167
|
+
const waiting = [];
|
|
168
|
+
let ended = false;
|
|
169
|
+
rl.on("line", (line) => {
|
|
170
|
+
const next = waiting.shift();
|
|
171
|
+
if (next) next(line);
|
|
172
|
+
else queued.push(line);
|
|
173
|
+
});
|
|
174
|
+
rl.on("close", () => {
|
|
175
|
+
ended = true;
|
|
176
|
+
while (waiting.length > 0) waiting.shift()?.(null);
|
|
177
|
+
});
|
|
178
|
+
function nextLine() {
|
|
179
|
+
if (queued.length > 0) return Promise.resolve(queued.shift());
|
|
180
|
+
if (ended) return Promise.resolve(null);
|
|
181
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
async ask(question, defaultVal) {
|
|
185
|
+
const offered = defaultVal ? ` (${defaultVal})` : "";
|
|
186
|
+
process.stdout.write(`${question}${offered}: `);
|
|
187
|
+
const line = await nextLine();
|
|
188
|
+
if (line === null) {
|
|
189
|
+
process.stdout.write("\n");
|
|
190
|
+
return defaultVal;
|
|
191
|
+
}
|
|
192
|
+
return line.trim() || defaultVal;
|
|
193
|
+
},
|
|
194
|
+
close() {
|
|
195
|
+
rl.close();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether a question can actually be put to somebody. A piped or redirected
|
|
201
|
+
* stdin still answers — with end-of-input — so this only tells a caller whether
|
|
202
|
+
* a *default* is going to be what the user gets.
|
|
203
|
+
*/
|
|
204
|
+
function isInteractive() {
|
|
205
|
+
return Boolean(process.stdin.isTTY);
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/errors.ts
|
|
209
|
+
/** A rejection carrying the gateway's own prose and, where given, its reason. */
|
|
210
|
+
var GatewayError = class extends Error {
|
|
211
|
+
status;
|
|
212
|
+
reason;
|
|
213
|
+
/** The raw response, for `--verbose` to print when the prose is not enough. */
|
|
214
|
+
body;
|
|
215
|
+
constructor(message, status, reason, body = "") {
|
|
216
|
+
super(message);
|
|
217
|
+
this.name = "GatewayError";
|
|
218
|
+
this.status = status;
|
|
219
|
+
this.reason = reason;
|
|
220
|
+
this.body = body;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Read a failed response into a `GatewayError`.
|
|
225
|
+
*
|
|
226
|
+
* `fallback` is used only when the response carries no prose of its own — a
|
|
227
|
+
* proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
|
|
228
|
+
* cased here: what to say about an expired session depends on what the caller
|
|
229
|
+
* was doing, so callers handle it before reaching this.
|
|
230
|
+
*/
|
|
231
|
+
async function asGatewayError(res, fallback) {
|
|
232
|
+
const body = await res.text().catch(() => "");
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(body);
|
|
235
|
+
return new GatewayError(parsed.error || fallback, res.status, parsed.reason, body);
|
|
236
|
+
} catch {
|
|
237
|
+
return new GatewayError(fallback, res.status, void 0, body);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** What went wrong, as text, whatever was thrown. */
|
|
241
|
+
function errorText(err) {
|
|
242
|
+
return err instanceof Error ? err.message : String(err);
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/tenant.ts
|
|
246
|
+
async function fetchTenantDetails(creds) {
|
|
247
|
+
const res = await fetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
248
|
+
if (!res.ok) throw await asGatewayError(res, `Failed to read workspace (HTTP ${res.status}).`);
|
|
249
|
+
return await res.json();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The workspace's name and slug, preferring what login already stored.
|
|
253
|
+
*
|
|
254
|
+
* Credentials written before the slug was stored have neither, so this fetches
|
|
255
|
+
* and writes them back rather than making every later command ask again.
|
|
256
|
+
*/
|
|
257
|
+
async function resolveWorkspace(creds) {
|
|
258
|
+
if (creds.tenant_slug && creds.tenant_name) return {
|
|
259
|
+
name: creds.tenant_name,
|
|
260
|
+
slug: creds.tenant_slug
|
|
261
|
+
};
|
|
262
|
+
const tenant = await fetchTenantDetails(creds);
|
|
263
|
+
writeCredentials({
|
|
264
|
+
...creds,
|
|
265
|
+
tenant_slug: tenant.tenant_slug,
|
|
266
|
+
tenant_name: tenant.name
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
name: tenant.name,
|
|
270
|
+
slug: tenant.tenant_slug
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Ask for the workspace's public address to become `slug`.
|
|
275
|
+
*
|
|
276
|
+
* The slug is sent exactly as typed. Sanitizing it the way an App slug is
|
|
277
|
+
* sanitized would hand the user a public address they never chose, so an
|
|
278
|
+
* unusable name comes back as a `malformed` rejection instead.
|
|
279
|
+
*/
|
|
280
|
+
async function renameTenantSlug(creds, slug) {
|
|
281
|
+
const res = await fetch(`${creds.gateway_url}/v1/tenant/slug`, {
|
|
282
|
+
method: "PUT",
|
|
283
|
+
headers: {
|
|
284
|
+
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
285
|
+
"Content-Type": "application/json"
|
|
286
|
+
},
|
|
287
|
+
body: JSON.stringify({ slug })
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) throw await asGatewayError(res, `Rename failed (HTTP ${res.status}).`);
|
|
290
|
+
const result = await res.json();
|
|
291
|
+
const stored = readCredentials();
|
|
292
|
+
if (stored) writeCredentials({
|
|
293
|
+
...stored,
|
|
294
|
+
tenant_slug: result.tenant_slug
|
|
295
|
+
});
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
/** The address a deployed Function answers on, for a workspace and app. */
|
|
299
|
+
function publicInvokeUrl(gatewayUrl, tenantSlug, appSlug, functionName) {
|
|
300
|
+
return `${gatewayUrl}/v1/s/${encodeURIComponent(tenantSlug)}/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(functionName)}/invoke`;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
151
303
|
//#region src/auth.ts
|
|
152
304
|
/**
|
|
305
|
+
* Ask what to call the workspace being created.
|
|
306
|
+
*
|
|
307
|
+
* There is no default worth offering: a name nobody typed is exactly what this
|
|
308
|
+
* replaced. A non-interactive login has nobody to ask, so it says what flag to
|
|
309
|
+
* pass rather than inventing one and minting a workspace under it.
|
|
310
|
+
*/
|
|
311
|
+
async function promptForWorkspaceName(options) {
|
|
312
|
+
const supplied = options.workspace?.trim();
|
|
313
|
+
if (supplied) return supplied;
|
|
314
|
+
if (!isInteractive()) throw new Error("No workspace found, and no name to create one with. Re-run with --workspace <name>.");
|
|
315
|
+
console.log("\n[wawesome] You don't have a workspace yet — let's create one.");
|
|
316
|
+
console.log(" (this is the name you'll see in the dashboard; its public address is set separately)");
|
|
317
|
+
const session = openPromptSession();
|
|
318
|
+
try {
|
|
319
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
320
|
+
const answer = await session.ask(" Workspace name?", "");
|
|
321
|
+
if (answer.trim()) return answer.trim();
|
|
322
|
+
console.log("[wawesome] A workspace needs a name.");
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
session.close();
|
|
326
|
+
}
|
|
327
|
+
throw new Error("No workspace name given. Nothing was created.");
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
153
330
|
* OAuth login flow:
|
|
154
331
|
* 1. Build Supabase OAuth URL
|
|
155
332
|
* 2. Open browser
|
|
@@ -217,18 +394,24 @@ async function login(options) {
|
|
|
217
394
|
if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
|
|
218
395
|
const tenants = await tenantsRes.json();
|
|
219
396
|
let primaryTenantId;
|
|
397
|
+
let workspaceName;
|
|
398
|
+
let workspaceSlug;
|
|
220
399
|
if (tenants.length === 0) {
|
|
221
|
-
|
|
400
|
+
const chosenName = await promptForWorkspaceName(options);
|
|
401
|
+
console.log(`[wawesome] Creating workspace "${chosenName}"...`);
|
|
222
402
|
const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
|
|
223
403
|
method: "POST",
|
|
224
404
|
headers: {
|
|
225
405
|
Authorization: `Bearer ${accessToken}`,
|
|
226
406
|
"Content-Type": "application/json"
|
|
227
407
|
},
|
|
228
|
-
body: JSON.stringify({ tenant_name:
|
|
408
|
+
body: JSON.stringify({ tenant_name: chosenName })
|
|
229
409
|
});
|
|
230
410
|
if (!initRes.ok) throw new Error(`Failed to initialize workspace (HTTP ${initRes.status}).`);
|
|
231
|
-
|
|
411
|
+
const initData = await initRes.json();
|
|
412
|
+
primaryTenantId = initData.tenant_id;
|
|
413
|
+
workspaceSlug = initData.tenant_slug;
|
|
414
|
+
workspaceName = initData.tenant_name || chosenName;
|
|
232
415
|
} else primaryTenantId = tenants[0].tenant_id;
|
|
233
416
|
if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
|
|
234
417
|
console.log("[wawesome] Exchanging for tenant-scoped credentials...");
|
|
@@ -242,6 +425,8 @@ async function login(options) {
|
|
|
242
425
|
});
|
|
243
426
|
if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
|
|
244
427
|
const exchangeData = await exchangeRes.json();
|
|
428
|
+
workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
|
|
429
|
+
workspaceName = exchangeData.tenant_name || workspaceName;
|
|
245
430
|
let userEmail = exchangeData.email || "unknown";
|
|
246
431
|
try {
|
|
247
432
|
const payloadPart = exchangeData.tenant_jwt.split(".")[1];
|
|
@@ -252,14 +437,18 @@ async function login(options) {
|
|
|
252
437
|
gateway_url: gatewayUrl,
|
|
253
438
|
tenant_jwt: exchangeData.tenant_jwt,
|
|
254
439
|
tenant_id: primaryTenantId,
|
|
255
|
-
user_email: userEmail
|
|
440
|
+
user_email: userEmail,
|
|
441
|
+
tenant_slug: workspaceSlug,
|
|
442
|
+
tenant_name: workspaceName
|
|
256
443
|
});
|
|
257
444
|
console.log("\n======================================================");
|
|
258
445
|
console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
|
|
259
446
|
console.log("======================================================");
|
|
260
|
-
console.log(`\n
|
|
261
|
-
console.log(`
|
|
262
|
-
console.log(`
|
|
447
|
+
if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
|
|
448
|
+
if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
|
|
449
|
+
console.log(` Tenant: ${primaryTenantId}`);
|
|
450
|
+
console.log(` Gateway: ${gatewayUrl}`);
|
|
451
|
+
console.log(` Email: ${userEmail}`);
|
|
263
452
|
console.log("\n Credentials saved to ~/.wawesome/credentials.json");
|
|
264
453
|
console.log("======================================================\n");
|
|
265
454
|
server.close();
|
|
@@ -297,16 +486,26 @@ function logout() {
|
|
|
297
486
|
/**
|
|
298
487
|
* Show current login status.
|
|
299
488
|
*/
|
|
300
|
-
function whoami() {
|
|
489
|
+
async function whoami() {
|
|
301
490
|
const creds = readCredentials();
|
|
302
491
|
if (!creds) {
|
|
303
492
|
console.log("[wawesome] Not logged in. Run 'wawesome login' to authenticate.");
|
|
304
493
|
return;
|
|
305
494
|
}
|
|
495
|
+
let workspace = null;
|
|
496
|
+
try {
|
|
497
|
+
workspace = await resolveWorkspace(creds);
|
|
498
|
+
} catch {
|
|
499
|
+
workspace = null;
|
|
500
|
+
}
|
|
306
501
|
console.log("\n[wawesome] Current session:");
|
|
307
|
-
console.log(` Email:
|
|
308
|
-
|
|
309
|
-
|
|
502
|
+
console.log(` Email: ${creds.user_email}`);
|
|
503
|
+
if (workspace) {
|
|
504
|
+
console.log(` Workspace: ${workspace.name}`);
|
|
505
|
+
console.log(` Address: ${workspace.slug}`);
|
|
506
|
+
}
|
|
507
|
+
console.log(` Tenant: ${creds.tenant_id}`);
|
|
508
|
+
console.log(` Gateway: ${creds.gateway_url}\n`);
|
|
310
509
|
}
|
|
311
510
|
//#endregion
|
|
312
511
|
//#region src/deploy.ts
|
|
@@ -403,40 +602,849 @@ async function deploy(entryInput, options) {
|
|
|
403
602
|
console.error(`[wawesome] Error: Deployment failed (HTTP ${deployRes.status}).`);
|
|
404
603
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
405
604
|
}
|
|
406
|
-
process.exit(1);
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
let invokeUrl = null;
|
|
608
|
+
try {
|
|
609
|
+
const { slug } = await resolveWorkspace(creds);
|
|
610
|
+
invokeUrl = publicInvokeUrl(creds.gateway_url, slug, app, funcName);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (isVerbose) console.log(`[wawesome:verbose] Could not resolve the workspace address: ${err instanceof Error ? err.message : err}`);
|
|
613
|
+
}
|
|
614
|
+
console.log("\n======================================================");
|
|
615
|
+
console.log("🚀 \x1B[32mDEPLOYED SUCCESSFULLY!\x1B[0m");
|
|
616
|
+
console.log("======================================================");
|
|
617
|
+
console.log(`\n App: ${app}`);
|
|
618
|
+
console.log(` Function: ${funcName}`);
|
|
619
|
+
if (version !== void 0) console.log(` Version: ${version}`);
|
|
620
|
+
if (invokeUrl) console.log(`\n URL: \x1b[36m${invokeUrl}\x1b[0m`);
|
|
621
|
+
console.log("======================================================\n");
|
|
622
|
+
return {
|
|
623
|
+
app,
|
|
624
|
+
functionName: funcName,
|
|
625
|
+
version,
|
|
626
|
+
invokeUrl
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region src/env.ts
|
|
631
|
+
const STANDARD_SECRET_MESSAGES = [
|
|
632
|
+
"Encrypted at rest using AES-256",
|
|
633
|
+
"This value can't be viewed again after you save it — only updated or deleted",
|
|
634
|
+
"Only decrypted at the moment your function runs — never returned by the CLI, dashboard, or API after creation."
|
|
635
|
+
];
|
|
636
|
+
/**
|
|
637
|
+
* Load credentials and validate project config.
|
|
638
|
+
*/
|
|
639
|
+
function loadClientContext() {
|
|
640
|
+
const creds = readCredentials();
|
|
641
|
+
if (!creds) {
|
|
642
|
+
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
const config = readFunctionConfig();
|
|
646
|
+
if (!config || !config.app || !config.app.trim()) {
|
|
647
|
+
console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
|
|
648
|
+
console.error("[wawesome] Run 'wawesome init' to create one, or create it manually.");
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
creds,
|
|
653
|
+
app: config.app.trim()
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Store one variable on an App, reporting failure by throwing.
|
|
658
|
+
*
|
|
659
|
+
* The endpoint half of `env set`, without the exiting: template scaffolding
|
|
660
|
+
* stores a whole manifest's worth of variables and has already written files by
|
|
661
|
+
* the time it does, so it needs to say what did land before it gives up.
|
|
662
|
+
*/
|
|
663
|
+
async function putEnvVar(creds, app, key, value, isSecret) {
|
|
664
|
+
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
|
|
665
|
+
const res = await fetch(url, {
|
|
666
|
+
method: "POST",
|
|
667
|
+
headers: {
|
|
668
|
+
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
669
|
+
"Content-Type": "application/json"
|
|
670
|
+
},
|
|
671
|
+
body: JSON.stringify({
|
|
672
|
+
key,
|
|
673
|
+
value,
|
|
674
|
+
is_secret: isSecret
|
|
675
|
+
})
|
|
676
|
+
});
|
|
677
|
+
if (!res.ok) {
|
|
678
|
+
if (res.status === 401) throw new GatewayError("Authentication expired. Run 'wawesome login' again.", 401);
|
|
679
|
+
throw await asGatewayError(res, `Failed to set variable '${key}' (HTTP ${res.status}).`);
|
|
680
|
+
}
|
|
681
|
+
return await res.json();
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Set or overwrite an environment variable on the current app.
|
|
685
|
+
*/
|
|
686
|
+
async function setEnvVar(key, value, options) {
|
|
687
|
+
const isVerbose = Boolean(options.verbose);
|
|
688
|
+
if (!key || value === void 0) {
|
|
689
|
+
console.error("[wawesome] Error: Missing key or value. Usage: wawesome env set <KEY> <VALUE> [--secret]");
|
|
690
|
+
process.exit(1);
|
|
691
|
+
}
|
|
692
|
+
const { creds, app } = loadClientContext();
|
|
693
|
+
const isSecret = Boolean(options.secret);
|
|
694
|
+
if (isVerbose) {
|
|
695
|
+
console.log(`[wawesome:verbose] POST ${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`);
|
|
696
|
+
console.log(`[wawesome:verbose] Payload: key=${key}, is_secret=${isSecret}`);
|
|
697
|
+
}
|
|
698
|
+
let data;
|
|
699
|
+
try {
|
|
700
|
+
data = await putEnvVar(creds, app, key, value, isSecret);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
console.error(`[wawesome] Error: ${errorText(err)}`);
|
|
703
|
+
if (isVerbose && err instanceof GatewayError) console.error(`[wawesome:verbose] Response: ${err.body}`);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (data.variable.is_secret || isSecret) {
|
|
708
|
+
console.log(`\n[wawesome] ✅ Set secret variable '${key}' on app '${app}'.`);
|
|
709
|
+
const messages = data.notice?.messages?.length ? data.notice.messages : STANDARD_SECRET_MESSAGES;
|
|
710
|
+
console.log("\n🔒 \x1B[1mSecret Notice:\x1B[0m");
|
|
711
|
+
for (const msg of messages) console.log(` • ${msg}`);
|
|
712
|
+
console.log("");
|
|
713
|
+
} else console.log(`[wawesome] ✅ Set variable '${key}' on app '${app}'.`);
|
|
714
|
+
}
|
|
715
|
+
function truncate(str, maxLen) {
|
|
716
|
+
if (str.length <= maxLen) return str;
|
|
717
|
+
if (maxLen <= 3) return str.slice(0, maxLen);
|
|
718
|
+
return str.slice(0, maxLen - 1) + "…";
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* List environment variables on the current app.
|
|
722
|
+
*/
|
|
723
|
+
async function listEnvVars(options) {
|
|
724
|
+
const isVerbose = Boolean(options.verbose);
|
|
725
|
+
const { creds, app } = loadClientContext();
|
|
726
|
+
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
|
|
727
|
+
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
728
|
+
const res = await fetch(url, {
|
|
729
|
+
method: "GET",
|
|
730
|
+
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
731
|
+
});
|
|
732
|
+
if (!res.ok) {
|
|
733
|
+
const errorBody = await res.text();
|
|
734
|
+
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
735
|
+
else {
|
|
736
|
+
console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
|
|
737
|
+
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
738
|
+
}
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
const data = await res.json();
|
|
742
|
+
if (!data.variables || data.variables.length === 0) {
|
|
743
|
+
console.log(`[wawesome] No environment variables set for app '${app}'.`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const availableWidth = (process.stdout.columns && process.stdout.columns > 40 ? process.stdout.columns : 80) - 12;
|
|
747
|
+
let rawMaxKeyLen = 15;
|
|
748
|
+
let rawMaxValLen = 15;
|
|
749
|
+
for (const item of data.variables) {
|
|
750
|
+
if (item.key.length > rawMaxKeyLen) rawMaxKeyLen = item.key.length;
|
|
751
|
+
if (item.value.length > rawMaxValLen) rawMaxValLen = item.value.length;
|
|
752
|
+
}
|
|
753
|
+
const maxKeyLen = Math.min(rawMaxKeyLen, Math.max(15, Math.floor(availableWidth * .4)));
|
|
754
|
+
const maxValLen = Math.min(rawMaxValLen, Math.max(15, availableWidth - maxKeyLen));
|
|
755
|
+
console.log(`\n📋 \x1b[1mEnvironment variables for '${app}'\x1b[0m\n`);
|
|
756
|
+
const headerKey = "KEY".padEnd(maxKeyLen);
|
|
757
|
+
const headerVal = "VALUE".padEnd(maxValLen);
|
|
758
|
+
console.log(`${headerKey} | ${headerVal} | SECRET`);
|
|
759
|
+
console.log(`${"-".repeat(maxKeyLen)}-|- ${"-".repeat(maxValLen)} |-------`);
|
|
760
|
+
for (const item of data.variables) {
|
|
761
|
+
const truncatedKey = truncate(item.key, maxKeyLen);
|
|
762
|
+
const truncatedVal = truncate(item.value, maxValLen);
|
|
763
|
+
const keyStr = truncatedKey.padEnd(maxKeyLen);
|
|
764
|
+
const valStr = truncatedVal.padEnd(maxValLen);
|
|
765
|
+
const secretStr = item.is_secret ? "\x1B[33mtrue\x1B[0m" : "false";
|
|
766
|
+
console.log(`${keyStr} | ${valStr} | ${secretStr}`);
|
|
767
|
+
}
|
|
768
|
+
console.log("");
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Delete an environment variable on the current app.
|
|
772
|
+
*/
|
|
773
|
+
async function removeEnvVar(key, options) {
|
|
774
|
+
const isVerbose = Boolean(options.verbose);
|
|
775
|
+
if (!key) {
|
|
776
|
+
console.error("[wawesome] Error: Missing variable key. Usage: wawesome env rm <KEY>");
|
|
777
|
+
process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
const { creds, app } = loadClientContext();
|
|
780
|
+
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
|
|
781
|
+
if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
|
|
782
|
+
const res = await fetch(url, {
|
|
783
|
+
method: "DELETE",
|
|
784
|
+
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
785
|
+
});
|
|
786
|
+
if (!res.ok) {
|
|
787
|
+
const errorBody = await res.text();
|
|
788
|
+
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
789
|
+
else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
|
|
790
|
+
else {
|
|
791
|
+
console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
|
|
792
|
+
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
793
|
+
}
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
console.log(`[wawesome] ✅ Deleted variable '${key}' from app '${app}'.`);
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Dispatcher function for `wawesome env` CLI command.
|
|
800
|
+
*/
|
|
801
|
+
async function envCommand(action, key, value, options) {
|
|
802
|
+
if (!action || action === "list" || action === "ls") return listEnvVars(options);
|
|
803
|
+
if (action === "set") return setEnvVar(key, value, options);
|
|
804
|
+
if (action === "rm" || action === "remove" || action === "delete" || action === "unset") return removeEnvVar(key, options);
|
|
805
|
+
console.error(`[wawesome] Error: Unknown env action '${action}'. Available actions: list, set, rm.`);
|
|
806
|
+
process.exit(1);
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/provision.ts
|
|
810
|
+
/** The gateway's own words for a failure, or a plain HTTP status if it gave none. */
|
|
811
|
+
async function rejected(res, fallback) {
|
|
812
|
+
if (res.status === 401) return new GatewayError("Authentication expired. Run 'wawesome login' again.", 401);
|
|
813
|
+
return asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
|
|
814
|
+
}
|
|
815
|
+
function authHeaders(creds) {
|
|
816
|
+
return {
|
|
817
|
+
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
818
|
+
"Content-Type": "application/json"
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Make sure the App exists before anything is attached to it.
|
|
823
|
+
*
|
|
824
|
+
* Environment variables and subscriptions are both App-scoped, and neither
|
|
825
|
+
* endpoint creates one — deploy would, but it runs last, and a Function that
|
|
826
|
+
* goes live before its secrets do is a Function that fails its first request.
|
|
827
|
+
*
|
|
828
|
+
* Listing first rather than creating and reading the error keeps the common
|
|
829
|
+
* case ("I already have this App") off the failure path; an App that appears in
|
|
830
|
+
* between is still treated as the success it is.
|
|
831
|
+
*/
|
|
832
|
+
async function ensureApp(creds, appSlug) {
|
|
833
|
+
const listRes = await fetch(`${creds.gateway_url}/v1/apps`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
834
|
+
if (!listRes.ok) throw await rejected(listRes, "Could not list the Tenant's apps");
|
|
835
|
+
if ((await listRes.json()).applications?.some((app) => app.slug === appSlug)) return;
|
|
836
|
+
const createRes = await fetch(`${creds.gateway_url}/v1/apps`, {
|
|
837
|
+
method: "POST",
|
|
838
|
+
headers: authHeaders(creds),
|
|
839
|
+
body: JSON.stringify({
|
|
840
|
+
name: appSlug,
|
|
841
|
+
slug: appSlug
|
|
842
|
+
})
|
|
843
|
+
});
|
|
844
|
+
if (createRes.ok) return;
|
|
845
|
+
const error = await rejected(createRes, `Could not create the app '${appSlug}'`);
|
|
846
|
+
if (/already exists/i.test(error.message)) return;
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Enable one catalogue provider for the App.
|
|
851
|
+
*
|
|
852
|
+
* A template declares the providers it calls; without the subscription the
|
|
853
|
+
* platform's egress policy denies those calls, so this is what stands between a
|
|
854
|
+
* scaffolded template and an outbound request that silently fails at runtime.
|
|
855
|
+
*/
|
|
856
|
+
async function enableEgressProvider(creds, appSlug, providerKey) {
|
|
857
|
+
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(appSlug)}/egress/subscriptions`;
|
|
858
|
+
const res = await fetch(url, {
|
|
859
|
+
method: "POST",
|
|
860
|
+
headers: authHeaders(creds),
|
|
861
|
+
body: JSON.stringify({ provider_key: providerKey })
|
|
862
|
+
});
|
|
863
|
+
if (!res.ok) throw await rejected(res, `Could not enable outbound provider '${providerKey}'`);
|
|
864
|
+
}
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region src/scaffold.ts
|
|
867
|
+
const MANIFEST_FILE = "template.json";
|
|
868
|
+
const PACKAGE_FILE = "package.json";
|
|
869
|
+
const FUNCTION_CONFIG_FILE = "wawesome-function.json";
|
|
870
|
+
/** Read the template's requirements declaration out of a scaffolded directory. */
|
|
871
|
+
function readTemplateManifest(dir) {
|
|
872
|
+
const manifestPath = path.join(dir, MANIFEST_FILE);
|
|
873
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`The template has no ${MANIFEST_FILE}, so there is nothing to set it up from.`);
|
|
874
|
+
let raw;
|
|
875
|
+
try {
|
|
876
|
+
raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
877
|
+
} catch (err) {
|
|
878
|
+
throw new Error(`The template's ${MANIFEST_FILE} is not valid JSON: ${errorText(err)}`);
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
env: Array.isArray(raw.env) ? raw.env : [],
|
|
882
|
+
providers: Array.isArray(raw.providers) ? raw.providers : [],
|
|
883
|
+
post_deploy: typeof raw.post_deploy === "string" ? raw.post_deploy : ""
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Patch the two configuration files, and nothing else.
|
|
888
|
+
*
|
|
889
|
+
* Every placeholder originally scoped for substitution turned out to be
|
|
890
|
+
* something else: the Tenant slug never appears in template source, and a
|
|
891
|
+
* template's environment-variable *names* are fixed by its own code. What is
|
|
892
|
+
* left is identity — which App this is, and what the Function is called.
|
|
893
|
+
*/
|
|
894
|
+
function applyTemplateIdentity(dir, identity) {
|
|
895
|
+
patchJson(path.join(dir, PACKAGE_FILE), (pkg) => ({
|
|
896
|
+
...pkg,
|
|
897
|
+
name: identity.functionName
|
|
898
|
+
}));
|
|
899
|
+
patchJson(path.join(dir, FUNCTION_CONFIG_FILE), (config) => ({
|
|
900
|
+
...config,
|
|
901
|
+
app: identity.app,
|
|
902
|
+
function: identity.functionName
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
function patchJson(file, patch) {
|
|
906
|
+
if (!fs.existsSync(file)) throw new Error(`The template is missing ${path.basename(file)}, which scaffolding must patch.`);
|
|
907
|
+
let current;
|
|
908
|
+
try {
|
|
909
|
+
current = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
910
|
+
} catch (err) {
|
|
911
|
+
throw new Error(`The template's ${path.basename(file)} is not valid JSON: ${errorText(err)}`);
|
|
912
|
+
}
|
|
913
|
+
fs.writeFileSync(file, JSON.stringify(patch(current), null, 2) + "\n", "utf-8");
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Which of the files a scaffold is about to write are already there.
|
|
917
|
+
*
|
|
918
|
+
* Checked before the first byte is written: half a template over half a project
|
|
919
|
+
* is worse than refusing, and the user is the only one who knows which they
|
|
920
|
+
* meant to keep.
|
|
921
|
+
*/
|
|
922
|
+
function collidingPaths(dir, relativePaths) {
|
|
923
|
+
return relativePaths.filter((relative) => fs.existsSync(path.join(dir, relative)));
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Fill the post-deploy message in. `{{url}}` is the only placeholder anywhere in
|
|
927
|
+
* the template system, and it never touches a source file — if the address could
|
|
928
|
+
* not be resolved the message still prints, with the placeholder left visible
|
|
929
|
+
* rather than a confident "undefined".
|
|
930
|
+
*/
|
|
931
|
+
function renderPostDeploy(postDeploy, invokeUrl) {
|
|
932
|
+
if (!invokeUrl) return postDeploy;
|
|
933
|
+
return postDeploy.split("{{url}}").join(invokeUrl);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Normalise arbitrary input into a legal DNS label, returning an empty string
|
|
937
|
+
* when nothing legal survives — callers decide whether that is an error.
|
|
938
|
+
*/
|
|
939
|
+
function sanitizeSlug(raw) {
|
|
940
|
+
let label = "";
|
|
941
|
+
for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z") label += c;
|
|
942
|
+
else if (c >= "0" && c <= "9") label += c;
|
|
943
|
+
else if (label.length > 0 && !label.endsWith("-")) label += "-";
|
|
944
|
+
label = label.slice(0, 63);
|
|
945
|
+
while (label.endsWith("-")) label = label.slice(0, -1);
|
|
946
|
+
return label;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Whether `candidate` is already a legal DNS label — i.e. whether
|
|
950
|
+
* {@link sanitizeSlug} would leave it untouched.
|
|
951
|
+
*/
|
|
952
|
+
function isLegalSlug(candidate) {
|
|
953
|
+
return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
|
|
954
|
+
}
|
|
955
|
+
//#endregion
|
|
956
|
+
//#region src/tarball.ts
|
|
957
|
+
/**
|
|
958
|
+
* Reading a repository archive, so scaffolding needs no git.
|
|
959
|
+
*
|
|
960
|
+
* Git being absent is the common case for the audience this platform is for, and
|
|
961
|
+
* on Windows it is the *normal* case — so the template arrives as a `.tar.gz`
|
|
962
|
+
* over plain HTTP and is unpacked here. Node ships gzip; the tar half is 512-byte
|
|
963
|
+
* blocks, which is little enough code that carrying a dependency for it would
|
|
964
|
+
* cost more than it saves.
|
|
965
|
+
*
|
|
966
|
+
* An archive is untrusted input even when it comes from a URL the platform
|
|
967
|
+
* chose: extraction refuses anything whose path could land outside the
|
|
968
|
+
* destination, and refuses links entirely, before writing a single file.
|
|
969
|
+
*/
|
|
970
|
+
const BLOCK_SIZE = 512;
|
|
971
|
+
/** Where each field a header carries begins, and how long it is. */
|
|
972
|
+
const NAME = {
|
|
973
|
+
at: 0,
|
|
974
|
+
len: 100
|
|
975
|
+
};
|
|
976
|
+
const MODE = {
|
|
977
|
+
at: 100,
|
|
978
|
+
len: 8
|
|
979
|
+
};
|
|
980
|
+
const SIZE = {
|
|
981
|
+
at: 124,
|
|
982
|
+
len: 12
|
|
983
|
+
};
|
|
984
|
+
const TYPE_FLAG = 156;
|
|
985
|
+
const MAGIC = {
|
|
986
|
+
at: 257,
|
|
987
|
+
len: 6
|
|
988
|
+
};
|
|
989
|
+
const PREFIX = {
|
|
990
|
+
at: 345,
|
|
991
|
+
len: 155
|
|
992
|
+
};
|
|
993
|
+
/** Entry types extraction acts on. Everything else is metadata or refused. */
|
|
994
|
+
const TYPE_FILE = "0";
|
|
995
|
+
const TYPE_FILE_ALT = "\0";
|
|
996
|
+
const TYPE_DIRECTORY = "5";
|
|
997
|
+
const TYPE_SYMLINK = "2";
|
|
998
|
+
const TYPE_HARDLINK = "1";
|
|
999
|
+
const TYPE_PAX_NEXT = "x";
|
|
1000
|
+
const TYPE_PAX_GLOBAL = "g";
|
|
1001
|
+
const TYPE_GNU_LONGNAME = "L";
|
|
1002
|
+
/** A tar the CLI cannot make sense of, phrased for whoever is watching. */
|
|
1003
|
+
var ArchiveError = class extends Error {};
|
|
1004
|
+
function field(block, at, len) {
|
|
1005
|
+
const raw = block.toString("utf-8", at, at + len);
|
|
1006
|
+
const end = raw.indexOf("\0");
|
|
1007
|
+
return end === -1 ? raw : raw.slice(0, end);
|
|
1008
|
+
}
|
|
1009
|
+
function octal(block, at, len) {
|
|
1010
|
+
const text = field(block, at, len).trim();
|
|
1011
|
+
if (!text) return 0;
|
|
1012
|
+
const value = parseInt(text, 8);
|
|
1013
|
+
return Number.isFinite(value) ? value : 0;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* The path an entry claims, honouring the two ways a long one is recorded:
|
|
1017
|
+
* split across the `ustar` prefix field, or carried by the pax header that
|
|
1018
|
+
* precedes it. Both come out of a real `git archive` — which one you get depends
|
|
1019
|
+
* only on where the path's slashes happen to fall.
|
|
1020
|
+
*/
|
|
1021
|
+
function entryPath(block, paxPath) {
|
|
1022
|
+
if (paxPath) return paxPath;
|
|
1023
|
+
const name = field(block, NAME.at, NAME.len);
|
|
1024
|
+
const prefix = field(block, PREFIX.at, PREFIX.len);
|
|
1025
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
1026
|
+
}
|
|
1027
|
+
/** The `path=` record of a pax extended header, if it declares one. */
|
|
1028
|
+
function paxHeaderPath(body) {
|
|
1029
|
+
let offset = 0;
|
|
1030
|
+
while (offset < body.length) {
|
|
1031
|
+
const space = body.indexOf(32, offset);
|
|
1032
|
+
if (space === -1) break;
|
|
1033
|
+
const length = parseInt(body.toString("ascii", offset, space), 10);
|
|
1034
|
+
if (!Number.isFinite(length) || length <= 0) break;
|
|
1035
|
+
const record = body.toString("utf-8", space + 1, offset + length).replace(/\n$/, "");
|
|
1036
|
+
const eq = record.indexOf("=");
|
|
1037
|
+
if (eq !== -1 && record.slice(0, eq) === "path") return record.slice(eq + 1);
|
|
1038
|
+
offset += length;
|
|
1039
|
+
}
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Every file in a tar, in archive order.
|
|
1044
|
+
*
|
|
1045
|
+
* Refuses links rather than skipping them: a symlink extracted into the
|
|
1046
|
+
* destination turns a later, innocent-looking entry into a write anywhere on
|
|
1047
|
+
* the filesystem, and no template has a reason to ship one.
|
|
1048
|
+
*/
|
|
1049
|
+
function readEntries(tar) {
|
|
1050
|
+
const entries = [];
|
|
1051
|
+
let paxPath = null;
|
|
1052
|
+
let offset = 0;
|
|
1053
|
+
let sawHeader = false;
|
|
1054
|
+
while (offset + BLOCK_SIZE <= tar.length) {
|
|
1055
|
+
const block = tar.subarray(offset, offset + BLOCK_SIZE);
|
|
1056
|
+
offset += BLOCK_SIZE;
|
|
1057
|
+
if (block.every((byte) => byte === 0)) break;
|
|
1058
|
+
if (!field(block, MAGIC.at, MAGIC.len).startsWith("ustar")) throw new ArchiveError("The downloaded file is not a tar archive.");
|
|
1059
|
+
sawHeader = true;
|
|
1060
|
+
const size = octal(block, SIZE.at, SIZE.len);
|
|
1061
|
+
const bodyEnd = offset + size;
|
|
1062
|
+
if (bodyEnd > tar.length) throw new ArchiveError("The downloaded archive is truncated.");
|
|
1063
|
+
const body = tar.subarray(offset, bodyEnd);
|
|
1064
|
+
offset += Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
|
|
1065
|
+
const type = String.fromCharCode(block[TYPE_FLAG]);
|
|
1066
|
+
if (type === TYPE_PAX_NEXT || type === TYPE_GNU_LONGNAME) {
|
|
1067
|
+
paxPath = type === TYPE_GNU_LONGNAME ? field(body, 0, body.length) : paxHeaderPath(body);
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (type === TYPE_PAX_GLOBAL) continue;
|
|
1071
|
+
const recorded = entryPath(block, paxPath);
|
|
1072
|
+
paxPath = null;
|
|
1073
|
+
if (type === TYPE_SYMLINK || type === TYPE_HARDLINK) throw new ArchiveError(`The archive contains a link ('${recorded}'), which is not extracted.`);
|
|
1074
|
+
if (type === TYPE_DIRECTORY) continue;
|
|
1075
|
+
if (type !== TYPE_FILE && type !== TYPE_FILE_ALT) continue;
|
|
1076
|
+
entries.push({
|
|
1077
|
+
path: recorded,
|
|
1078
|
+
mode: octal(block, MODE.at, MODE.len),
|
|
1079
|
+
body
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
if (!sawHeader) throw new ArchiveError("The downloaded file is not a tar archive.");
|
|
1083
|
+
return entries;
|
|
1084
|
+
}
|
|
1085
|
+
/** Drop the archive's single root directory, the way `--strip-components=1` does. */
|
|
1086
|
+
function withoutArchiveRoot(entryPath) {
|
|
1087
|
+
const firstSlash = entryPath.indexOf("/");
|
|
1088
|
+
if (firstSlash === -1) return null;
|
|
1089
|
+
return entryPath.slice(firstSlash + 1);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The path an entry takes inside the template directory, or `null` if it belongs
|
|
1093
|
+
* to a different one. `.` means the template owns the whole repository.
|
|
1094
|
+
*/
|
|
1095
|
+
function withinDirectory(entryPath, directory) {
|
|
1096
|
+
if (directory === "." || directory === "") return entryPath;
|
|
1097
|
+
const prefix = `${directory}/`;
|
|
1098
|
+
return entryPath.startsWith(prefix) ? entryPath.slice(prefix.length) : null;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Unpack one template directory out of a repository archive.
|
|
1102
|
+
*
|
|
1103
|
+
* Contents are written exactly as they were recorded — a template is a real
|
|
1104
|
+
* project, so nothing here rewrites, re-encodes or substitutes anything. The
|
|
1105
|
+
* whole archive is validated before the first write, so a refused entry leaves
|
|
1106
|
+
* the destination as it found it rather than half-scaffolded.
|
|
1107
|
+
*
|
|
1108
|
+
* Returns the paths written, relative to `destination`.
|
|
1109
|
+
*/
|
|
1110
|
+
function extractTemplateDirectory(archive, options) {
|
|
1111
|
+
let tar;
|
|
1112
|
+
try {
|
|
1113
|
+
tar = zlib.gunzipSync(archive);
|
|
1114
|
+
} catch {
|
|
1115
|
+
throw new ArchiveError("The downloaded file is not a gzip archive.");
|
|
1116
|
+
}
|
|
1117
|
+
const { directory, destination } = options;
|
|
1118
|
+
const root = path.resolve(destination);
|
|
1119
|
+
const planned = [];
|
|
1120
|
+
for (const entry of readEntries(tar)) {
|
|
1121
|
+
if (path.posix.isAbsolute(entry.path) || /^[A-Za-z]:/.test(entry.path)) throw new ArchiveError(`The archive contains an absolute path ('${entry.path}').`);
|
|
1122
|
+
const stripped = withoutArchiveRoot(entry.path);
|
|
1123
|
+
if (stripped === null) continue;
|
|
1124
|
+
const relative = withinDirectory(stripped, directory);
|
|
1125
|
+
if (relative === null || relative === "") continue;
|
|
1126
|
+
const absolute = path.resolve(root, relative);
|
|
1127
|
+
if (absolute !== root && !absolute.startsWith(root + path.sep)) throw new ArchiveError(`The archive contains a path outside the project ('${entry.path}').`);
|
|
1128
|
+
planned.push({
|
|
1129
|
+
absolute,
|
|
1130
|
+
relative,
|
|
1131
|
+
entry
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
if (planned.length === 0) throw new ArchiveError(`The archive contains no directory named '${directory}'.`);
|
|
1135
|
+
for (const { absolute, entry } of planned) {
|
|
1136
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
1137
|
+
fs.writeFileSync(absolute, entry.body, { mode: entry.mode & 511 || 420 });
|
|
1138
|
+
}
|
|
1139
|
+
return planned.map(({ relative }) => relative);
|
|
1140
|
+
}
|
|
1141
|
+
//#endregion
|
|
1142
|
+
//#region src/templates.ts
|
|
1143
|
+
/**
|
|
1144
|
+
* Read the template catalogue.
|
|
1145
|
+
*
|
|
1146
|
+
* Deliberately unauthenticated: discovery has to work for someone who has not
|
|
1147
|
+
* signed up yet, so no credential is read and none is sent — a stale token file
|
|
1148
|
+
* left over from a previous install cannot turn this into a rejection.
|
|
1149
|
+
*/
|
|
1150
|
+
async function fetchTemplateCatalog(gatewayUrl) {
|
|
1151
|
+
let res;
|
|
1152
|
+
try {
|
|
1153
|
+
res = await fetch(`${gatewayUrl}/v1/templates`);
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
throw new Error(`Could not reach ${gatewayUrl} to list templates. ${err instanceof Error ? err.message : err}`);
|
|
1156
|
+
}
|
|
1157
|
+
if (!res.ok) throw new Error(`${gatewayUrl} could not list templates (HTTP ${res.status}).`);
|
|
1158
|
+
let data;
|
|
1159
|
+
try {
|
|
1160
|
+
data = await res.json();
|
|
1161
|
+
} catch {
|
|
1162
|
+
throw new Error(`${gatewayUrl} answered with something that is not a template list.`);
|
|
1163
|
+
}
|
|
1164
|
+
if (!Array.isArray(data.templates)) throw new Error(`${gatewayUrl} answered with something that is not a template list.`);
|
|
1165
|
+
return data.templates;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Find one template by the name a user typed after `--template`.
|
|
1169
|
+
*
|
|
1170
|
+
* Resolved against the catalogue rather than a list compiled in here, so a
|
|
1171
|
+
* template added yesterday is scaffoldable by a CLI released last year. An
|
|
1172
|
+
* unknown name answers with the names that do exist: the mistake is almost
|
|
1173
|
+
* always a typo, and the fix is on screen.
|
|
1174
|
+
*/
|
|
1175
|
+
async function resolveTemplate(gatewayUrl, name) {
|
|
1176
|
+
const templates = await fetchTemplateCatalog(gatewayUrl);
|
|
1177
|
+
const match = templates.find((template) => template.name === name);
|
|
1178
|
+
if (match) return match;
|
|
1179
|
+
if (templates.length === 0) throw new Error(`No templates are available from ${gatewayUrl} yet.`);
|
|
1180
|
+
const available = templates.map((template) => template.name).join(", ");
|
|
1181
|
+
throw new Error(`No template called '${name}'. Available templates: ${available}.`);
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Download a template's archive.
|
|
1185
|
+
*
|
|
1186
|
+
* From the archive host the catalogue named, rather than the REST API's tarball
|
|
1187
|
+
* endpoint: that one is rate-limited per IP for unauthenticated callers, so
|
|
1188
|
+
* behind corporate NAT or on a shared runner the very first thing a new user
|
|
1189
|
+
* does would fail intermittently, for a reason they cannot diagnose.
|
|
1190
|
+
*/
|
|
1191
|
+
async function downloadTemplateArchive(archiveUrl) {
|
|
1192
|
+
let res;
|
|
1193
|
+
try {
|
|
1194
|
+
res = await fetch(archiveUrl);
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
throw new Error(`Could not download the template from ${archiveUrl}. ${errorText(err)}`);
|
|
1197
|
+
}
|
|
1198
|
+
if (!res.ok) throw new Error(`Could not download the template from ${archiveUrl} (HTTP ${res.status}). The template's stable ref may not have been published yet.`);
|
|
1199
|
+
return Buffer.from(await res.arrayBuffer());
|
|
1200
|
+
}
|
|
1201
|
+
/** List every template in the catalogue. */
|
|
1202
|
+
async function listTemplates(options) {
|
|
1203
|
+
const gatewayUrl = getGatewayUrl(options.api);
|
|
1204
|
+
if (options.verbose) console.log(`[wawesome:verbose] GET ${gatewayUrl}/v1/templates`);
|
|
1205
|
+
let templates;
|
|
1206
|
+
try {
|
|
1207
|
+
templates = await fetchTemplateCatalog(gatewayUrl);
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (templates.length === 0) {
|
|
1214
|
+
console.log("[wawesome] No templates available yet.");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
console.log("\n📦 \x1B[1mTemplates\x1B[0m\n");
|
|
1218
|
+
for (const template of templates) {
|
|
1219
|
+
console.log(` \x1b[36m${template.name}\x1b[0m — ${template.title}`);
|
|
1220
|
+
console.log(` ${template.description}`);
|
|
1221
|
+
if (template.tags.length > 0) console.log(` \x1b[90m${template.tags.join(", ")}\x1b[0m`);
|
|
1222
|
+
console.log(` \x1b[90m${template.source_url}\x1b[0m`);
|
|
1223
|
+
if (options.verbose) console.log(` \x1b[90mref ${template.ref} · ${template.archive_url}\x1b[0m`);
|
|
1224
|
+
console.log("");
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/** Dispatch for `wawesome templates [action]`. */
|
|
1228
|
+
async function templatesCommand(action, options) {
|
|
1229
|
+
if (!action || action === "list" || action === "ls") return listTemplates(options);
|
|
1230
|
+
console.error(`[wawesome] Error: Unknown templates action '${action}'. Available actions: list (alias: ls).`);
|
|
1231
|
+
process.exit(1);
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
//#region src/init.ts
|
|
1235
|
+
/** Bound on the re-prompt loop, so unusable input exits instead of looping. */
|
|
1236
|
+
const MAX_SLUG_ATTEMPTS = 5;
|
|
1237
|
+
/**
|
|
1238
|
+
* Ask for a name that becomes part of the public URL, until the answer is one.
|
|
1239
|
+
*
|
|
1240
|
+
* Both names this asks for — the App the Function belongs to, and the Function
|
|
1241
|
+
* itself — end up in the address a client reads, so neither may be taken as
|
|
1242
|
+
* typed. An illegal answer is re-offered as a suggestion rather than rewritten:
|
|
1243
|
+
* the user sees the name they will get before agreeing to it.
|
|
1244
|
+
*/
|
|
1245
|
+
async function promptForSlug(session, label, proposed) {
|
|
1246
|
+
let suggestion = sanitizeSlug(proposed);
|
|
1247
|
+
for (let attempt = 0; attempt < MAX_SLUG_ATTEMPTS; attempt++) {
|
|
1248
|
+
const answer = await session.ask(` ${label}?`, suggestion);
|
|
1249
|
+
if (isLegalSlug(answer)) return answer;
|
|
1250
|
+
const sanitized = sanitizeSlug(answer);
|
|
1251
|
+
if (sanitized) {
|
|
1252
|
+
console.log(`[wawesome] '${answer}' can't be used as ${anArticle(label)}. Suggested: '${sanitized}'.`);
|
|
1253
|
+
suggestion = sanitized;
|
|
1254
|
+
} else console.log(`[wawesome] ${anArticle(label, true)} needs lowercase letters or numbers, separated by single hyphens.`);
|
|
1255
|
+
}
|
|
1256
|
+
console.error(`[wawesome] Error: no usable ${label} after ${MAX_SLUG_ATTEMPTS} attempts. Nothing was written.`);
|
|
1257
|
+
console.error(`[wawesome] Run 'wawesome init' again once you know what to call it.`);
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
/** "an App slug" / "A Function name" — so one prompt reads for both names. */
|
|
1261
|
+
function anArticle(label, capitalized = false) {
|
|
1262
|
+
const article = /^[aeiou]/i.test(label) ? "an" : "a";
|
|
1263
|
+
return `${capitalized ? article[0].toUpperCase() + article.slice(1) : article} ${label}`;
|
|
1264
|
+
}
|
|
1265
|
+
/** Report a failure the user can act on, and stop. */
|
|
1266
|
+
function fail(message, ...advice) {
|
|
1267
|
+
console.error(`[wawesome] Error: ${message}`);
|
|
1268
|
+
for (const line of advice) console.error(`[wawesome] ${line}`);
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
throw new Error(message);
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Ask for every variable the template declared.
|
|
1274
|
+
*
|
|
1275
|
+
* The pointer to where the value lives is shown with the question, because the
|
|
1276
|
+
* user is being asked for a value that exists in somebody else's dashboard and
|
|
1277
|
+
* hunting for it in third-party documentation is where a first deploy stalls.
|
|
1278
|
+
* A blank answer is allowed even for a required variable: for a webhook secret
|
|
1279
|
+
* the value does not exist yet — the endpoint has to be created first — so
|
|
1280
|
+
* refusing to continue would be refusing the only order that works.
|
|
1281
|
+
*/
|
|
1282
|
+
async function promptForDeclaredEnv(session, declared) {
|
|
1283
|
+
if (declared.length === 0) return [];
|
|
1284
|
+
console.log("\n[wawesome] This template needs a few values before it can run.\n");
|
|
1285
|
+
const answers = [];
|
|
1286
|
+
for (const variable of declared) {
|
|
1287
|
+
const flags = [variable.required ? "required" : "optional"];
|
|
1288
|
+
if (variable.secret) flags.push("stored write-only");
|
|
1289
|
+
console.log(` \x1b[1m${variable.key}\x1b[0m \x1b[90m(${flags.join(", ")})\x1b[0m`);
|
|
1290
|
+
console.log(` ${variable.description}`);
|
|
1291
|
+
console.log(` \x1b[90mWhere to find it: ${variable.where_to_find}\x1b[0m`);
|
|
1292
|
+
const value = await session.ask(` ${variable.key}?`, "");
|
|
1293
|
+
console.log("");
|
|
1294
|
+
answers.push({
|
|
1295
|
+
declared: variable,
|
|
1296
|
+
value
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
return answers;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Satisfy the template's declarations against the platform.
|
|
1303
|
+
*
|
|
1304
|
+
* The manifest declares; nothing here is template-specific. Each declaration
|
|
1305
|
+
* goes through an endpoint the platform already ships, which is what keeps a new
|
|
1306
|
+
* template from needing a CLI release.
|
|
1307
|
+
*/
|
|
1308
|
+
async function wireUp(creds, appSlug, manifest, answers) {
|
|
1309
|
+
const scaffolded = "Your project is scaffolded — fix this, then run 'wawesome deploy'.";
|
|
1310
|
+
try {
|
|
1311
|
+
await ensureApp(creds, appSlug);
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
fail(errorText(err), scaffolded);
|
|
1314
|
+
}
|
|
1315
|
+
for (const { declared, value } of answers) {
|
|
1316
|
+
if (!value) {
|
|
1317
|
+
const secretFlag = declared.secret ? " --secret" : "";
|
|
1318
|
+
console.log(`[wawesome] ${declared.key} left blank. Set it with: wawesome env set ${declared.key} <value>${secretFlag}`);
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
try {
|
|
1322
|
+
await putEnvVar(creds, appSlug, declared.key, value, declared.secret);
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
fail(errorText(err), scaffolded);
|
|
1325
|
+
}
|
|
1326
|
+
console.log(`[wawesome] ✅ Stored ${declared.key}${declared.secret ? " (secret)" : ""}.`);
|
|
1327
|
+
}
|
|
1328
|
+
for (const provider of manifest.providers) {
|
|
1329
|
+
try {
|
|
1330
|
+
await enableEgressProvider(creds, appSlug, provider);
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
fail(errorText(err), scaffolded);
|
|
1333
|
+
}
|
|
1334
|
+
console.log(`[wawesome] ✅ Enabled outbound calls to '${provider}'.`);
|
|
407
1335
|
}
|
|
408
|
-
console.log("\n======================================================");
|
|
409
|
-
console.log("🚀 \x1B[32mDEPLOYED SUCCESSFULLY!\x1B[0m");
|
|
410
|
-
console.log("======================================================");
|
|
411
|
-
console.log(`\n App: ${app}`);
|
|
412
|
-
console.log(` Function: ${funcName}`);
|
|
413
|
-
if (version !== void 0) console.log(` Version: ${version}`);
|
|
414
|
-
console.log("======================================================\n");
|
|
415
1336
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1337
|
+
/**
|
|
1338
|
+
* Scaffold from a catalogue template, wire up what it declared, and deploy it.
|
|
1339
|
+
*
|
|
1340
|
+
* The template arrives as a repository archive over plain HTTP — no git, which
|
|
1341
|
+
* makes this work on a machine that has never had it installed, and on Windows.
|
|
1342
|
+
* It is unpacked into a staging directory first, so a template that turns out to
|
|
1343
|
+
* collide with what is already here leaves the directory exactly as it found it.
|
|
1344
|
+
*/
|
|
1345
|
+
async function initFromTemplate(templateName, options) {
|
|
1346
|
+
const isVerbose = Boolean(options.verbose);
|
|
1347
|
+
const gatewayUrl = getGatewayUrl(options.api);
|
|
1348
|
+
const cwd = process.cwd();
|
|
1349
|
+
const dirName = path.basename(cwd);
|
|
1350
|
+
let template;
|
|
1351
|
+
try {
|
|
1352
|
+
template = await resolveTemplate(gatewayUrl, templateName);
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
fail(errorText(err));
|
|
1355
|
+
}
|
|
1356
|
+
console.log(`[wawesome] Fetching ${template.title} (${template.ref})...`);
|
|
1357
|
+
if (isVerbose) console.log(`[wawesome:verbose] GET ${template.archive_url}`);
|
|
1358
|
+
let archive;
|
|
1359
|
+
try {
|
|
1360
|
+
archive = await downloadTemplateArchive(template.archive_url);
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
fail(errorText(err));
|
|
1363
|
+
}
|
|
1364
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "wawesome-template-"));
|
|
1365
|
+
const sweepStaging = () => fs.rmSync(staging, {
|
|
1366
|
+
recursive: true,
|
|
1367
|
+
force: true
|
|
422
1368
|
});
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1369
|
+
process.once("exit", sweepStaging);
|
|
1370
|
+
const session = openPromptSession();
|
|
1371
|
+
let functionName;
|
|
1372
|
+
let appSlug;
|
|
1373
|
+
let manifest;
|
|
1374
|
+
try {
|
|
1375
|
+
let files;
|
|
1376
|
+
try {
|
|
1377
|
+
files = extractTemplateDirectory(archive, {
|
|
1378
|
+
directory: template.directory,
|
|
1379
|
+
destination: staging
|
|
1380
|
+
});
|
|
1381
|
+
manifest = readTemplateManifest(staging);
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
fail(errorText(err));
|
|
1384
|
+
}
|
|
1385
|
+
const collisions = collidingPaths(cwd, files);
|
|
1386
|
+
if (collisions.length > 0) fail(`${collisions.join(", ")} already exist${collisions.length === 1 ? "s" : ""} here.`, "Run this in an empty directory, or move those files aside first. Nothing was written.");
|
|
1387
|
+
functionName = await promptForSlug(session, "Function name", readFunctionConfig(staging)?.function || dirName);
|
|
1388
|
+
console.log(" (an App groups the Functions of one project — its slug is part of the public URL)");
|
|
1389
|
+
appSlug = await promptForSlug(session, "App slug", dirName);
|
|
1390
|
+
try {
|
|
1391
|
+
fs.cpSync(staging, cwd, { recursive: true });
|
|
1392
|
+
applyTemplateIdentity(cwd, {
|
|
1393
|
+
app: appSlug,
|
|
1394
|
+
functionName
|
|
1395
|
+
});
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
fail(errorText(err), `Some of '${template.name}' may have been written to ${cwd}.`);
|
|
1398
|
+
}
|
|
1399
|
+
console.log(`\n[wawesome] ✅ Scaffolded ${files.length} files from '${template.name}'.`);
|
|
1400
|
+
} finally {
|
|
1401
|
+
sweepStaging();
|
|
1402
|
+
process.off("exit", sweepStaging);
|
|
1403
|
+
}
|
|
1404
|
+
let answers = [];
|
|
1405
|
+
let creds;
|
|
1406
|
+
try {
|
|
1407
|
+
creds = readCredentials();
|
|
1408
|
+
if (creds) answers = await promptForDeclaredEnv(session, manifest.env);
|
|
1409
|
+
} finally {
|
|
1410
|
+
session.close();
|
|
1411
|
+
}
|
|
1412
|
+
if (!creds) {
|
|
1413
|
+
console.log("\n[wawesome] 🎉 Project ready. Next steps:");
|
|
1414
|
+
console.log(" 1. npm install");
|
|
1415
|
+
console.log(" 2. wawesome login");
|
|
1416
|
+
console.log(" 3. wawesome deploy\n");
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
await wireUp(creds, appSlug, manifest, answers);
|
|
1420
|
+
const result = await deploy(void 0, {
|
|
1421
|
+
out: "dist/index.js",
|
|
1422
|
+
verbose: options.verbose
|
|
428
1423
|
});
|
|
1424
|
+
if (manifest.post_deploy) {
|
|
1425
|
+
console.log(renderPostDeploy(manifest.post_deploy, result.invokeUrl));
|
|
1426
|
+
console.log("");
|
|
1427
|
+
}
|
|
429
1428
|
}
|
|
430
1429
|
/**
|
|
431
1430
|
* Scaffold a new wawesome function project in the current directory.
|
|
432
1431
|
*/
|
|
433
1432
|
async function init(options) {
|
|
1433
|
+
if (options.template) return initFromTemplate(options.template, options);
|
|
434
1434
|
const isVerbose = Boolean(options.verbose);
|
|
435
1435
|
const cwd = process.cwd();
|
|
436
1436
|
const dirName = path.basename(cwd);
|
|
437
1437
|
console.log("[wawesome] Initializing a new function project...\n");
|
|
438
|
-
const
|
|
439
|
-
|
|
1438
|
+
const session = openPromptSession();
|
|
1439
|
+
let functionName;
|
|
1440
|
+
let appSlug;
|
|
1441
|
+
try {
|
|
1442
|
+
functionName = await promptForSlug(session, "Function name", dirName);
|
|
1443
|
+
console.log(" (an App groups the Functions of one project — its slug is part of the public URL)");
|
|
1444
|
+
appSlug = await promptForSlug(session, "App slug", dirName);
|
|
1445
|
+
} finally {
|
|
1446
|
+
session.close();
|
|
1447
|
+
}
|
|
440
1448
|
const entry = "src/index.ts";
|
|
441
1449
|
if (isVerbose) console.log(`[wawesome:verbose] function=${functionName}, app=${appSlug}, entry=${entry}`);
|
|
442
1450
|
const configContent = {
|
|
@@ -666,177 +1674,6 @@ async function switchVersion(targetInput, options) {
|
|
|
666
1674
|
console.log(`======================================================\n`);
|
|
667
1675
|
}
|
|
668
1676
|
//#endregion
|
|
669
|
-
//#region src/env.ts
|
|
670
|
-
const STANDARD_SECRET_MESSAGES = [
|
|
671
|
-
"Encrypted at rest using AES-256",
|
|
672
|
-
"This value can't be viewed again after you save it — only updated or deleted",
|
|
673
|
-
"Only decrypted at the moment your function runs — never returned by the CLI, dashboard, or API after creation."
|
|
674
|
-
];
|
|
675
|
-
/**
|
|
676
|
-
* Load credentials and validate project config.
|
|
677
|
-
*/
|
|
678
|
-
function loadClientContext() {
|
|
679
|
-
const creds = readCredentials();
|
|
680
|
-
if (!creds) {
|
|
681
|
-
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
682
|
-
process.exit(1);
|
|
683
|
-
}
|
|
684
|
-
const config = readFunctionConfig();
|
|
685
|
-
if (!config || !config.app || !config.app.trim()) {
|
|
686
|
-
console.error("[wawesome] Error: No wawesome-function.json found in the current directory.");
|
|
687
|
-
console.error("[wawesome] Run 'wawesome init' to create one, or create it manually.");
|
|
688
|
-
process.exit(1);
|
|
689
|
-
}
|
|
690
|
-
return {
|
|
691
|
-
creds,
|
|
692
|
-
app: config.app.trim()
|
|
693
|
-
};
|
|
694
|
-
}
|
|
695
|
-
/**
|
|
696
|
-
* Set or overwrite an environment variable on the current app.
|
|
697
|
-
*/
|
|
698
|
-
async function setEnvVar(key, value, options) {
|
|
699
|
-
const isVerbose = Boolean(options.verbose);
|
|
700
|
-
if (!key || value === void 0) {
|
|
701
|
-
console.error("[wawesome] Error: Missing key or value. Usage: wawesome env set <KEY> <VALUE> [--secret]");
|
|
702
|
-
process.exit(1);
|
|
703
|
-
}
|
|
704
|
-
const { creds, app } = loadClientContext();
|
|
705
|
-
const isSecret = Boolean(options.secret);
|
|
706
|
-
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
|
|
707
|
-
if (isVerbose) {
|
|
708
|
-
console.log(`[wawesome:verbose] POST ${url}`);
|
|
709
|
-
console.log(`[wawesome:verbose] Payload: key=${key}, is_secret=${isSecret}`);
|
|
710
|
-
}
|
|
711
|
-
const res = await fetch(url, {
|
|
712
|
-
method: "POST",
|
|
713
|
-
headers: {
|
|
714
|
-
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
715
|
-
"Content-Type": "application/json"
|
|
716
|
-
},
|
|
717
|
-
body: JSON.stringify({
|
|
718
|
-
key,
|
|
719
|
-
value,
|
|
720
|
-
is_secret: isSecret
|
|
721
|
-
})
|
|
722
|
-
});
|
|
723
|
-
if (!res.ok) {
|
|
724
|
-
const errorBody = await res.text();
|
|
725
|
-
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
726
|
-
else {
|
|
727
|
-
let msg = `HTTP ${res.status}`;
|
|
728
|
-
try {
|
|
729
|
-
const parsed = JSON.parse(errorBody);
|
|
730
|
-
if (parsed.error) msg = parsed.error;
|
|
731
|
-
} catch {}
|
|
732
|
-
console.error(`[wawesome] Error: Failed to set variable '${key}' (${msg}).`);
|
|
733
|
-
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
734
|
-
}
|
|
735
|
-
process.exit(1);
|
|
736
|
-
}
|
|
737
|
-
const data = await res.json();
|
|
738
|
-
if (data.variable.is_secret || isSecret) {
|
|
739
|
-
console.log(`\n[wawesome] ✅ Set secret variable '${key}' on app '${app}'.`);
|
|
740
|
-
const messages = data.notice?.messages?.length ? data.notice.messages : STANDARD_SECRET_MESSAGES;
|
|
741
|
-
console.log("\n🔒 \x1B[1mSecret Notice:\x1B[0m");
|
|
742
|
-
for (const msg of messages) console.log(` • ${msg}`);
|
|
743
|
-
console.log("");
|
|
744
|
-
} else console.log(`[wawesome] ✅ Set variable '${key}' on app '${app}'.`);
|
|
745
|
-
}
|
|
746
|
-
function truncate(str, maxLen) {
|
|
747
|
-
if (str.length <= maxLen) return str;
|
|
748
|
-
if (maxLen <= 3) return str.slice(0, maxLen);
|
|
749
|
-
return str.slice(0, maxLen - 1) + "…";
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* List environment variables on the current app.
|
|
753
|
-
*/
|
|
754
|
-
async function listEnvVars(options) {
|
|
755
|
-
const isVerbose = Boolean(options.verbose);
|
|
756
|
-
const { creds, app } = loadClientContext();
|
|
757
|
-
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env`;
|
|
758
|
-
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
759
|
-
const res = await fetch(url, {
|
|
760
|
-
method: "GET",
|
|
761
|
-
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
762
|
-
});
|
|
763
|
-
if (!res.ok) {
|
|
764
|
-
const errorBody = await res.text();
|
|
765
|
-
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
766
|
-
else {
|
|
767
|
-
console.error(`[wawesome] Error: Failed to list variables (HTTP ${res.status}).`);
|
|
768
|
-
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
769
|
-
}
|
|
770
|
-
process.exit(1);
|
|
771
|
-
}
|
|
772
|
-
const data = await res.json();
|
|
773
|
-
if (!data.variables || data.variables.length === 0) {
|
|
774
|
-
console.log(`[wawesome] No environment variables set for app '${app}'.`);
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
const availableWidth = (process.stdout.columns && process.stdout.columns > 40 ? process.stdout.columns : 80) - 12;
|
|
778
|
-
let rawMaxKeyLen = 15;
|
|
779
|
-
let rawMaxValLen = 15;
|
|
780
|
-
for (const item of data.variables) {
|
|
781
|
-
if (item.key.length > rawMaxKeyLen) rawMaxKeyLen = item.key.length;
|
|
782
|
-
if (item.value.length > rawMaxValLen) rawMaxValLen = item.value.length;
|
|
783
|
-
}
|
|
784
|
-
const maxKeyLen = Math.min(rawMaxKeyLen, Math.max(15, Math.floor(availableWidth * .4)));
|
|
785
|
-
const maxValLen = Math.min(rawMaxValLen, Math.max(15, availableWidth - maxKeyLen));
|
|
786
|
-
console.log(`\n📋 \x1b[1mEnvironment variables for '${app}'\x1b[0m\n`);
|
|
787
|
-
const headerKey = "KEY".padEnd(maxKeyLen);
|
|
788
|
-
const headerVal = "VALUE".padEnd(maxValLen);
|
|
789
|
-
console.log(`${headerKey} | ${headerVal} | SECRET`);
|
|
790
|
-
console.log(`${"-".repeat(maxKeyLen)}-|- ${"-".repeat(maxValLen)} |-------`);
|
|
791
|
-
for (const item of data.variables) {
|
|
792
|
-
const truncatedKey = truncate(item.key, maxKeyLen);
|
|
793
|
-
const truncatedVal = truncate(item.value, maxValLen);
|
|
794
|
-
const keyStr = truncatedKey.padEnd(maxKeyLen);
|
|
795
|
-
const valStr = truncatedVal.padEnd(maxValLen);
|
|
796
|
-
const secretStr = item.is_secret ? "\x1B[33mtrue\x1B[0m" : "false";
|
|
797
|
-
console.log(`${keyStr} | ${valStr} | ${secretStr}`);
|
|
798
|
-
}
|
|
799
|
-
console.log("");
|
|
800
|
-
}
|
|
801
|
-
/**
|
|
802
|
-
* Delete an environment variable on the current app.
|
|
803
|
-
*/
|
|
804
|
-
async function removeEnvVar(key, options) {
|
|
805
|
-
const isVerbose = Boolean(options.verbose);
|
|
806
|
-
if (!key) {
|
|
807
|
-
console.error("[wawesome] Error: Missing variable key. Usage: wawesome env rm <KEY>");
|
|
808
|
-
process.exit(1);
|
|
809
|
-
}
|
|
810
|
-
const { creds, app } = loadClientContext();
|
|
811
|
-
const url = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/env/${encodeURIComponent(key)}`;
|
|
812
|
-
if (isVerbose) console.log(`[wawesome:verbose] DELETE ${url}`);
|
|
813
|
-
const res = await fetch(url, {
|
|
814
|
-
method: "DELETE",
|
|
815
|
-
headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
|
|
816
|
-
});
|
|
817
|
-
if (!res.ok) {
|
|
818
|
-
const errorBody = await res.text();
|
|
819
|
-
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
820
|
-
else if (res.status === 404) console.error(`[wawesome] Error: Variable '${key}' not found on app '${app}'.`);
|
|
821
|
-
else {
|
|
822
|
-
console.error(`[wawesome] Error: Failed to delete variable '${key}' (HTTP ${res.status}).`);
|
|
823
|
-
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
824
|
-
}
|
|
825
|
-
process.exit(1);
|
|
826
|
-
}
|
|
827
|
-
console.log(`[wawesome] ✅ Deleted variable '${key}' from app '${app}'.`);
|
|
828
|
-
}
|
|
829
|
-
/**
|
|
830
|
-
* Dispatcher function for `wawesome env` CLI command.
|
|
831
|
-
*/
|
|
832
|
-
async function envCommand(action, key, value, options) {
|
|
833
|
-
if (!action || action === "list" || action === "ls") return listEnvVars(options);
|
|
834
|
-
if (action === "set") return setEnvVar(key, value, options);
|
|
835
|
-
if (action === "rm" || action === "remove" || action === "delete" || action === "unset") return removeEnvVar(key, options);
|
|
836
|
-
console.error(`[wawesome] Error: Unknown env action '${action}'. Available actions: list, set, rm.`);
|
|
837
|
-
process.exit(1);
|
|
838
|
-
}
|
|
839
|
-
//#endregion
|
|
840
1677
|
//#region src/logs.ts
|
|
841
1678
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
842
1679
|
function isUuid(str) {
|
|
@@ -1323,6 +2160,105 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
|
|
|
1323
2160
|
}
|
|
1324
2161
|
}
|
|
1325
2162
|
//#endregion
|
|
2163
|
+
//#region src/workspace.ts
|
|
2164
|
+
function requireCredentials() {
|
|
2165
|
+
const creds = readCredentials();
|
|
2166
|
+
if (!creds) {
|
|
2167
|
+
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
2168
|
+
process.exit(1);
|
|
2169
|
+
}
|
|
2170
|
+
return creds;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Show the workspace's name, address, and whether that address is still free to
|
|
2174
|
+
* change — the rename is offered here rather than discovered by attempting it.
|
|
2175
|
+
*/
|
|
2176
|
+
async function showWorkspace(options) {
|
|
2177
|
+
const creds = requireCredentials();
|
|
2178
|
+
let tenant;
|
|
2179
|
+
try {
|
|
2180
|
+
tenant = await fetchTenantDetails(creds);
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
|
|
2183
|
+
process.exit(1);
|
|
2184
|
+
}
|
|
2185
|
+
console.log("\n[wawesome] Workspace:");
|
|
2186
|
+
console.log(` Name: ${tenant.name}`);
|
|
2187
|
+
console.log(` Address: ${tenant.tenant_slug}`);
|
|
2188
|
+
console.log(` Tenant: ${tenant.id}`);
|
|
2189
|
+
if (options.verbose) console.log(` Invoke: ${creds.gateway_url}/v1/s/${tenant.tenant_slug}/apps/<app>/functions/<function>/invoke`);
|
|
2190
|
+
if (tenant.slug_locked) console.log("\n 🔒 The address is fixed — a Function version has been promoted and live URLs carry it.");
|
|
2191
|
+
else {
|
|
2192
|
+
console.log("\n The address can still be changed: \x1B[36mwawesome workspace rename <name>\x1B[0m");
|
|
2193
|
+
console.log(" It locks for good the first time you deploy.");
|
|
2194
|
+
}
|
|
2195
|
+
console.log("");
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Change the workspace's public address.
|
|
2199
|
+
*
|
|
2200
|
+
* The lock is read from the gateway before asking, so a user who cannot rename
|
|
2201
|
+
* is told why instead of being walked into a rejection. The rejection is still
|
|
2202
|
+
* handled: a promotion landing between the two calls is the one case where the
|
|
2203
|
+
* gateway knows something this command could not.
|
|
2204
|
+
*/
|
|
2205
|
+
async function renameWorkspace(slug, options) {
|
|
2206
|
+
const creds = requireCredentials();
|
|
2207
|
+
if (!slug || !slug.trim()) {
|
|
2208
|
+
console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
|
|
2209
|
+
process.exit(1);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
try {
|
|
2213
|
+
const tenant = await fetchTenantDetails(creds);
|
|
2214
|
+
if (tenant.slug_locked) {
|
|
2215
|
+
console.error("\n[wawesome] \x1B[31mThe workspace address can no longer be changed.\x1B[0m");
|
|
2216
|
+
console.error(`[wawesome] '${tenant.tenant_slug}' is fixed: a Function version has been promoted, and live URLs already carry it.`);
|
|
2217
|
+
process.exit(1);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (options.verbose) console.log(`[wawesome:verbose] Renaming '${tenant.tenant_slug}' → '${slug}'`);
|
|
2221
|
+
const result = await renameTenantSlug(creds, slug.trim());
|
|
2222
|
+
console.log("\n======================================================");
|
|
2223
|
+
console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
|
|
2224
|
+
console.log("======================================================");
|
|
2225
|
+
console.log(`\n Address: ${result.tenant_slug}`);
|
|
2226
|
+
console.log(` Previous: ${result.previous_slug} (still resolves)`);
|
|
2227
|
+
console.log("======================================================\n");
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
if (err instanceof GatewayError) {
|
|
2230
|
+
console.error(`\n[wawesome] \x1b[31m${err.message}\x1b[0m`);
|
|
2231
|
+
switch (err.reason) {
|
|
2232
|
+
case "taken":
|
|
2233
|
+
console.error("[wawesome] Pick a different name.");
|
|
2234
|
+
break;
|
|
2235
|
+
case "reserved":
|
|
2236
|
+
console.error("[wawesome] Pick a different name.");
|
|
2237
|
+
break;
|
|
2238
|
+
case "malformed":
|
|
2239
|
+
console.error("[wawesome] Use lowercase letters, numbers and single hyphens.");
|
|
2240
|
+
break;
|
|
2241
|
+
case "locked":
|
|
2242
|
+
console.error("[wawesome] A deploy landed while this was running, which fixed the address for good.");
|
|
2243
|
+
break;
|
|
2244
|
+
}
|
|
2245
|
+
console.error("");
|
|
2246
|
+
process.exit(1);
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
|
|
2250
|
+
process.exit(1);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
/** Dispatch for `wawesome workspace [action]`. */
|
|
2254
|
+
async function workspaceCommand(action, target, options) {
|
|
2255
|
+
if (!action || action === "show" || action === "info") return showWorkspace(options);
|
|
2256
|
+
if (action === "rename") return renameWorkspace(target, options);
|
|
2257
|
+
console.error(`[wawesome] Error: Unknown workspace action '${action}'.`);
|
|
2258
|
+
console.error("[wawesome] Usage: wawesome workspace [show|rename <name>]");
|
|
2259
|
+
process.exit(1);
|
|
2260
|
+
}
|
|
2261
|
+
//#endregion
|
|
1326
2262
|
//#region src/index.ts
|
|
1327
2263
|
const cli = cac("wawesome");
|
|
1328
2264
|
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));
|
|
@@ -1338,10 +2274,12 @@ cli.command("env [action] [key] [value]", "Manage environment variables (set, li
|
|
|
1338
2274
|
cli.command("env set <key> <value>", "Set or overwrite an environment variable on the current app").option("-s, --secret", "Flag variable as secret (write-only)").option("-v, --verbose", "Enable verbose debug output").action((key, value, options) => setEnvVar(key, value, options));
|
|
1339
2275
|
cli.command("env list", "List environment variables for the current app").alias("env ls").option("-v, --verbose", "Enable verbose debug output").action((options) => listEnvVars(options));
|
|
1340
2276
|
cli.command("env rm <key>", "Delete an environment variable from the current app").alias("env remove").alias("env delete").alias("env unset").option("-v, --verbose", "Enable verbose debug output").action((key, options) => removeEnvVar(key, options));
|
|
1341
|
-
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("--provider <name>", "OAuth provider (default: github)").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
|
|
2277
|
+
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("--provider <name>", "OAuth provider (default: github)").option("--workspace <name>", "Name for the workspace, when signing up without a terminal to prompt").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
|
|
1342
2278
|
cli.command("logout", "Clear stored authentication credentials").action(() => logout());
|
|
1343
2279
|
cli.command("whoami", "Show current login session info").action(() => whoami());
|
|
1344
|
-
cli.command("
|
|
2280
|
+
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));
|
|
2281
|
+
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));
|
|
2282
|
+
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("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
|
|
1345
2283
|
cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
|
|
1346
2284
|
|
|
1347
2285
|
The target argument determines what the command does:
|