wawesome 0.0.4 → 0.0.6
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 +98 -15
- package/dist/index.mjs +860 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
> **[wawesome.io](https://wawesome.io)** platform.
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/wawesome)
|
|
7
|
-
[](https://opensource.org/licenses/MIT)
|
|
8
7
|
|
|
9
8
|
Deploy ultra-fast, lightweight serverless WebAssembly functions directly from your terminal in under 60 seconds.
|
|
10
9
|
|
|
@@ -33,6 +32,15 @@ mkdir my-wasm-app && cd my-wasm-app
|
|
|
33
32
|
npx wawesome init
|
|
34
33
|
```
|
|
35
34
|
|
|
35
|
+
`init` asks for the function name and for the **App slug** — the App groups the Functions of one
|
|
36
|
+
project, and its slug is part of the public URL your client sees. Both default to the directory
|
|
37
|
+
name, so naming is usually a matter of pressing enter.
|
|
38
|
+
|
|
39
|
+
An App slug must be a legal hostname label: lowercase letters, numbers, and single hyphens between
|
|
40
|
+
them (no leading or trailing hyphen, 63 characters at most). Type something else — `My Client` —
|
|
41
|
+
and the CLI shows you the slug it would become (`my-client`) and asks again, rather than rewriting
|
|
42
|
+
your answer behind your back.
|
|
43
|
+
|
|
36
44
|
### 4. Build & Deploy
|
|
37
45
|
|
|
38
46
|
Deploy your serverless function to Wawesome Cloud instantly:
|
|
@@ -45,19 +53,91 @@ npx wawesome deploy
|
|
|
45
53
|
|
|
46
54
|
## 📖 Command Reference
|
|
47
55
|
|
|
48
|
-
| Command
|
|
49
|
-
|
|
50
|
-
| `npx wawesome login`
|
|
51
|
-
| `npx wawesome logout`
|
|
52
|
-
| `npx wawesome whoami`
|
|
53
|
-
| `npx wawesome init`
|
|
54
|
-
| `npx wawesome build`
|
|
55
|
-
| `npx wawesome deploy`
|
|
56
|
-
| `npx wawesome
|
|
57
|
-
| `npx wawesome
|
|
58
|
-
| `npx wawesome
|
|
59
|
-
| `npx wawesome
|
|
60
|
-
| `npx wawesome
|
|
56
|
+
| Command | Description |
|
|
57
|
+
|:----------------------------------------|:--------------------------------------------------------------------|
|
|
58
|
+
| `npx wawesome login` | Authenticate CLI with your Wawesome account via browser |
|
|
59
|
+
| `npx wawesome logout` | Log out and clear saved credentials from your machine |
|
|
60
|
+
| `npx wawesome whoami` | View current logged-in user, workspace, and gateway info |
|
|
61
|
+
| `npx wawesome init` | Scaffold a new serverless function project in the current directory |
|
|
62
|
+
| `npx wawesome build` | Bundle TypeScript entry code into an optimized JS bundle |
|
|
63
|
+
| `npx wawesome deploy` | Build, upload, and promote a function version to production |
|
|
64
|
+
| `npx wawesome logs [func]` | List recent past invocations for a function |
|
|
65
|
+
| `npx wawesome logs --invocation <id>` | Fetch full stdout/stderr log body for a specific invocation |
|
|
66
|
+
| `npx wawesome logs <func> --follow` | Follow live output (waits for the next invocation if needed) |
|
|
67
|
+
| `npx wawesome version list` | List version history for the current function |
|
|
68
|
+
| `npx wawesome version switch <v>` | Roll back or promote a specific function version |
|
|
69
|
+
| `npx wawesome env list` | View environment variables for the current app |
|
|
70
|
+
| `npx wawesome env set <key> <val>` | Set an environment variable (add `--secret` for write-only) |
|
|
71
|
+
| `npx wawesome env rm <key>` | Delete an environment variable |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 📜 Invocation Logs
|
|
76
|
+
|
|
77
|
+
Inspect past function runs or view raw `stdout` / `stderr` log outputs directly in your terminal.
|
|
78
|
+
|
|
79
|
+
### 1. List Recent Invocations
|
|
80
|
+
|
|
81
|
+
List past executions (including status, trigger type, timestamp, and duration) for the function in the
|
|
82
|
+
current directory:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx wawesome logs
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or list invocations for a specific function by name:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx wawesome logs my-function
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Filter by invocation status — only show errors, timeouts, etc.:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npx wawesome logs my-function --error
|
|
98
|
+
npx wawesome logs my-function --status timeout
|
|
99
|
+
npx wawesome logs my-function --running
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2. View Invocation Log Body (`stdout`/`stderr`)
|
|
103
|
+
|
|
104
|
+
Fetch and print the captured `console.log` / `console.error` text for a specific invocation:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npx wawesome logs --invocation 019fb344-ea0c-78f2-8a9b-d04e188b9823
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or pass the UUID directly as the target:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npx wawesome logs 019fb344-ea0c-78f2-8a9b-d04e188b9823
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 3. Follow Live Output (`--follow`)
|
|
117
|
+
|
|
118
|
+
Stream an invocation's output as it runs — like `tail -f` for your serverless function.
|
|
119
|
+
|
|
120
|
+
#### Follow by function name (recommended)
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx wawesome logs my-function --follow
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
If the function is currently running, its output is streamed immediately. If the latest invocation
|
|
127
|
+
already finished, the CLI **waits for the next invocation** to start and then streams it live.
|
|
128
|
+
Press `Ctrl-C` at any time to stop.
|
|
129
|
+
|
|
130
|
+
#### Follow a specific invocation by ID
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npx wawesome logs 019fb344-ea0c-78f2-8a9b-d04e188b9823 --follow
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### Reconnection
|
|
137
|
+
|
|
138
|
+
On transient network errors or server issues (5xx), the CLI automatically reconnects with
|
|
139
|
+
exponential back-off (up to 3 retries). Non-recoverable errors like authentication failures
|
|
140
|
+
(401) or unknown invocations (404) exit immediately with a clear message.
|
|
61
141
|
|
|
62
142
|
---
|
|
63
143
|
|
|
@@ -75,6 +155,9 @@ Every project directory includes a `wawesome-function.json` file generated durin
|
|
|
75
155
|
}
|
|
76
156
|
```
|
|
77
157
|
|
|
158
|
+
`app` is the App this Function is deployed into, and it is client-facing — every deploy from this
|
|
159
|
+
directory is scoped to it.
|
|
160
|
+
|
|
78
161
|
### Local Development / Gateway Overrides
|
|
79
162
|
|
|
80
163
|
If you are running a local gateway or self-hosted instance, you can configure your CLI Gateway URL using any of the
|
|
@@ -119,4 +202,4 @@ npx wawesome env set STRIPE_SECRET_KEY sk_live_xxx --secret
|
|
|
119
202
|
|
|
120
203
|
- **Platform Homepage**: [https://wawesome.io](https://wawesome.io)
|
|
121
204
|
- **Documentation**: [https://docs.wawesome.io](https://docs.wawesome.io)
|
|
122
|
-
|
|
205
|
+
|
package/dist/index.mjs
CHANGED
|
@@ -148,8 +148,166 @@ async function buildJs(entryInput, options) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
//#endregion
|
|
151
|
+
//#region src/prompt.ts
|
|
152
|
+
/**
|
|
153
|
+
* Open a prompt session on stdin, queueing lines as they arrive.
|
|
154
|
+
*
|
|
155
|
+
* Reading one `rl.question` at a time drops piped input: it lands as a single
|
|
156
|
+
* chunk, so every line after the first is emitted with no question pending.
|
|
157
|
+
* End of input answers the remaining questions with their defaults.
|
|
158
|
+
*/
|
|
159
|
+
function openPromptSession() {
|
|
160
|
+
const rl = readline.createInterface({
|
|
161
|
+
input: process.stdin,
|
|
162
|
+
output: process.stdout
|
|
163
|
+
});
|
|
164
|
+
const queued = [];
|
|
165
|
+
const waiting = [];
|
|
166
|
+
let ended = false;
|
|
167
|
+
rl.on("line", (line) => {
|
|
168
|
+
const next = waiting.shift();
|
|
169
|
+
if (next) next(line);
|
|
170
|
+
else queued.push(line);
|
|
171
|
+
});
|
|
172
|
+
rl.on("close", () => {
|
|
173
|
+
ended = true;
|
|
174
|
+
while (waiting.length > 0) waiting.shift()?.(null);
|
|
175
|
+
});
|
|
176
|
+
function nextLine() {
|
|
177
|
+
if (queued.length > 0) return Promise.resolve(queued.shift());
|
|
178
|
+
if (ended) return Promise.resolve(null);
|
|
179
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
async ask(question, defaultVal) {
|
|
183
|
+
const offered = defaultVal ? ` (${defaultVal})` : "";
|
|
184
|
+
process.stdout.write(`${question}${offered}: `);
|
|
185
|
+
const line = await nextLine();
|
|
186
|
+
if (line === null) {
|
|
187
|
+
process.stdout.write("\n");
|
|
188
|
+
return defaultVal;
|
|
189
|
+
}
|
|
190
|
+
return line.trim() || defaultVal;
|
|
191
|
+
},
|
|
192
|
+
close() {
|
|
193
|
+
rl.close();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Whether a question can actually be put to somebody. A piped or redirected
|
|
199
|
+
* stdin still answers — with end-of-input — so this only tells a caller whether
|
|
200
|
+
* a *default* is going to be what the user gets.
|
|
201
|
+
*/
|
|
202
|
+
function isInteractive() {
|
|
203
|
+
return Boolean(process.stdin.isTTY);
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/tenant.ts
|
|
207
|
+
/** A rejection carrying the gateway's own prose and, where given, its reason. */
|
|
208
|
+
var GatewayError = class extends Error {
|
|
209
|
+
status;
|
|
210
|
+
reason;
|
|
211
|
+
constructor(message, status, reason) {
|
|
212
|
+
super(message);
|
|
213
|
+
this.name = "GatewayError";
|
|
214
|
+
this.status = status;
|
|
215
|
+
this.reason = reason;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
async function asGatewayError(res, fallback) {
|
|
219
|
+
const body = await res.text();
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(body);
|
|
222
|
+
return new GatewayError(parsed.error || fallback, res.status, parsed.reason);
|
|
223
|
+
} catch {
|
|
224
|
+
return new GatewayError(fallback, res.status);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function fetchTenantDetails(creds) {
|
|
228
|
+
const res = await fetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
229
|
+
if (!res.ok) throw await asGatewayError(res, `Failed to read workspace (HTTP ${res.status}).`);
|
|
230
|
+
return await res.json();
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The workspace's name and slug, preferring what login already stored.
|
|
234
|
+
*
|
|
235
|
+
* Credentials written before the slug was stored have neither, so this fetches
|
|
236
|
+
* and writes them back rather than making every later command ask again.
|
|
237
|
+
*/
|
|
238
|
+
async function resolveWorkspace(creds) {
|
|
239
|
+
if (creds.tenant_slug && creds.tenant_name) return {
|
|
240
|
+
name: creds.tenant_name,
|
|
241
|
+
slug: creds.tenant_slug
|
|
242
|
+
};
|
|
243
|
+
const tenant = await fetchTenantDetails(creds);
|
|
244
|
+
writeCredentials({
|
|
245
|
+
...creds,
|
|
246
|
+
tenant_slug: tenant.tenant_slug,
|
|
247
|
+
tenant_name: tenant.name
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
name: tenant.name,
|
|
251
|
+
slug: tenant.tenant_slug
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Ask for the workspace's public address to become `slug`.
|
|
256
|
+
*
|
|
257
|
+
* The slug is sent exactly as typed. Sanitizing it the way an App slug is
|
|
258
|
+
* sanitized would hand the user a public address they never chose, so an
|
|
259
|
+
* unusable name comes back as a `malformed` rejection instead.
|
|
260
|
+
*/
|
|
261
|
+
async function renameTenantSlug(creds, slug) {
|
|
262
|
+
const res = await fetch(`${creds.gateway_url}/v1/tenant/slug`, {
|
|
263
|
+
method: "PUT",
|
|
264
|
+
headers: {
|
|
265
|
+
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
266
|
+
"Content-Type": "application/json"
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify({ slug })
|
|
269
|
+
});
|
|
270
|
+
if (!res.ok) throw await asGatewayError(res, `Rename failed (HTTP ${res.status}).`);
|
|
271
|
+
const result = await res.json();
|
|
272
|
+
const stored = readCredentials();
|
|
273
|
+
if (stored) writeCredentials({
|
|
274
|
+
...stored,
|
|
275
|
+
tenant_slug: result.tenant_slug
|
|
276
|
+
});
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
/** The address a deployed Function answers on, for a workspace and app. */
|
|
280
|
+
function publicInvokeUrl(gatewayUrl, tenantSlug, appSlug, functionName) {
|
|
281
|
+
return `${gatewayUrl}/v1/s/${encodeURIComponent(tenantSlug)}/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(functionName)}/invoke`;
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
151
284
|
//#region src/auth.ts
|
|
152
285
|
/**
|
|
286
|
+
* Ask what to call the workspace being created.
|
|
287
|
+
*
|
|
288
|
+
* There is no default worth offering: a name nobody typed is exactly what this
|
|
289
|
+
* replaced. A non-interactive login has nobody to ask, so it says what flag to
|
|
290
|
+
* pass rather than inventing one and minting a workspace under it.
|
|
291
|
+
*/
|
|
292
|
+
async function promptForWorkspaceName(options) {
|
|
293
|
+
const supplied = options.workspace?.trim();
|
|
294
|
+
if (supplied) return supplied;
|
|
295
|
+
if (!isInteractive()) throw new Error("No workspace found, and no name to create one with. Re-run with --workspace <name>.");
|
|
296
|
+
console.log("\n[wawesome] You don't have a workspace yet — let's create one.");
|
|
297
|
+
console.log(" (this is the name you'll see in the dashboard; its public address is set separately)");
|
|
298
|
+
const session = openPromptSession();
|
|
299
|
+
try {
|
|
300
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
301
|
+
const answer = await session.ask(" Workspace name?", "");
|
|
302
|
+
if (answer.trim()) return answer.trim();
|
|
303
|
+
console.log("[wawesome] A workspace needs a name.");
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
session.close();
|
|
307
|
+
}
|
|
308
|
+
throw new Error("No workspace name given. Nothing was created.");
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
153
311
|
* OAuth login flow:
|
|
154
312
|
* 1. Build Supabase OAuth URL
|
|
155
313
|
* 2. Open browser
|
|
@@ -217,18 +375,24 @@ async function login(options) {
|
|
|
217
375
|
if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
|
|
218
376
|
const tenants = await tenantsRes.json();
|
|
219
377
|
let primaryTenantId;
|
|
378
|
+
let workspaceName;
|
|
379
|
+
let workspaceSlug;
|
|
220
380
|
if (tenants.length === 0) {
|
|
221
|
-
|
|
381
|
+
const chosenName = await promptForWorkspaceName(options);
|
|
382
|
+
console.log(`[wawesome] Creating workspace "${chosenName}"...`);
|
|
222
383
|
const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
|
|
223
384
|
method: "POST",
|
|
224
385
|
headers: {
|
|
225
386
|
Authorization: `Bearer ${accessToken}`,
|
|
226
387
|
"Content-Type": "application/json"
|
|
227
388
|
},
|
|
228
|
-
body: JSON.stringify({ tenant_name:
|
|
389
|
+
body: JSON.stringify({ tenant_name: chosenName })
|
|
229
390
|
});
|
|
230
391
|
if (!initRes.ok) throw new Error(`Failed to initialize workspace (HTTP ${initRes.status}).`);
|
|
231
|
-
|
|
392
|
+
const initData = await initRes.json();
|
|
393
|
+
primaryTenantId = initData.tenant_id;
|
|
394
|
+
workspaceSlug = initData.tenant_slug;
|
|
395
|
+
workspaceName = initData.tenant_name || chosenName;
|
|
232
396
|
} else primaryTenantId = tenants[0].tenant_id;
|
|
233
397
|
if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
|
|
234
398
|
console.log("[wawesome] Exchanging for tenant-scoped credentials...");
|
|
@@ -242,6 +406,8 @@ async function login(options) {
|
|
|
242
406
|
});
|
|
243
407
|
if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
|
|
244
408
|
const exchangeData = await exchangeRes.json();
|
|
409
|
+
workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
|
|
410
|
+
workspaceName = exchangeData.tenant_name || workspaceName;
|
|
245
411
|
let userEmail = exchangeData.email || "unknown";
|
|
246
412
|
try {
|
|
247
413
|
const payloadPart = exchangeData.tenant_jwt.split(".")[1];
|
|
@@ -252,14 +418,18 @@ async function login(options) {
|
|
|
252
418
|
gateway_url: gatewayUrl,
|
|
253
419
|
tenant_jwt: exchangeData.tenant_jwt,
|
|
254
420
|
tenant_id: primaryTenantId,
|
|
255
|
-
user_email: userEmail
|
|
421
|
+
user_email: userEmail,
|
|
422
|
+
tenant_slug: workspaceSlug,
|
|
423
|
+
tenant_name: workspaceName
|
|
256
424
|
});
|
|
257
425
|
console.log("\n======================================================");
|
|
258
426
|
console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
|
|
259
427
|
console.log("======================================================");
|
|
260
|
-
console.log(`\n
|
|
261
|
-
console.log(`
|
|
262
|
-
console.log(`
|
|
428
|
+
if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
|
|
429
|
+
if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
|
|
430
|
+
console.log(` Tenant: ${primaryTenantId}`);
|
|
431
|
+
console.log(` Gateway: ${gatewayUrl}`);
|
|
432
|
+
console.log(` Email: ${userEmail}`);
|
|
263
433
|
console.log("\n Credentials saved to ~/.wawesome/credentials.json");
|
|
264
434
|
console.log("======================================================\n");
|
|
265
435
|
server.close();
|
|
@@ -297,16 +467,26 @@ function logout() {
|
|
|
297
467
|
/**
|
|
298
468
|
* Show current login status.
|
|
299
469
|
*/
|
|
300
|
-
function whoami() {
|
|
470
|
+
async function whoami() {
|
|
301
471
|
const creds = readCredentials();
|
|
302
472
|
if (!creds) {
|
|
303
473
|
console.log("[wawesome] Not logged in. Run 'wawesome login' to authenticate.");
|
|
304
474
|
return;
|
|
305
475
|
}
|
|
476
|
+
let workspace = null;
|
|
477
|
+
try {
|
|
478
|
+
workspace = await resolveWorkspace(creds);
|
|
479
|
+
} catch {
|
|
480
|
+
workspace = null;
|
|
481
|
+
}
|
|
306
482
|
console.log("\n[wawesome] Current session:");
|
|
307
|
-
console.log(` Email:
|
|
308
|
-
|
|
309
|
-
|
|
483
|
+
console.log(` Email: ${creds.user_email}`);
|
|
484
|
+
if (workspace) {
|
|
485
|
+
console.log(` Workspace: ${workspace.name}`);
|
|
486
|
+
console.log(` Address: ${workspace.slug}`);
|
|
487
|
+
}
|
|
488
|
+
console.log(` Tenant: ${creds.tenant_id}`);
|
|
489
|
+
console.log(` Gateway: ${creds.gateway_url}\n`);
|
|
310
490
|
}
|
|
311
491
|
//#endregion
|
|
312
492
|
//#region src/deploy.ts
|
|
@@ -405,27 +585,67 @@ async function deploy(entryInput, options) {
|
|
|
405
585
|
}
|
|
406
586
|
process.exit(1);
|
|
407
587
|
}
|
|
588
|
+
let invokeUrl = null;
|
|
589
|
+
try {
|
|
590
|
+
const { slug } = await resolveWorkspace(creds);
|
|
591
|
+
invokeUrl = publicInvokeUrl(creds.gateway_url, slug, app, funcName);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (isVerbose) console.log(`[wawesome:verbose] Could not resolve the workspace address: ${err instanceof Error ? err.message : err}`);
|
|
594
|
+
}
|
|
408
595
|
console.log("\n======================================================");
|
|
409
596
|
console.log("🚀 \x1B[32mDEPLOYED SUCCESSFULLY!\x1B[0m");
|
|
410
597
|
console.log("======================================================");
|
|
411
598
|
console.log(`\n App: ${app}`);
|
|
412
599
|
console.log(` Function: ${funcName}`);
|
|
413
600
|
if (version !== void 0) console.log(` Version: ${version}`);
|
|
601
|
+
if (invokeUrl) console.log(`\n URL: \x1b[36m${invokeUrl}\x1b[0m`);
|
|
414
602
|
console.log("======================================================\n");
|
|
415
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Normalise arbitrary input into a legal DNS label, returning an empty string
|
|
606
|
+
* when nothing legal survives — callers decide whether that is an error.
|
|
607
|
+
*/
|
|
608
|
+
function sanitizeSlug(raw) {
|
|
609
|
+
let label = "";
|
|
610
|
+
for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z") label += c;
|
|
611
|
+
else if (c >= "0" && c <= "9") label += c;
|
|
612
|
+
else if (label.length > 0 && !label.endsWith("-")) label += "-";
|
|
613
|
+
label = label.slice(0, 63);
|
|
614
|
+
while (label.endsWith("-")) label = label.slice(0, -1);
|
|
615
|
+
return label;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Whether `candidate` is already a legal DNS label — i.e. whether
|
|
619
|
+
* {@link sanitizeSlug} would leave it untouched.
|
|
620
|
+
*/
|
|
621
|
+
function isLegalSlug(candidate) {
|
|
622
|
+
return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
|
|
623
|
+
}
|
|
416
624
|
//#endregion
|
|
417
625
|
//#region src/init.ts
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
626
|
+
/** Bound on the re-prompt loop, so unusable input exits instead of looping. */
|
|
627
|
+
const MAX_APP_SLUG_ATTEMPTS = 5;
|
|
628
|
+
/**
|
|
629
|
+
* Ask which App this Function belongs to, defaulting to the directory name.
|
|
630
|
+
*
|
|
631
|
+
* Never falls back to `default-app`, and re-offers an illegal answer as a
|
|
632
|
+
* suggestion rather than rewriting it: the slug is part of the public URL a
|
|
633
|
+
* client reads, so the user sees the name they will get before agreeing to it.
|
|
634
|
+
*/
|
|
635
|
+
async function promptForAppSlug(session, dirName) {
|
|
636
|
+
let suggestion = sanitizeSlug(dirName);
|
|
637
|
+
for (let attempt = 0; attempt < MAX_APP_SLUG_ATTEMPTS; attempt++) {
|
|
638
|
+
const answer = await session.ask(" App slug?", suggestion);
|
|
639
|
+
if (isLegalSlug(answer)) return answer;
|
|
640
|
+
const sanitized = sanitizeSlug(answer);
|
|
641
|
+
if (sanitized) {
|
|
642
|
+
console.log(`[wawesome] '${answer}' can't be used as an App slug. Suggested: '${sanitized}'.`);
|
|
643
|
+
suggestion = sanitized;
|
|
644
|
+
} else console.log("[wawesome] An App slug needs lowercase letters or numbers, separated by single hyphens.");
|
|
645
|
+
}
|
|
646
|
+
console.error(`[wawesome] Error: no usable App slug after ${MAX_APP_SLUG_ATTEMPTS} attempts. Nothing was written.`);
|
|
647
|
+
console.error("[wawesome] Run 'wawesome init' again once you know what to call the App.");
|
|
648
|
+
process.exit(1);
|
|
429
649
|
}
|
|
430
650
|
/**
|
|
431
651
|
* Scaffold a new wawesome function project in the current directory.
|
|
@@ -435,8 +655,16 @@ async function init(options) {
|
|
|
435
655
|
const cwd = process.cwd();
|
|
436
656
|
const dirName = path.basename(cwd);
|
|
437
657
|
console.log("[wawesome] Initializing a new function project...\n");
|
|
438
|
-
const
|
|
439
|
-
|
|
658
|
+
const session = openPromptSession();
|
|
659
|
+
let functionName;
|
|
660
|
+
let appSlug;
|
|
661
|
+
try {
|
|
662
|
+
functionName = await session.ask(" Function name?", dirName);
|
|
663
|
+
console.log(" (an App groups the Functions of one project — its slug is part of the public URL)");
|
|
664
|
+
appSlug = await promptForAppSlug(session, dirName);
|
|
665
|
+
} finally {
|
|
666
|
+
session.close();
|
|
667
|
+
}
|
|
440
668
|
const entry = "src/index.ts";
|
|
441
669
|
if (isVerbose) console.log(`[wawesome:verbose] function=${functionName}, app=${appSlug}, entry=${entry}`);
|
|
442
670
|
const configContent = {
|
|
@@ -837,6 +1065,591 @@ async function envCommand(action, key, value, options) {
|
|
|
837
1065
|
process.exit(1);
|
|
838
1066
|
}
|
|
839
1067
|
//#endregion
|
|
1068
|
+
//#region src/logs.ts
|
|
1069
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1070
|
+
function isUuid(str) {
|
|
1071
|
+
return UUID_REGEX.test(str);
|
|
1072
|
+
}
|
|
1073
|
+
function formatDuration(startedAt, endedAt) {
|
|
1074
|
+
if (!endedAt) return "-";
|
|
1075
|
+
const start = new Date(startedAt).getTime();
|
|
1076
|
+
const end = new Date(endedAt).getTime();
|
|
1077
|
+
if (isNaN(start) || isNaN(end)) return "-";
|
|
1078
|
+
const durationMs = end - start;
|
|
1079
|
+
if (durationMs < 0) return "-";
|
|
1080
|
+
if (durationMs < 1e3) return `${durationMs}ms`;
|
|
1081
|
+
return `${(durationMs / 1e3).toFixed(2)}s`;
|
|
1082
|
+
}
|
|
1083
|
+
function formatDate(dateStr) {
|
|
1084
|
+
const d = new Date(dateStr);
|
|
1085
|
+
if (isNaN(d.getTime())) return dateStr;
|
|
1086
|
+
return d.toISOString().replace("T", " ").slice(0, 19);
|
|
1087
|
+
}
|
|
1088
|
+
function colorizeStatus(text, status) {
|
|
1089
|
+
switch (status.toLowerCase()) {
|
|
1090
|
+
case "success": return `\x1b[32m${text}\x1b[0m`;
|
|
1091
|
+
case "error": return `\x1b[31m${text}\x1b[0m`;
|
|
1092
|
+
case "timeout": return `\x1b[33m${text}\x1b[0m`;
|
|
1093
|
+
case "running": return `\x1b[36m${text}\x1b[0m`;
|
|
1094
|
+
default: return text;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Main handler for `wawesome logs [target] [--invocation <id>] [--app <app>]`
|
|
1099
|
+
*/
|
|
1100
|
+
async function logsCommand(target, options = {}) {
|
|
1101
|
+
const isVerbose = Boolean(options.verbose);
|
|
1102
|
+
const creds = readCredentials();
|
|
1103
|
+
if (!creds) {
|
|
1104
|
+
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
1105
|
+
process.exit(1);
|
|
1106
|
+
}
|
|
1107
|
+
let invocationId = options.invocation;
|
|
1108
|
+
let funcNameInput;
|
|
1109
|
+
if (!invocationId && target && isUuid(target)) invocationId = target;
|
|
1110
|
+
else if (target && !isUuid(target)) funcNameInput = target;
|
|
1111
|
+
let statusFilter = options.status;
|
|
1112
|
+
if (!statusFilter) {
|
|
1113
|
+
if (options.success) statusFilter = "success";
|
|
1114
|
+
else if (options.error) statusFilter = "error";
|
|
1115
|
+
else if (options.timeout) statusFilter = "timeout";
|
|
1116
|
+
else if (options.running) statusFilter = "running";
|
|
1117
|
+
}
|
|
1118
|
+
if (options.follow) {
|
|
1119
|
+
if (invocationId) return followInvocationLog(creds.gateway_url, creds.tenant_jwt, invocationId, isVerbose);
|
|
1120
|
+
return followFunctionLog(creds.gateway_url, creds.tenant_jwt, funcNameInput, options.app, isVerbose);
|
|
1121
|
+
}
|
|
1122
|
+
if (invocationId) return fetchInvocationLogBody(creds.gateway_url, creds.tenant_jwt, invocationId, isVerbose);
|
|
1123
|
+
else return listInvocations(creds.gateway_url, creds.tenant_jwt, funcNameInput, options.app, statusFilter, isVerbose);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Fetch and display raw log body for a single invocation.
|
|
1127
|
+
*/
|
|
1128
|
+
async function fetchInvocationLogBody(gatewayUrl, tenantJwt, invocationId, isVerbose) {
|
|
1129
|
+
const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
|
|
1130
|
+
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
1131
|
+
const res = await fetch(url, {
|
|
1132
|
+
method: "GET",
|
|
1133
|
+
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
1134
|
+
});
|
|
1135
|
+
if (!res.ok) {
|
|
1136
|
+
const errorText = await res.text();
|
|
1137
|
+
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1138
|
+
else if (res.status === 404) console.error(`[wawesome] Error: Invocation log '${invocationId}' not found or expired (logs are retained for 14 days).`);
|
|
1139
|
+
else if (res.status === 400) console.error(`[wawesome] Error: Invalid invocation ID '${invocationId}'.`);
|
|
1140
|
+
else {
|
|
1141
|
+
console.error(`[wawesome] Error: Failed to fetch invocation logs (HTTP ${res.status}).`);
|
|
1142
|
+
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorText}`);
|
|
1143
|
+
}
|
|
1144
|
+
process.exit(1);
|
|
1145
|
+
}
|
|
1146
|
+
const body = await res.text();
|
|
1147
|
+
process.stdout.write(body.endsWith("\n") ? body : body + "\n");
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* List recent invocations for a function.
|
|
1151
|
+
*/
|
|
1152
|
+
async function listInvocations(gatewayUrl, tenantJwt, funcNameInput, appOverride, statusFilter, isVerbose) {
|
|
1153
|
+
const config = readFunctionConfig();
|
|
1154
|
+
const appSlug = appOverride || config?.app;
|
|
1155
|
+
const funcName = funcNameInput || config?.function;
|
|
1156
|
+
if (!funcName) {
|
|
1157
|
+
console.error("[wawesome] Error: Missing function name or invocation ID.");
|
|
1158
|
+
console.error("[wawesome] Usage: wawesome logs <function-name-or-id> or run inside a function directory with wawesome-function.json.");
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
}
|
|
1161
|
+
const queryParams = new URLSearchParams();
|
|
1162
|
+
queryParams.set("limit", "50");
|
|
1163
|
+
if (statusFilter && statusFilter.toLowerCase() !== "all") queryParams.set("status", statusFilter.toLowerCase());
|
|
1164
|
+
const res = await fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, `?${queryParams.toString()}`, isVerbose);
|
|
1165
|
+
if (!res.ok) {
|
|
1166
|
+
const errorText = await res.text();
|
|
1167
|
+
if (res.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1168
|
+
else if (res.status === 404) console.error(`[wawesome] Error: Function '${funcName}' not found.`);
|
|
1169
|
+
else {
|
|
1170
|
+
console.error(`[wawesome] Error: Failed to list invocations (HTTP ${res.status}).`);
|
|
1171
|
+
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorText}`);
|
|
1172
|
+
}
|
|
1173
|
+
process.exit(1);
|
|
1174
|
+
}
|
|
1175
|
+
const data = await res.json();
|
|
1176
|
+
if (!data.invocations || data.invocations.length === 0) {
|
|
1177
|
+
console.log(`[wawesome] No invocations found for function '${funcName}'.`);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
|
|
1181
|
+
const totalRecords = data.total ?? data.invocations.length;
|
|
1182
|
+
const shownRecords = data.invocations.length;
|
|
1183
|
+
console.log(`\n📜 \x1b[1mInvocations for '${displayTarget}' (Showing ${shownRecords} of ${totalRecords} records)\x1b[0m\n`);
|
|
1184
|
+
console.log("INVOCATION ID | STATUS | TRIGGER | STARTED AT | DURATION ");
|
|
1185
|
+
console.log("------------------------------------|-----------|---------|---------------------|----------");
|
|
1186
|
+
for (const inv of data.invocations) {
|
|
1187
|
+
const idStr = inv.id.padEnd(36);
|
|
1188
|
+
const statusStr = colorizeStatus(inv.status.padEnd(9), inv.status);
|
|
1189
|
+
const triggerStr = inv.trigger_type.padEnd(7);
|
|
1190
|
+
const dateStr = formatDate(inv.started_at).padEnd(19);
|
|
1191
|
+
const durationStr = formatDuration(inv.started_at, inv.ended_at).padEnd(9);
|
|
1192
|
+
console.log(`${idStr} | ${statusStr} | ${triggerStr} | ${dateStr} | ${durationStr}`);
|
|
1193
|
+
}
|
|
1194
|
+
console.log("\nTo view logs for a specific invocation, run:\n wawesome logs --invocation <id>\n");
|
|
1195
|
+
console.log("To follow live output (waits for the next invocation if none is running):\n wawesome logs <function-name> --follow\n");
|
|
1196
|
+
console.log("To follow a specific invocation:\n wawesome logs --invocation <id> --follow\n");
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Fetch a Function's invocations, transparently falling back from the app-scoped
|
|
1200
|
+
* route to the unscoped (default-app) route on a 404 when `--app` wasn't given.
|
|
1201
|
+
* Shared by the list view and the `--follow` latest-invocation resolver so both
|
|
1202
|
+
* hit the same routes and fallback behaviour.
|
|
1203
|
+
*/
|
|
1204
|
+
async function fetchInvocationsResponse(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, queryString, isVerbose) {
|
|
1205
|
+
const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/invocations${queryString}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
|
|
1206
|
+
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
1207
|
+
let res = await fetch(url, {
|
|
1208
|
+
method: "GET",
|
|
1209
|
+
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
1210
|
+
});
|
|
1211
|
+
if (res.status === 404 && appSlug && !appOverride) {
|
|
1212
|
+
const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/invocations${queryString}`;
|
|
1213
|
+
if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
|
|
1214
|
+
try {
|
|
1215
|
+
const fallbackRes = await fetch(fallbackUrl, {
|
|
1216
|
+
method: "GET",
|
|
1217
|
+
headers: { Authorization: `Bearer ${tenantJwt}` }
|
|
1218
|
+
});
|
|
1219
|
+
if (fallbackRes) res = fallbackRes;
|
|
1220
|
+
} catch {}
|
|
1221
|
+
}
|
|
1222
|
+
return res;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Split a growing SSE buffer into complete event `data` payloads, returning the
|
|
1226
|
+
* still-incomplete remainder. Each SSE event is terminated by a blank line; its
|
|
1227
|
+
* `data:` field lines are rejoined with newlines (so a multi-line NDJSON chunk
|
|
1228
|
+
* survives intact). Comment/keep-alive events (no `data:` line) are dropped.
|
|
1229
|
+
*/
|
|
1230
|
+
function splitSseEvents(buffer) {
|
|
1231
|
+
const events = [];
|
|
1232
|
+
let idx;
|
|
1233
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
1234
|
+
const rawEvent = buffer.slice(0, idx);
|
|
1235
|
+
buffer = buffer.slice(idx + 2);
|
|
1236
|
+
const data = rawEvent.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
|
|
1237
|
+
if (data) events.push(data);
|
|
1238
|
+
}
|
|
1239
|
+
return {
|
|
1240
|
+
events,
|
|
1241
|
+
rest: buffer
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Print one NDJSON log line the way `logs` does, colorizing `stderr` records
|
|
1246
|
+
* red. Falls back to the raw line if it isn't the expected structured shape.
|
|
1247
|
+
*/
|
|
1248
|
+
function printFollowLine(line) {
|
|
1249
|
+
const trimmed = line.trim();
|
|
1250
|
+
if (!trimmed) return;
|
|
1251
|
+
try {
|
|
1252
|
+
const entry = JSON.parse(trimmed);
|
|
1253
|
+
if (entry && typeof entry.msg === "string") {
|
|
1254
|
+
if (entry.stream === "stderr") process.stdout.write(`\x1b[31m${entry.msg}\x1b[0m\n`);
|
|
1255
|
+
else process.stdout.write(`${entry.msg}\n`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
} catch {}
|
|
1259
|
+
process.stdout.write(trimmed + "\n");
|
|
1260
|
+
}
|
|
1261
|
+
/** Maximum number of reconnection attempts on transient errors. */
|
|
1262
|
+
const MAX_RETRIES = 3;
|
|
1263
|
+
/** Base delay in milliseconds for exponential back-off (1 s → 2 s → 4 s). */
|
|
1264
|
+
const BASE_RETRY_DELAY_MS = 1e3;
|
|
1265
|
+
/**
|
|
1266
|
+
* Sleep for `ms` milliseconds, respecting an `AbortSignal` so Ctrl-C doesn't
|
|
1267
|
+
* hang during back-off waits.
|
|
1268
|
+
*/
|
|
1269
|
+
function retrySleep(ms, signal) {
|
|
1270
|
+
return new Promise((resolve, reject) => {
|
|
1271
|
+
if (signal.aborted) {
|
|
1272
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const timer = setTimeout(resolve, ms);
|
|
1276
|
+
const onAbort = () => {
|
|
1277
|
+
clearTimeout(timer);
|
|
1278
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
1279
|
+
};
|
|
1280
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Calculate exponential back-off delay with jitter for a given attempt.
|
|
1285
|
+
* attempt 0 → ~1 s, attempt 1 → ~2 s, attempt 2 → ~4 s.
|
|
1286
|
+
*/
|
|
1287
|
+
function retryDelay(attempt) {
|
|
1288
|
+
const base = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
1289
|
+
return base + Math.random() * base * .25;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Returns `true` for errors that are worth retrying: network-level failures
|
|
1293
|
+
* (fetch throws) and 5xx server errors. Client errors (4xx) are not transient.
|
|
1294
|
+
*/
|
|
1295
|
+
function isTransientError(res) {
|
|
1296
|
+
if (!res) return true;
|
|
1297
|
+
return res.status >= 500;
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Follow a single invocation's output live over the SSE endpoint, printing each
|
|
1301
|
+
* flushed chunk's lines as they arrive. The connection is consumed with `fetch`
|
|
1302
|
+
* + a streaming body reader (rather than `EventSource`) so the tenant `Bearer`
|
|
1303
|
+
* token can be sent.
|
|
1304
|
+
*
|
|
1305
|
+
* On transient network errors or 5xx responses, the connection is retried with
|
|
1306
|
+
* exponential back-off (max 3 retries). Non-retriable errors (401, 404, 400)
|
|
1307
|
+
* exit immediately.
|
|
1308
|
+
*
|
|
1309
|
+
* Returns when the invocation finishes, the server closes the stream at its
|
|
1310
|
+
* max-duration cap, or the user interrupts with Ctrl-C.
|
|
1311
|
+
*/
|
|
1312
|
+
async function followInvocationLog(gatewayUrl, tenantJwt, invocationId, isVerbose) {
|
|
1313
|
+
const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs/stream`;
|
|
1314
|
+
if (isVerbose) console.log(`[wawesome:verbose] GET ${url} (SSE)`);
|
|
1315
|
+
const controller = new AbortController();
|
|
1316
|
+
const onSigint = () => {
|
|
1317
|
+
controller.abort();
|
|
1318
|
+
process.stderr.write("\n[wawesome] Stopped following.\n");
|
|
1319
|
+
process.exit(0);
|
|
1320
|
+
};
|
|
1321
|
+
process.on("SIGINT", onSigint);
|
|
1322
|
+
try {
|
|
1323
|
+
let attempt = 0;
|
|
1324
|
+
while (true) {
|
|
1325
|
+
let res = null;
|
|
1326
|
+
try {
|
|
1327
|
+
res = await fetch(url, {
|
|
1328
|
+
method: "GET",
|
|
1329
|
+
headers: {
|
|
1330
|
+
Authorization: `Bearer ${tenantJwt}`,
|
|
1331
|
+
Accept: "text/event-stream"
|
|
1332
|
+
},
|
|
1333
|
+
signal: controller.signal
|
|
1334
|
+
});
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
if (controller.signal.aborted) throw err;
|
|
1337
|
+
if (attempt < MAX_RETRIES) {
|
|
1338
|
+
const delay = retryDelay(attempt);
|
|
1339
|
+
if (isVerbose) console.error(`[wawesome:verbose] Connection failed (${String(err)}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
|
|
1340
|
+
else console.error(`[wawesome] Connection lost, reconnecting (${attempt + 1}/${MAX_RETRIES})...`);
|
|
1341
|
+
await retrySleep(delay, controller.signal);
|
|
1342
|
+
attempt++;
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
|
|
1346
|
+
if (isVerbose) console.error(`[wawesome:verbose] ${String(err)}`);
|
|
1347
|
+
process.exit(1);
|
|
1348
|
+
}
|
|
1349
|
+
if (res.status === 401) {
|
|
1350
|
+
console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1351
|
+
process.exit(1);
|
|
1352
|
+
}
|
|
1353
|
+
if (res.status === 404) {
|
|
1354
|
+
console.error(`[wawesome] Error: Invocation '${invocationId}' not found or expired (logs are retained for 14 days).`);
|
|
1355
|
+
process.exit(1);
|
|
1356
|
+
}
|
|
1357
|
+
if (res.status === 400) {
|
|
1358
|
+
console.error(`[wawesome] Error: Invalid invocation ID '${invocationId}'.`);
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
}
|
|
1361
|
+
if (!res.ok && isTransientError(res)) {
|
|
1362
|
+
if (attempt < MAX_RETRIES) {
|
|
1363
|
+
const delay = retryDelay(attempt);
|
|
1364
|
+
if (isVerbose) console.error(`[wawesome:verbose] Server error (HTTP ${res.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
|
|
1365
|
+
else console.error(`[wawesome] Server error, reconnecting (${attempt + 1}/${MAX_RETRIES})...`);
|
|
1366
|
+
await retrySleep(delay, controller.signal);
|
|
1367
|
+
attempt++;
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}) after retries.`);
|
|
1371
|
+
process.exit(1);
|
|
1372
|
+
}
|
|
1373
|
+
if (!res.ok) {
|
|
1374
|
+
console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}).`);
|
|
1375
|
+
process.exit(1);
|
|
1376
|
+
}
|
|
1377
|
+
if (!res.body) {
|
|
1378
|
+
console.error("[wawesome] Error: Live tail response had no body stream.");
|
|
1379
|
+
process.exit(1);
|
|
1380
|
+
}
|
|
1381
|
+
attempt = 0;
|
|
1382
|
+
if (isVerbose) console.error(`[wawesome:verbose] Connected to SSE stream.`);
|
|
1383
|
+
console.error(`[wawesome] 📡 Following invocation ${invocationId} (Ctrl-C to stop)...`);
|
|
1384
|
+
try {
|
|
1385
|
+
const reader = res.body.getReader();
|
|
1386
|
+
const decoder = new TextDecoder();
|
|
1387
|
+
let buffer = "";
|
|
1388
|
+
for (;;) {
|
|
1389
|
+
const { value, done } = await reader.read();
|
|
1390
|
+
if (done) break;
|
|
1391
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1392
|
+
const { events, rest } = splitSseEvents(buffer);
|
|
1393
|
+
buffer = rest;
|
|
1394
|
+
for (const chunk of events) for (const line of chunk.split("\n")) printFollowLine(line);
|
|
1395
|
+
}
|
|
1396
|
+
console.error(`[wawesome] ✔ Live tail ended for ${invocationId}.`);
|
|
1397
|
+
return;
|
|
1398
|
+
} catch (streamErr) {
|
|
1399
|
+
if (controller.signal.aborted) throw streamErr;
|
|
1400
|
+
if (attempt < MAX_RETRIES) {
|
|
1401
|
+
const delay = retryDelay(attempt);
|
|
1402
|
+
if (isVerbose) console.error(`[wawesome:verbose] Stream interrupted (${String(streamErr)}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
|
|
1403
|
+
else console.error(`[wawesome] Stream interrupted, reconnecting (${attempt + 1}/${MAX_RETRIES})...`);
|
|
1404
|
+
await retrySleep(delay, controller.signal);
|
|
1405
|
+
attempt++;
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
console.error("[wawesome] Error: Live tail stream interrupted and retries exhausted.");
|
|
1409
|
+
if (isVerbose) console.error(`[wawesome:verbose] ${String(streamErr)}`);
|
|
1410
|
+
process.exit(1);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
} finally {
|
|
1414
|
+
process.removeListener("SIGINT", onSigint);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Follow a whole Function's live output over SSE: the gateway streams output from
|
|
1419
|
+
* every invocation of the Function as it happens, across invocation boundaries,
|
|
1420
|
+
* so this is a continuous `tail -f` for the Function rather than one run.
|
|
1421
|
+
*
|
|
1422
|
+
* Resolves the Function from the `funcNameInput` argument or the
|
|
1423
|
+
* `wawesome-function.json` in the current directory, prefers the App-scoped route
|
|
1424
|
+
* and falls back to the unscoped (default-app) route on a 404, and reconnects
|
|
1425
|
+
* automatically when the server closes the stream at its max-duration cap so the
|
|
1426
|
+
* terminal keeps following. Ctrl-C stops. Auth/'function not found' errors exit
|
|
1427
|
+
* immediately; transient network/5xx errors retry with exponential back-off.
|
|
1428
|
+
*/
|
|
1429
|
+
async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverride, isVerbose) {
|
|
1430
|
+
const config = readFunctionConfig();
|
|
1431
|
+
const appSlug = appOverride || config?.app;
|
|
1432
|
+
const funcName = funcNameInput || config?.function;
|
|
1433
|
+
if (!funcName) {
|
|
1434
|
+
console.error("[wawesome] Error: Missing function name.");
|
|
1435
|
+
console.error("[wawesome] Usage: wawesome logs <function-name> --follow, or run inside a function directory with wawesome-function.json.");
|
|
1436
|
+
process.exit(1);
|
|
1437
|
+
}
|
|
1438
|
+
const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/logs/stream` : null;
|
|
1439
|
+
const unscopedUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/logs/stream`;
|
|
1440
|
+
const controller = new AbortController();
|
|
1441
|
+
const onSigint = () => {
|
|
1442
|
+
controller.abort();
|
|
1443
|
+
process.stderr.write("\n[wawesome] Stopped following.\n");
|
|
1444
|
+
process.exit(0);
|
|
1445
|
+
};
|
|
1446
|
+
process.on("SIGINT", onSigint);
|
|
1447
|
+
const headers = {
|
|
1448
|
+
Authorization: `Bearer ${tenantJwt}`,
|
|
1449
|
+
Accept: "text/event-stream"
|
|
1450
|
+
};
|
|
1451
|
+
try {
|
|
1452
|
+
let attempt = 0;
|
|
1453
|
+
let announced = false;
|
|
1454
|
+
while (true) {
|
|
1455
|
+
if (controller.signal.aborted) return;
|
|
1456
|
+
let res = null;
|
|
1457
|
+
try {
|
|
1458
|
+
if (scopedUrl) {
|
|
1459
|
+
res = await fetch(scopedUrl, {
|
|
1460
|
+
headers,
|
|
1461
|
+
signal: controller.signal
|
|
1462
|
+
});
|
|
1463
|
+
if (res.status === 404) {
|
|
1464
|
+
if (isVerbose) console.error(`[wawesome:verbose] 404 on app-scoped route, retrying unscoped.`);
|
|
1465
|
+
res = await fetch(unscopedUrl, {
|
|
1466
|
+
headers,
|
|
1467
|
+
signal: controller.signal
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
} else res = await fetch(unscopedUrl, {
|
|
1471
|
+
headers,
|
|
1472
|
+
signal: controller.signal
|
|
1473
|
+
});
|
|
1474
|
+
} catch (err) {
|
|
1475
|
+
if (controller.signal.aborted) return;
|
|
1476
|
+
if (attempt < MAX_RETRIES) {
|
|
1477
|
+
const delay = retryDelay(attempt);
|
|
1478
|
+
console.error(`[wawesome] Connection lost, reconnecting (${attempt + 1}/${MAX_RETRIES})...`);
|
|
1479
|
+
if (isVerbose) console.error(`[wawesome:verbose] ${String(err)}`);
|
|
1480
|
+
await retrySleep(delay, controller.signal);
|
|
1481
|
+
attempt++;
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
|
|
1485
|
+
process.exit(1);
|
|
1486
|
+
}
|
|
1487
|
+
if (res.status === 401) {
|
|
1488
|
+
console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1489
|
+
process.exit(1);
|
|
1490
|
+
}
|
|
1491
|
+
if (res.status === 404) {
|
|
1492
|
+
console.error(`[wawesome] Error: Function '${funcName}' not found.`);
|
|
1493
|
+
process.exit(1);
|
|
1494
|
+
}
|
|
1495
|
+
if (!res.ok && isTransientError(res)) {
|
|
1496
|
+
if (attempt < MAX_RETRIES) {
|
|
1497
|
+
const delay = retryDelay(attempt);
|
|
1498
|
+
console.error(`[wawesome] Server error, reconnecting (${attempt + 1}/${MAX_RETRIES})...`);
|
|
1499
|
+
await retrySleep(delay, controller.signal);
|
|
1500
|
+
attempt++;
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}) after retries.`);
|
|
1504
|
+
process.exit(1);
|
|
1505
|
+
}
|
|
1506
|
+
if (!res.ok) {
|
|
1507
|
+
console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}).`);
|
|
1508
|
+
process.exit(1);
|
|
1509
|
+
}
|
|
1510
|
+
if (!res.body) {
|
|
1511
|
+
console.error("[wawesome] Error: Live tail response had no body stream.");
|
|
1512
|
+
process.exit(1);
|
|
1513
|
+
}
|
|
1514
|
+
attempt = 0;
|
|
1515
|
+
if (!announced) {
|
|
1516
|
+
const target = appSlug ? `${appSlug}/${funcName}` : funcName;
|
|
1517
|
+
console.error(`[wawesome] 📡 Following function ${target} (Ctrl-C to stop)...`);
|
|
1518
|
+
announced = true;
|
|
1519
|
+
}
|
|
1520
|
+
try {
|
|
1521
|
+
const reader = res.body.getReader();
|
|
1522
|
+
const decoder = new TextDecoder();
|
|
1523
|
+
let buffer = "";
|
|
1524
|
+
for (;;) {
|
|
1525
|
+
const { value, done } = await reader.read();
|
|
1526
|
+
if (done) break;
|
|
1527
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1528
|
+
const { events, rest } = splitSseEvents(buffer);
|
|
1529
|
+
buffer = rest;
|
|
1530
|
+
for (const chunk of events) for (const line of chunk.split("\n")) printFollowLine(line);
|
|
1531
|
+
}
|
|
1532
|
+
if (controller.signal.aborted) return;
|
|
1533
|
+
if (isVerbose) console.error(`[wawesome:verbose] Stream closed by server (cap); reconnecting.`);
|
|
1534
|
+
continue;
|
|
1535
|
+
} catch (streamErr) {
|
|
1536
|
+
if (controller.signal.aborted) return;
|
|
1537
|
+
if (attempt < MAX_RETRIES) {
|
|
1538
|
+
const delay = retryDelay(attempt);
|
|
1539
|
+
console.error(`[wawesome] Stream interrupted, reconnecting (${attempt + 1}/${MAX_RETRIES})...`);
|
|
1540
|
+
if (isVerbose) console.error(`[wawesome:verbose] ${String(streamErr)}`);
|
|
1541
|
+
await retrySleep(delay, controller.signal);
|
|
1542
|
+
attempt++;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
console.error("[wawesome] Error: Live tail stream interrupted and retries exhausted.");
|
|
1546
|
+
process.exit(1);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
} finally {
|
|
1550
|
+
process.removeListener("SIGINT", onSigint);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
//#endregion
|
|
1554
|
+
//#region src/workspace.ts
|
|
1555
|
+
function requireCredentials() {
|
|
1556
|
+
const creds = readCredentials();
|
|
1557
|
+
if (!creds) {
|
|
1558
|
+
console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
|
|
1559
|
+
process.exit(1);
|
|
1560
|
+
}
|
|
1561
|
+
return creds;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Show the workspace's name, address, and whether that address is still free to
|
|
1565
|
+
* change — the rename is offered here rather than discovered by attempting it.
|
|
1566
|
+
*/
|
|
1567
|
+
async function showWorkspace(options) {
|
|
1568
|
+
const creds = requireCredentials();
|
|
1569
|
+
let tenant;
|
|
1570
|
+
try {
|
|
1571
|
+
tenant = await fetchTenantDetails(creds);
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
|
|
1574
|
+
process.exit(1);
|
|
1575
|
+
}
|
|
1576
|
+
console.log("\n[wawesome] Workspace:");
|
|
1577
|
+
console.log(` Name: ${tenant.name}`);
|
|
1578
|
+
console.log(` Address: ${tenant.tenant_slug}`);
|
|
1579
|
+
console.log(` Tenant: ${tenant.id}`);
|
|
1580
|
+
if (options.verbose) console.log(` Invoke: ${creds.gateway_url}/v1/s/${tenant.tenant_slug}/apps/<app>/functions/<function>/invoke`);
|
|
1581
|
+
if (tenant.slug_locked) console.log("\n 🔒 The address is fixed — a Function version has been promoted and live URLs carry it.");
|
|
1582
|
+
else {
|
|
1583
|
+
console.log("\n The address can still be changed: \x1B[36mwawesome workspace rename <name>\x1B[0m");
|
|
1584
|
+
console.log(" It locks for good the first time you deploy.");
|
|
1585
|
+
}
|
|
1586
|
+
console.log("");
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Change the workspace's public address.
|
|
1590
|
+
*
|
|
1591
|
+
* The lock is read from the gateway before asking, so a user who cannot rename
|
|
1592
|
+
* is told why instead of being walked into a rejection. The rejection is still
|
|
1593
|
+
* handled: a promotion landing between the two calls is the one case where the
|
|
1594
|
+
* gateway knows something this command could not.
|
|
1595
|
+
*/
|
|
1596
|
+
async function renameWorkspace(slug, options) {
|
|
1597
|
+
const creds = requireCredentials();
|
|
1598
|
+
if (!slug || !slug.trim()) {
|
|
1599
|
+
console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
|
|
1600
|
+
process.exit(1);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
try {
|
|
1604
|
+
const tenant = await fetchTenantDetails(creds);
|
|
1605
|
+
if (tenant.slug_locked) {
|
|
1606
|
+
console.error("\n[wawesome] \x1B[31mThe workspace address can no longer be changed.\x1B[0m");
|
|
1607
|
+
console.error(`[wawesome] '${tenant.tenant_slug}' is fixed: a Function version has been promoted, and live URLs already carry it.`);
|
|
1608
|
+
process.exit(1);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (options.verbose) console.log(`[wawesome:verbose] Renaming '${tenant.tenant_slug}' → '${slug}'`);
|
|
1612
|
+
const result = await renameTenantSlug(creds, slug.trim());
|
|
1613
|
+
console.log("\n======================================================");
|
|
1614
|
+
console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
|
|
1615
|
+
console.log("======================================================");
|
|
1616
|
+
console.log(`\n Address: ${result.tenant_slug}`);
|
|
1617
|
+
console.log(` Previous: ${result.previous_slug} (still resolves)`);
|
|
1618
|
+
console.log("======================================================\n");
|
|
1619
|
+
} catch (err) {
|
|
1620
|
+
if (err instanceof GatewayError) {
|
|
1621
|
+
console.error(`\n[wawesome] \x1b[31m${err.message}\x1b[0m`);
|
|
1622
|
+
switch (err.reason) {
|
|
1623
|
+
case "taken":
|
|
1624
|
+
console.error("[wawesome] Pick a different name.");
|
|
1625
|
+
break;
|
|
1626
|
+
case "reserved":
|
|
1627
|
+
console.error("[wawesome] Pick a different name.");
|
|
1628
|
+
break;
|
|
1629
|
+
case "malformed":
|
|
1630
|
+
console.error("[wawesome] Use lowercase letters, numbers and single hyphens.");
|
|
1631
|
+
break;
|
|
1632
|
+
case "locked":
|
|
1633
|
+
console.error("[wawesome] A deploy landed while this was running, which fixed the address for good.");
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
console.error("");
|
|
1637
|
+
process.exit(1);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
console.error(`[wawesome] Error: ${err instanceof Error ? err.message : err}`);
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/** Dispatch for `wawesome workspace [action]`. */
|
|
1645
|
+
async function workspaceCommand(action, target, options) {
|
|
1646
|
+
if (!action || action === "show" || action === "info") return showWorkspace(options);
|
|
1647
|
+
if (action === "rename") return renameWorkspace(target, options);
|
|
1648
|
+
console.error(`[wawesome] Error: Unknown workspace action '${action}'.`);
|
|
1649
|
+
console.error("[wawesome] Usage: wawesome workspace [show|rename <name>]");
|
|
1650
|
+
process.exit(1);
|
|
1651
|
+
}
|
|
1652
|
+
//#endregion
|
|
840
1653
|
//#region src/index.ts
|
|
841
1654
|
const cli = cac("wawesome");
|
|
842
1655
|
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));
|
|
@@ -852,10 +1665,32 @@ cli.command("env [action] [key] [value]", "Manage environment variables (set, li
|
|
|
852
1665
|
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));
|
|
853
1666
|
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));
|
|
854
1667
|
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));
|
|
855
|
-
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));
|
|
1668
|
+
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));
|
|
856
1669
|
cli.command("logout", "Clear stored authentication credentials").action(() => logout());
|
|
857
1670
|
cli.command("whoami", "Show current login session info").action(() => whoami());
|
|
1671
|
+
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));
|
|
858
1672
|
cli.command("init", "Scaffold a new function project in the current directory").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
|
|
1673
|
+
cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
|
|
1674
|
+
|
|
1675
|
+
The target argument determines what the command does:
|
|
1676
|
+
|
|
1677
|
+
MODE 1 — List invocations (no UUID target)
|
|
1678
|
+
wawesome logs # list invocations for the function in the current directory
|
|
1679
|
+
wawesome logs my-function # list invocations for 'my-function'
|
|
1680
|
+
wawesome logs my-function --error # only show failed invocations
|
|
1681
|
+
wawesome logs my-function --running # only show currently running invocations
|
|
1682
|
+
|
|
1683
|
+
MODE 2 — Fetch captured log body (UUID target or --invocation)
|
|
1684
|
+
wawesome logs <invocation-id> # print stdout/stderr for a specific invocation
|
|
1685
|
+
wawesome logs my-function --invocation <id> # same, explicit flag form
|
|
1686
|
+
|
|
1687
|
+
MODE 3 — Follow live output (--follow / -f)
|
|
1688
|
+
wawesome logs my-function --follow # follow the function: stream output from every invocation as it runs
|
|
1689
|
+
wawesome logs <invocation-id> --follow # follow one specific in-flight invocation by ID
|
|
1690
|
+
|
|
1691
|
+
With a function name, --follow streams the function's output continuously across
|
|
1692
|
+
invocations — new output appears each time the function runs, no need to catch a
|
|
1693
|
+
specific invocation. Press Ctrl-C to stop at any time.`).option("-f, --follow", "Stream live output (tail -f style). Follows a running invocation or waits for the next one. Ctrl-C to stop").option("-i, --invocation <id>", "Fetch stdout/stderr log body for a specific invocation ID").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-s, --status <status>", "Filter invocations by status (success, error, timeout, running)").option("--success", "Shorthand for --status success").option("--error", "Shorthand for --status error").option("--timeout", "Shorthand for --status timeout").option("--running", "Shorthand for --status running").option("-v, --verbose", "Enable verbose debug output").action((target, options) => logsCommand(target, options));
|
|
859
1694
|
cli.help();
|
|
860
1695
|
cli.version("1.0.0");
|
|
861
1696
|
cli.parse();
|