wawesome 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +99 -8
  2. package/dist/index.mjs +823 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -106,6 +106,11 @@ npx wawesome deploy
106
106
  | `npx wawesome logs [func]` | List recent past invocations for a function |
107
107
  | `npx wawesome logs --invocation <id>` | Fetch full stdout/stderr log body for a specific invocation |
108
108
  | `npx wawesome logs <func> --follow` | Follow live output (waits for the next invocation if needed) |
109
+ | `npx wawesome invoke [func]` | Fire a function run immediately and follow its output live |
110
+ | `npx wawesome cron [func]` | List schedules for a function or app |
111
+ | `npx wawesome cron pause <name>` | Pause a schedule by name (survives future deploys) |
112
+ | `npx wawesome cron resume <name>` | Resume a paused schedule (no backfill) |
113
+ | `npx wawesome cron history [func]` | Read background run history for scheduled and manual runs |
109
114
  | `npx wawesome version list` | List version history for the current function |
110
115
  | `npx wawesome version switch <v>` | Roll back or promote a specific function version |
111
116
  | `npx wawesome env list` | View environment variables for the current app |
@@ -203,6 +208,77 @@ exponential back-off (up to 3 retries). Non-recoverable errors like authenticati
203
208
 
204
209
  ---
205
210
 
211
+ ## ⚡ Manual Invocation
212
+
213
+ Fire a background run of a Function immediately without waiting for a schedule tick or deploying code:
214
+
215
+ ```bash
216
+ # Invoke the function in the current directory and follow its output
217
+ npx wawesome invoke
218
+
219
+ # Invoke a specific function by name
220
+ npx wawesome invoke my-function
221
+
222
+ # Send custom HTTP method and request body
223
+ npx wawesome invoke -m POST -d '{"event":"audit"}'
224
+
225
+ # Fire without following live output
226
+ npx wawesome invoke --no-follow
227
+ ```
228
+
229
+ ---
230
+
231
+ ## ⏰ Schedules & Cron Management
232
+
233
+ Manage recurring Schedules and inspect background run history directly from your terminal.
234
+
235
+ ### 1. List Schedules
236
+
237
+ List a Function's Schedules with expression, state, and next run in UTC:
238
+
239
+ ```bash
240
+ # List schedules for the function in the current directory
241
+ npx wawesome cron
242
+
243
+ # List schedules for a specific function
244
+ npx wawesome cron list my-function
245
+
246
+ # List schedules across all functions in an App
247
+ npx wawesome cron list --app my-app
248
+ ```
249
+
250
+ The output clearly distinguishes the three off-states:
251
+ - `paused`: stopped by a user, resumable with `wawesome cron resume <name>`
252
+ - `not in this config file`: disabled because it was removed from configuration, resumable only by declaring it again in code
253
+ - `suspended`: suspended by the non-payment ladder, resumable only after settling workspace balance
254
+
255
+ ### 2. Pause and Resume Schedules
256
+
257
+ ```bash
258
+ # Pause a schedule by name (stops queued ticks and survives future deploys)
259
+ npx wawesome cron pause nightly-reconcile
260
+
261
+ # Pause with an optional reason for incident context
262
+ npx wawesome cron pause nightly-reconcile --reason "database maintenance"
263
+
264
+ # Resume a paused schedule (recomputes next run from now, no catch-up backfilling)
265
+ npx wawesome cron resume nightly-reconcile
266
+ ```
267
+
268
+ ### 3. Read Run History
269
+
270
+ Inspect past scheduled and manual runs, showing when each run was due, when it started, pool delay, and how it ended:
271
+
272
+ ```bash
273
+ # View run history for the current function
274
+ npx wawesome cron history
275
+
276
+ # Filter run history by state (pending, running, dispatched, skipped, missed, cancelled, lost, failed)
277
+ npx wawesome cron history my-function --state failed
278
+ ```
279
+
280
+ ---
281
+
206
282
  ## ⚙️ Configuration & Custom Gateway
207
283
 
208
284
  ### `wawesome-function.json`
@@ -220,6 +296,12 @@ Every project directory includes a `wawesome-function.json` file generated durin
220
296
  `app` is the App this Function is deployed into, and it is client-facing — every deploy from this
221
297
  directory is scoped to it.
222
298
 
299
+ `function` is the address. Changing it does not rename anything: your Function's URL is built from
300
+ its name, so the next deploy lands on a Function of its own and the old one stays live at the old
301
+ URL, serving the code its callers already hold. The CLI remembers where this directory last
302
+ deployed and asks before that happens, naming both URLs. If you meant it, delete the old Function
303
+ from the dashboard once nothing calls it.
304
+
223
305
  Add `"assets"` to deploy static files beside your code:
224
306
 
225
307
  ```json
@@ -313,17 +395,26 @@ A job on a timer should not also be sitting at a guessable URL where a stranger
313
395
  }
314
396
  ```
315
397
 
316
- A private Function has **no address at all** — not a hidden one, not one behind a credential. A
317
- request for it is answered with the same 404 as a Function that was never deployed, on your App's
318
- own hostname and on the development path form alike. This is how you deploy a job with side effects
319
- without leaving it where a stranger who guesses the slug can fire it.
398
+ A private Function has **no public address at all** in production — not a hidden one, not one
399
+ behind a credential. A request for it against your App's own hostname is answered with the same 404
400
+ as a Function that was never deployed. This is how you deploy a job with side effects without leaving
401
+ it where a stranger who guesses the slug can fire it.
320
402
 
321
403
  Leave the line out and your Function is public, which is what every Function without it has always
322
404
  been.
323
405
 
324
- Today that is all a private Function is: there is no way *in* to one yet. Schedules do not fire yet,
325
- and the authenticated trigger that runs a Function by hand is not built — so make one private when
326
- you want it off the web, and expect to publish it again to call it until those land.
406
+ On your own machine, the local development surface serves Functions regardless of visibility, and
407
+ honours an explicit `x-wawesome-trigger` header so you can exercise a scheduled run by hand:
408
+
409
+ ```bash
410
+ # Exercise a private or scheduled function locally on the development path form:
411
+ curl -X POST http://localhost:3000/x/my-tenant-slug/default-app/nightly-reconcile \
412
+ -H "x-wawesome-trigger: schedule"
413
+ ```
414
+
415
+ Production strips the reserved header namespace inbound, so the same header against your App's own
416
+ hostname reaches nothing that reads it: the run is a `caller`'s, and a private Function is a 404
417
+ either way. Skipping your own authorization for a `schedule` run therefore opens nothing.
327
418
 
328
419
  Making a Function private takes nothing but the deploy. Making it public again does not: deleting
329
420
  the line is refused, and the deploy tells you so having written nothing.
@@ -348,7 +439,7 @@ Four headers arrive or leave on it, and the stripping is what makes them worth t
348
439
  | Header | Direction | What it means |
349
440
  | --- | --- | --- |
350
441
  | `x-wawesome-forwarded-prefix` | inbound | The mount that was stripped from the path. Join it to the path you observe to rebuild the caller's URL. |
351
- | `x-wawesome-trigger` | inbound | How this run started: `caller` when someone called your address. A caller cannot forge it, so you may skip your own authorization on anything else and open nothing. |
442
+ | `x-wawesome-trigger` | inbound | How this run started: `caller` when someone called your address, `schedule` when fired by a Schedule. A caller cannot forge it in production (stripped inbound). On the local development surface, pass `x-wawesome-trigger: schedule` to exercise a background run by hand with the collapsed budget. |
352
443
  | `x-wawesome-invocation-id` | outbound | The id of this run — the key to fetch its logs with `npx wawesome logs --invocation <id>`. |
353
444
  | `x-wawesome-error` | outbound | Present only when the platform failed, never when your Function did. Its *absence* means the status on the wire is yours — up to the moment your response is committed, and no further. |
354
445
 
package/dist/index.mjs CHANGED
@@ -759,7 +759,7 @@ async function buildJs(entryInput, options) {
759
759
  * that has to name this version — `--version`, the dependency a scaffolded
760
760
  * project pins — reads it here, so a release bumps one file.
761
761
  */
762
- const CLI_VERSION = "0.5.0";
762
+ const CLI_VERSION = "0.7.0";
763
763
  //#endregion
764
764
  //#region src/prompt.ts
765
765
  /**
@@ -1159,18 +1159,17 @@ async function whoami() {
1159
1159
  /**
1160
1160
  * When a schedule next runs, or why it does not run at all.
1161
1161
  *
1162
- * The three ways of being off never render alike: someone who cannot tell a
1163
- * pause from a schedule they deleted from their config file will press resume
1164
- * and watch nothing happen.
1162
+ * The three ways of being off never render alike, and a schedule that is off
1163
+ * twice over says so: someone who reads only the pause will declare it again,
1164
+ * press resume, and file a bug when neither starts it.
1165
1165
  */
1166
1166
  function whenItRuns(schedule) {
1167
- switch (schedule.state) {
1168
- case "active": return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
1169
- case "paused": return "paused — resume it to run again";
1170
- case "undeclared": return "not in this config file — disabled, kept";
1171
- case "suspended": return "suspended — the workspace owes payment";
1172
- default: return schedule.state;
1173
- }
1167
+ if (schedule.state === "suspended") return "suspended — the workspace owes payment";
1168
+ const undeclared = schedule.declared === false;
1169
+ if (schedule.state === "paused") return undeclared ? "paused, and not in this config file declare it again, then resume" : "paused — resume it to run again";
1170
+ if (undeclared) return "not in this config file — disabled, kept";
1171
+ if (schedule.state !== "active") return schedule.state;
1172
+ return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
1174
1173
  }
1175
1174
  /** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
1176
1175
  function asUtc(iso) {
@@ -1363,7 +1362,9 @@ function billingPageUrl() {
1363
1362
  const PLAN_LIMITS = [
1364
1363
  "app-slots-exhausted",
1365
1364
  "storage-exhausted",
1366
- "paid-plan-required"
1365
+ "paid-plan-required",
1366
+ "payment-required",
1367
+ "schedule-suspended"
1367
1368
  ];
1368
1369
  /** The rule itself is the gateway's prose, and is deliberately not restated here. */
1369
1370
  function planLimitAdvice(reason) {
@@ -1513,6 +1514,97 @@ function trimTrailingSlashes(origin) {
1513
1514
  return origin.replace(/\/+$/, "");
1514
1515
  }
1515
1516
  //#endregion
1517
+ //#region src/deploy-target.ts
1518
+ const RECORD_PATH = path.join(".wawesome", "last-deploy.json");
1519
+ function readLastDeployTarget(projectDir = process.cwd()) {
1520
+ let parsed;
1521
+ try {
1522
+ parsed = JSON.parse(fs.readFileSync(path.join(projectDir, RECORD_PATH), "utf-8"));
1523
+ } catch {
1524
+ return null;
1525
+ }
1526
+ if (typeof parsed.app !== "string" || typeof parsed.function !== "string") return null;
1527
+ return {
1528
+ app: parsed.app,
1529
+ function: parsed.function
1530
+ };
1531
+ }
1532
+ /**
1533
+ * The directory ignores itself rather than relying on the project's `.gitignore`,
1534
+ * since this is per-checkout state and most projects reaching it were scaffolded
1535
+ * before it existed. A write that fails is dropped: the deploy has already
1536
+ * landed, and reporting a read-only project directory as a failed deploy would
1537
+ * be a worse lie than forgetting the name.
1538
+ */
1539
+ function recordDeployTarget(target, projectDir = process.cwd()) {
1540
+ const file = path.join(projectDir, RECORD_PATH);
1541
+ try {
1542
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1543
+ fs.writeFileSync(path.join(path.dirname(file), ".gitignore"), "*\n", "utf-8");
1544
+ fs.writeFileSync(file, JSON.stringify(target, null, 2) + "\n", "utf-8");
1545
+ } catch {
1546
+ return;
1547
+ }
1548
+ }
1549
+ /** See ADR-0016 for the fork this asks about, and why no rename is on offer. */
1550
+ async function confirmDeployTarget(creds, target, projectDir = process.cwd()) {
1551
+ const previous = readLastDeployTarget(projectDir);
1552
+ if (!previous || previous.app === target.app && previous.function === target.function) return true;
1553
+ const surface = await addressesFor(creds, previous, target);
1554
+ for (const line of divergenceLines(previous, target, surface)) console.warn(line);
1555
+ if (!isInteractive()) {
1556
+ console.warn("[wawesome] There is no terminal to confirm at, so the deploy continues.\n");
1557
+ return true;
1558
+ }
1559
+ const session = openPromptSession();
1560
+ try {
1561
+ const answer = await session.ask(` Deploy to '${qualifiedName(target)}' and leave '${qualifiedName(previous)}' live? [y/N]`, "");
1562
+ return /^y(es)?$/i.test(answer.trim());
1563
+ } finally {
1564
+ session.close();
1565
+ }
1566
+ }
1567
+ function divergenceLines(previous, target, addresses) {
1568
+ return [
1569
+ "",
1570
+ `[wawesome] \x1b[33mThis project last deployed to ${qualifiedName(previous)}.\x1b[0m`,
1571
+ `[wawesome] wawesome-function.json now says ${qualifiedName(target)}.`,
1572
+ "",
1573
+ "[wawesome] Deploying will not rename it. A Function's address is built from its",
1574
+ "[wawesome] name, so this deploy lands on a Function of its own and leaves the old",
1575
+ "[wawesome] one exactly as it is — promoted, and still answering the code on it:",
1576
+ "",
1577
+ `[wawesome] stays live: ${addresses.previous ?? qualifiedName(previous)}`,
1578
+ `[wawesome] gets this deploy: ${addresses.next ?? qualifiedName(target)}`,
1579
+ "",
1580
+ "[wawesome] Anything holding the old address keeps reaching the old code. Delete",
1581
+ "[wawesome] the old Function from the dashboard once nothing calls it.",
1582
+ ""
1583
+ ];
1584
+ }
1585
+ function qualifiedName(target) {
1586
+ return `${target.app}/${target.function}`;
1587
+ }
1588
+ /**
1589
+ * A gateway that cannot be reached must not turn this into a failed deploy: the
1590
+ * names alone still say what is about to happen, which is the point of saying it.
1591
+ */
1592
+ async function addressesFor(creds, previous, target) {
1593
+ try {
1594
+ const { slug } = await resolveWorkspace(creds);
1595
+ const surface = await invocationSurfaceOrNone(creds.gateway_url);
1596
+ return {
1597
+ previous: publicAddress(surface, slug, previous.app, previous.function),
1598
+ next: publicAddress(surface, slug, target.app, target.function)
1599
+ };
1600
+ } catch {
1601
+ return {
1602
+ previous: null,
1603
+ next: null
1604
+ };
1605
+ }
1606
+ }
1607
+ //#endregion
1516
1608
  //#region src/deploy.ts
1517
1609
  function declaredFields(declared) {
1518
1610
  return {
@@ -1564,6 +1656,13 @@ async function deploy(entryInput, options) {
1564
1656
  console.log(`[wawesome:verbose] Deploying to app=${app}, function=${funcName}`);
1565
1657
  console.log(`[wawesome:verbose] Gateway: ${creds.gateway_url}`);
1566
1658
  }
1659
+ if (!await confirmDeployTarget(creds, {
1660
+ app,
1661
+ function: funcName
1662
+ })) {
1663
+ console.log("[wawesome] Nothing was deployed.");
1664
+ process.exit(0);
1665
+ }
1567
1666
  let bundlePath;
1568
1667
  if (options.skipBuild) {
1569
1668
  bundlePath = path.resolve(options.out);
@@ -1631,8 +1730,12 @@ async function deploy(entryInput, options) {
1631
1730
  process.exit(1);
1632
1731
  }
1633
1732
  console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
1634
- console.error("[wawesome] Code versions are immutable and cannot be overwritten.");
1635
- console.error("[wawesome] To switch active version, run: \x1B[36mwawesome version switch\x1B[0m\n");
1733
+ const advice = planLimitAdvice(refusal.reason);
1734
+ if (advice) console.error(`[wawesome] ${advice}\n`);
1735
+ else if (!refusal.reason) {
1736
+ console.error("[wawesome] Code versions are immutable and cannot be overwritten.");
1737
+ console.error("[wawesome] To switch active version, run: \x1B[36mwawesome version switch\x1B[0m\n");
1738
+ }
1636
1739
  } else {
1637
1740
  console.error(`[wawesome] Error: Code upload failed (HTTP ${uploadRes.status}).`);
1638
1741
  if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
@@ -1670,6 +1773,10 @@ async function deploy(entryInput, options) {
1670
1773
  process.exit(1);
1671
1774
  }
1672
1775
  }
1776
+ recordDeployTarget({
1777
+ app,
1778
+ function: funcName
1779
+ });
1673
1780
  let address = null;
1674
1781
  let surface = null;
1675
1782
  if (visibility === "public") try {
@@ -3606,8 +3713,237 @@ async function followFunctionLog(gatewayUrl, tenantJwt, funcNameInput, appOverri
3606
3713
  }
3607
3714
  }
3608
3715
  //#endregion
3716
+ //#region src/invoke.ts
3717
+ const MAX_STREAM_RETRIES = 3;
3718
+ function formatOutcomeDuration(meta) {
3719
+ if (!meta) return "-";
3720
+ if (meta.duration_ms !== void 0 && meta.duration_ms !== null && !isNaN(meta.duration_ms) && meta.duration_ms >= 0) {
3721
+ if (meta.duration_ms < 1e3) return `${meta.duration_ms}ms`;
3722
+ return `${(meta.duration_ms / 1e3).toFixed(2)}s`;
3723
+ }
3724
+ if (meta.started_at) return formatDuration(meta.started_at, meta.ended_at);
3725
+ return "-";
3726
+ }
3727
+ function printOutcome(meta) {
3728
+ const durationStr = formatOutcomeDuration(meta);
3729
+ const statusStr = colorizeStatus(meta.status, meta.status);
3730
+ if (meta.status === "success") console.log(`[wawesome] ✔ Run completed: ${statusStr} (${durationStr})`);
3731
+ else console.log(`[wawesome] ✖ Run completed: ${statusStr} (${durationStr})`);
3732
+ }
3733
+ async function triggerRun(gatewayUrl, tenantJwt, funcName, appSlug, appOverride, payload, isVerbose) {
3734
+ const url = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
3735
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${url}`);
3736
+ let res = await fetch(url, {
3737
+ method: "POST",
3738
+ headers: {
3739
+ Authorization: `Bearer ${tenantJwt}`,
3740
+ "Content-Type": "application/json"
3741
+ },
3742
+ body: JSON.stringify(payload)
3743
+ });
3744
+ if (res.status === 404 && appSlug && !appOverride) {
3745
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs`;
3746
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
3747
+ try {
3748
+ const fallbackRes = await fetch(fallbackUrl, {
3749
+ method: "POST",
3750
+ headers: {
3751
+ Authorization: `Bearer ${tenantJwt}`,
3752
+ "Content-Type": "application/json"
3753
+ },
3754
+ body: JSON.stringify(payload)
3755
+ });
3756
+ if (fallbackRes) res = fallbackRes;
3757
+ } catch {}
3758
+ }
3759
+ return res;
3760
+ }
3761
+ async function fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3762
+ const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}`;
3763
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3764
+ const res = await fetch(url, {
3765
+ method: "GET",
3766
+ headers: { Authorization: `Bearer ${tenantJwt}` }
3767
+ });
3768
+ if (!res.ok) return null;
3769
+ return await res.json();
3770
+ }
3771
+ async function fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose) {
3772
+ const url = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs`;
3773
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
3774
+ const res = await fetch(url, {
3775
+ method: "GET",
3776
+ headers: { Authorization: `Bearer ${tenantJwt}` }
3777
+ });
3778
+ if (!res.ok) return null;
3779
+ return await res.text();
3780
+ }
3781
+ async function followRun(gatewayUrl, tenantJwt, invocationId, isVerbose, pollIntervalMs = 250) {
3782
+ const streamUrl = `${gatewayUrl}/v1/invocations/${encodeURIComponent(invocationId)}/logs/stream`;
3783
+ const controller = new AbortController();
3784
+ const onSigint = () => {
3785
+ controller.abort();
3786
+ process.stderr.write("\n[wawesome] Stopped following.\n");
3787
+ process.exit(0);
3788
+ };
3789
+ process.on("SIGINT", onSigint);
3790
+ try {
3791
+ let attempt = 0;
3792
+ let streamReadComplete = false;
3793
+ let printedAny = false;
3794
+ while (!streamReadComplete) {
3795
+ if (controller.signal.aborted) return;
3796
+ let res = null;
3797
+ try {
3798
+ res = await fetch(streamUrl, {
3799
+ method: "GET",
3800
+ headers: {
3801
+ Authorization: `Bearer ${tenantJwt}`,
3802
+ Accept: "text/event-stream"
3803
+ },
3804
+ signal: controller.signal
3805
+ });
3806
+ } catch (err) {
3807
+ if (controller.signal.aborted) return;
3808
+ if (attempt < MAX_STREAM_RETRIES) {
3809
+ const delay = retryDelay(attempt);
3810
+ if (isVerbose) console.error(`[wawesome:verbose] Stream connection failed (${String(err)}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_STREAM_RETRIES})...`);
3811
+ await retrySleep(delay, controller.signal);
3812
+ attempt++;
3813
+ continue;
3814
+ }
3815
+ console.error("[wawesome] Error: Failed to connect to the live tail after retries.");
3816
+ process.exit(1);
3817
+ }
3818
+ if (res.status === 404) {
3819
+ const meta = await fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose);
3820
+ if (!meta) {
3821
+ console.error(`[wawesome] Error: Invocation '${invocationId}' not found.`);
3822
+ process.exit(1);
3823
+ }
3824
+ if (meta.status === "queued" || meta.status === "running") {
3825
+ await retrySleep(pollIntervalMs, controller.signal);
3826
+ continue;
3827
+ }
3828
+ if (meta.status === "success" || meta.status === "error" || meta.status === "timeout") {
3829
+ const logBody = await fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose);
3830
+ if (logBody) for (const line of logBody.split("\n")) printFollowLine(line);
3831
+ printOutcome(meta);
3832
+ return;
3833
+ }
3834
+ console.error(`[wawesome] ✖ Run ${meta.status}${meta.failure_reason ? `: ${meta.failure_reason}` : ""}`);
3835
+ return;
3836
+ }
3837
+ if (res.status === 401) {
3838
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3839
+ process.exit(1);
3840
+ }
3841
+ if (!res.ok && res.status >= 500) {
3842
+ if (attempt < MAX_STREAM_RETRIES) {
3843
+ const delay = retryDelay(attempt);
3844
+ if (isVerbose) console.error(`[wawesome:verbose] Server error (${res.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_STREAM_RETRIES})...`);
3845
+ await retrySleep(delay, controller.signal);
3846
+ attempt++;
3847
+ continue;
3848
+ }
3849
+ console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}) after retries.`);
3850
+ process.exit(1);
3851
+ }
3852
+ if (!res.ok) {
3853
+ console.error(`[wawesome] Error: Failed to open live tail (HTTP ${res.status}).`);
3854
+ process.exit(1);
3855
+ }
3856
+ if (!res.body) {
3857
+ console.error("[wawesome] Error: Live tail response had no body stream.");
3858
+ process.exit(1);
3859
+ }
3860
+ attempt = 0;
3861
+ try {
3862
+ const reader = res.body.getReader();
3863
+ const decoder = new TextDecoder();
3864
+ let buffer = "";
3865
+ for (;;) {
3866
+ const { value, done } = await reader.read();
3867
+ if (done) break;
3868
+ buffer += decoder.decode(value, { stream: true });
3869
+ const { events, rest } = splitSseEvents(buffer);
3870
+ buffer = rest;
3871
+ for (const chunk of events) for (const line of chunk.split("\n")) {
3872
+ printFollowLine(line);
3873
+ printedAny = true;
3874
+ }
3875
+ }
3876
+ streamReadComplete = true;
3877
+ } catch (streamErr) {
3878
+ if (controller.signal.aborted) return;
3879
+ if (attempt < MAX_STREAM_RETRIES) {
3880
+ await retrySleep(retryDelay(attempt), controller.signal);
3881
+ attempt++;
3882
+ continue;
3883
+ }
3884
+ console.error("[wawesome] Error: Live tail stream interrupted and retries exhausted.");
3885
+ process.exit(1);
3886
+ }
3887
+ }
3888
+ const meta = await fetchInvocationMetadata(gatewayUrl, tenantJwt, invocationId, isVerbose);
3889
+ if (meta) {
3890
+ if (!printedAny && (meta.status === "success" || meta.status === "error" || meta.status === "timeout")) {
3891
+ const logBody = await fetchInvocationLogs(gatewayUrl, tenantJwt, invocationId, isVerbose);
3892
+ if (logBody) for (const line of logBody.split("\n")) printFollowLine(line);
3893
+ }
3894
+ printOutcome(meta);
3895
+ }
3896
+ } finally {
3897
+ process.removeListener("SIGINT", onSigint);
3898
+ }
3899
+ }
3900
+ async function invokeCommand(target, options = {}) {
3901
+ const isVerbose = Boolean(options.verbose);
3902
+ const creds = readCredentials();
3903
+ if (!creds) {
3904
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
3905
+ process.exit(1);
3906
+ }
3907
+ const config = readFunctionConfig();
3908
+ const funcName = target || config?.function;
3909
+ const appSlug = options.app || config?.app;
3910
+ if (!funcName) {
3911
+ console.error("[wawesome] Error: Missing function name.");
3912
+ console.error("[wawesome] Usage: wawesome invoke [function-name] or run inside a function directory with wawesome-function.json.");
3913
+ process.exit(1);
3914
+ }
3915
+ const method = options.method?.trim();
3916
+ const body = options.body ?? options.data;
3917
+ const triggerPayload = {};
3918
+ if (method) triggerPayload.method = method;
3919
+ if (body !== void 0) triggerPayload.body = body;
3920
+ const res = await triggerRun(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, options.app, triggerPayload, isVerbose);
3921
+ if (!res.ok) {
3922
+ const errorBody = await res.text();
3923
+ if (res.status === 401) {
3924
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
3925
+ process.exit(1);
3926
+ }
3927
+ if (res.status === 404) {
3928
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
3929
+ process.exit(1);
3930
+ }
3931
+ const refusal = rejectionOf(errorBody, res.status, `Failed to invoke function (HTTP ${res.status}).`);
3932
+ console.error(`[wawesome] Error: ${refusal.message}`);
3933
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
3934
+ if (advice) console.error(`[wawesome] ${advice}`);
3935
+ if (isVerbose && res.status !== 409 && res.status !== 402) console.error(`[wawesome:verbose] Response: ${errorBody}`);
3936
+ process.exit(1);
3937
+ }
3938
+ const data = await res.json();
3939
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
3940
+ console.log(`[wawesome] ⏳ Run ${data.invocation_id} queued for '${displayTarget}'...`);
3941
+ if (options.follow === false) return;
3942
+ await followRun(creds.gateway_url, creds.tenant_jwt, data.invocation_id, isVerbose);
3943
+ }
3944
+ //#endregion
3609
3945
  //#region src/workspace.ts
3610
- function requireCredentials() {
3946
+ function requireCredentials$1() {
3611
3947
  const creds = readCredentials();
3612
3948
  if (!creds) {
3613
3949
  console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
@@ -3620,7 +3956,7 @@ function requireCredentials() {
3620
3956
  * change — the rename is offered here rather than discovered by attempting it.
3621
3957
  */
3622
3958
  async function showWorkspace(options) {
3623
- const creds = requireCredentials();
3959
+ const creds = requireCredentials$1();
3624
3960
  let tenant;
3625
3961
  try {
3626
3962
  tenant = await fetchTenantDetails(creds);
@@ -3655,7 +3991,7 @@ async function showWorkspace(options) {
3655
3991
  * gateway knows something this command could not.
3656
3992
  */
3657
3993
  async function renameWorkspace(slug, options) {
3658
- const creds = requireCredentials();
3994
+ const creds = requireCredentials$1();
3659
3995
  if (!slug || !slug.trim()) {
3660
3996
  console.error("[wawesome] Error: No name given. Usage: wawesome workspace rename <name>");
3661
3997
  process.exit(1);
@@ -3699,6 +4035,442 @@ async function workspaceCommand(action, target, options) {
3699
4035
  process.exit(1);
3700
4036
  }
3701
4037
  //#endregion
4038
+ //#region src/cron.ts
4039
+ /** `2026-08-23 03:00:00 UTC` with second-precision for run history timestamps. */
4040
+ function asUtcWithSeconds(iso) {
4041
+ const at = new Date(iso);
4042
+ if (Number.isNaN(at.getTime())) return iso;
4043
+ const pad = (n) => String(n).padStart(2, "0");
4044
+ return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())} UTC`;
4045
+ }
4046
+ /** Format execution delay in ms or s. */
4047
+ function formatDelay(delayMs) {
4048
+ if (delayMs === void 0 || delayMs === null || isNaN(delayMs) || delayMs < 0) return "-";
4049
+ if (delayMs < 1e3) return `${delayMs}ms`;
4050
+ return `${(delayMs / 1e3).toFixed(2)}s`;
4051
+ }
4052
+ /** Colorize state names for terminal output. */
4053
+ function colorizeRunState(state, text) {
4054
+ const display = text ?? state;
4055
+ switch (state.toLowerCase()) {
4056
+ case "dispatched":
4057
+ case "success": return `\x1b[32m${display}\x1b[0m`;
4058
+ case "running": return `\x1b[36m${display}\x1b[0m`;
4059
+ case "pending":
4060
+ case "queued": return `\x1b[33m${display}\x1b[0m`;
4061
+ case "missed": return `\x1b[33m${display}\x1b[0m`;
4062
+ case "cancelled":
4063
+ case "skipped": return `\x1b[90m${display}\x1b[0m`;
4064
+ case "failed":
4065
+ case "lost":
4066
+ case "error": return `\x1b[31m${display}\x1b[0m`;
4067
+ default: return display;
4068
+ }
4069
+ }
4070
+ /**
4071
+ * Format a list of schedules for a single function.
4072
+ */
4073
+ function formatFunctionSchedules(funcName, appSlug, schedules) {
4074
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4075
+ if (schedules.length === 0) return [`[wawesome] No schedules found for '${displayTarget}'.`];
4076
+ const nameWidth = Math.max(8, ...schedules.map((s) => s.name.length));
4077
+ const exprWidth = Math.max(10, ...schedules.map((s) => s.expression.length));
4078
+ return [
4079
+ `\n🗓 \x1b[1mSchedules for '${displayTarget}'\x1b[0m\n`,
4080
+ ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(exprWidth)} ${whenItRuns(schedule)}`),
4081
+ ""
4082
+ ];
4083
+ }
4084
+ /**
4085
+ * Format schedules across all functions in an App.
4086
+ */
4087
+ function formatAppSchedules(appSlug, functionSchedules) {
4088
+ if (functionSchedules.reduce((acc, f) => acc + f.schedules.length, 0) === 0) return [`[wawesome] No schedules found for app '${appSlug}'.`];
4089
+ const allSchedules = functionSchedules.flatMap((f) => f.schedules);
4090
+ const nameWidth = Math.max(8, ...allSchedules.map((s) => s.name.length));
4091
+ const exprWidth = Math.max(10, ...allSchedules.map((s) => s.expression.length));
4092
+ const out = [`\n🗓 \x1b[1mSchedules for app '${appSlug}'\x1b[0m\n`];
4093
+ for (const fn of functionSchedules) {
4094
+ out.push(` Function: \x1b[1m${fn.functionName}\x1b[0m`);
4095
+ if (fn.schedules.length === 0) out.push(" (no schedules)");
4096
+ else for (const s of [...fn.schedules].sort((a, b) => a.name.localeCompare(b.name))) out.push(` ${s.name.padEnd(nameWidth)} ${s.expression.padEnd(exprWidth)} ${whenItRuns(s)}`);
4097
+ out.push("");
4098
+ }
4099
+ return out;
4100
+ }
4101
+ /**
4102
+ * Format background run history table.
4103
+ */
4104
+ function formatRunHistoryTable(runs) {
4105
+ if (runs.length === 0) return [];
4106
+ const scheduleColHeader = "SCHEDULE";
4107
+ const stateColHeader = "STATE";
4108
+ const dueColHeader = "DUE AT (UTC)";
4109
+ const startedColHeader = "STARTED AT (UTC)";
4110
+ const delayColHeader = "DELAY";
4111
+ const invColHeader = "INVOCATION ID";
4112
+ const rows = runs.map((run) => {
4113
+ const schedName = run.schedule_name || "manual";
4114
+ const stateDisplay = run.failure_reason ? `${run.state} (${run.failure_reason})` : run.state;
4115
+ const dueAt = asUtcWithSeconds(run.fire_at);
4116
+ const startedAt = run.claimed_at ? asUtcWithSeconds(run.claimed_at) : "-";
4117
+ const delay = formatDelay(run.delay_ms);
4118
+ const invId = run.invocation_id;
4119
+ return {
4120
+ schedName,
4121
+ stateDisplay,
4122
+ rawState: run.state,
4123
+ dueAt,
4124
+ startedAt,
4125
+ delay,
4126
+ invId
4127
+ };
4128
+ });
4129
+ const schedWidth = Math.max(8, ...rows.map((r) => r.schedName.length));
4130
+ const stateWidth = Math.max(5, ...rows.map((r) => r.stateDisplay.length));
4131
+ const dueWidth = Math.max(12, ...rows.map((r) => r.dueAt.length));
4132
+ const startedWidth = Math.max(16, ...rows.map((r) => r.startedAt.length));
4133
+ const delayWidth = Math.max(5, ...rows.map((r) => r.delay.length));
4134
+ const invWidth = Math.max(13, ...rows.map((r) => r.invId.length));
4135
+ return [
4136
+ `${scheduleColHeader.padEnd(schedWidth)} | ${stateColHeader.padEnd(stateWidth)} | ${dueColHeader.padEnd(dueWidth)} | ${startedColHeader.padEnd(startedWidth)} | ${delayColHeader.padEnd(delayWidth)} | ${invColHeader.padEnd(invWidth)}`,
4137
+ `${"-".repeat(schedWidth)}-|-${"-".repeat(stateWidth)}-|-${"-".repeat(dueWidth)}-|-${"-".repeat(startedWidth)}-|-${"-".repeat(delayWidth)}-|-${"-".repeat(invWidth)}`,
4138
+ ...rows.map((r) => {
4139
+ const schedPad = r.schedName.padEnd(schedWidth);
4140
+ const coloredState = colorizeRunState(r.rawState, r.stateDisplay);
4141
+ return `${schedPad} | ${r.stateDisplay.length < stateWidth ? coloredState + " ".repeat(stateWidth - r.stateDisplay.length) : coloredState} | ${r.dueAt.padEnd(dueWidth)} | ${r.startedAt.padEnd(startedWidth)} | ${r.delay.padEnd(delayWidth)} | ${r.invId.padEnd(invWidth)}`;
4142
+ })
4143
+ ];
4144
+ }
4145
+ async function fetchFunctionSchedules(gatewayUrl, tenantJwt, funcName, appSlug, isVerbose) {
4146
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
4147
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
4148
+ let res = await fetch(scopedUrl, {
4149
+ method: "GET",
4150
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4151
+ });
4152
+ if (res.status === 404 && appSlug) {
4153
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules`;
4154
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
4155
+ try {
4156
+ const fallbackRes = await fetch(fallbackUrl, {
4157
+ method: "GET",
4158
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4159
+ });
4160
+ if (fallbackRes.ok) res = fallbackRes;
4161
+ } catch {}
4162
+ }
4163
+ if (!res.ok) return null;
4164
+ const data = await res.json();
4165
+ return Array.isArray(data.schedules) ? data.schedules : [];
4166
+ }
4167
+ async function fetchAppFunctions(gatewayUrl, tenantJwt, appSlug, isVerbose) {
4168
+ const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions`;
4169
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
4170
+ const res = await fetch(url, {
4171
+ method: "GET",
4172
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4173
+ });
4174
+ if (!res.ok) return null;
4175
+ return await res.json();
4176
+ }
4177
+ async function pauseScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, reason, isVerbose) {
4178
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
4179
+ const body = reason?.trim() ? JSON.stringify({ reason: reason.trim() }) : void 0;
4180
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
4181
+ let res = await fetch(scopedUrl, {
4182
+ method: "POST",
4183
+ headers: {
4184
+ Authorization: `Bearer ${tenantJwt}`,
4185
+ ...body ? { "Content-Type": "application/json" } : {}
4186
+ },
4187
+ body
4188
+ });
4189
+ if (res.status === 404 && appSlug) {
4190
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/pause`;
4191
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
4192
+ try {
4193
+ const fallbackRes = await fetch(fallbackUrl, {
4194
+ method: "POST",
4195
+ headers: {
4196
+ Authorization: `Bearer ${tenantJwt}`,
4197
+ ...body ? { "Content-Type": "application/json" } : {}
4198
+ },
4199
+ body
4200
+ });
4201
+ if (fallbackRes) res = fallbackRes;
4202
+ } catch {}
4203
+ }
4204
+ return res;
4205
+ }
4206
+ async function resumeScheduleApi(gatewayUrl, tenantJwt, funcName, scheduleName, appSlug, isVerbose) {
4207
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
4208
+ if (isVerbose) console.log(`[wawesome:verbose] POST ${scopedUrl}`);
4209
+ let res = await fetch(scopedUrl, {
4210
+ method: "POST",
4211
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4212
+ });
4213
+ if (res.status === 404 && appSlug) {
4214
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/schedules/${encodeURIComponent(scheduleName)}/resume`;
4215
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: POST ${fallbackUrl}`);
4216
+ try {
4217
+ const fallbackRes = await fetch(fallbackUrl, {
4218
+ method: "POST",
4219
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4220
+ });
4221
+ if (fallbackRes) res = fallbackRes;
4222
+ } catch {}
4223
+ }
4224
+ return res;
4225
+ }
4226
+ async function fetchBackgroundRunsApi(gatewayUrl, tenantJwt, funcName, appSlug, queryParams, isVerbose) {
4227
+ const params = new URLSearchParams();
4228
+ if (queryParams?.limit) params.set("limit", String(queryParams.limit));
4229
+ if (queryParams?.page) params.set("page", String(queryParams.page));
4230
+ if (queryParams?.state && queryParams.state.toLowerCase() !== "all") params.set("state", queryParams.state.toLowerCase());
4231
+ const qs = params.toString() ? `?${params.toString()}` : "";
4232
+ const scopedUrl = appSlug ? `${gatewayUrl}/v1/apps/${encodeURIComponent(appSlug)}/functions/${encodeURIComponent(funcName)}/runs${qs}` : `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
4233
+ if (isVerbose) console.log(`[wawesome:verbose] GET ${scopedUrl}`);
4234
+ let res = await fetch(scopedUrl, {
4235
+ method: "GET",
4236
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4237
+ });
4238
+ if (res.status === 404 && appSlug) {
4239
+ const fallbackUrl = `${gatewayUrl}/v1/functions/${encodeURIComponent(funcName)}/runs${qs}`;
4240
+ if (isVerbose) console.log(`[wawesome:verbose] 404 on app-scoped route. Retrying unscoped: GET ${fallbackUrl}`);
4241
+ try {
4242
+ const fallbackRes = await fetch(fallbackUrl, {
4243
+ method: "GET",
4244
+ headers: { Authorization: `Bearer ${tenantJwt}` }
4245
+ });
4246
+ if (fallbackRes) res = fallbackRes;
4247
+ } catch {}
4248
+ }
4249
+ return res;
4250
+ }
4251
+ function requireCredentials() {
4252
+ const creds = readCredentials();
4253
+ if (!creds) {
4254
+ console.error("[wawesome] Error: Not logged in. Run 'wawesome login' first.");
4255
+ process.exit(1);
4256
+ }
4257
+ return creds;
4258
+ }
4259
+ async function listSchedulesCommand(target, options = {}) {
4260
+ const isVerbose = Boolean(options.verbose);
4261
+ const creds = requireCredentials();
4262
+ const config = readFunctionConfig();
4263
+ const explicitApp = options.app;
4264
+ const appSlug = explicitApp || config?.app;
4265
+ const funcName = target || options.function || (explicitApp ? void 0 : config?.function);
4266
+ if (funcName) {
4267
+ const schedules = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, isVerbose);
4268
+ if (schedules === null) {
4269
+ if (target && !options.app) {
4270
+ const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, target, isVerbose);
4271
+ if (appFuncs !== null) {
4272
+ const functionSchedules = [];
4273
+ for (const item of appFuncs) {
4274
+ const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, target, isVerbose);
4275
+ functionSchedules.push({
4276
+ functionName: item.function.name,
4277
+ schedules: scheds || []
4278
+ });
4279
+ }
4280
+ const lines = formatAppSchedules(target, functionSchedules);
4281
+ for (const line of lines) console.log(line);
4282
+ return;
4283
+ }
4284
+ }
4285
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
4286
+ process.exit(1);
4287
+ }
4288
+ const lines = formatFunctionSchedules(funcName, appSlug, schedules);
4289
+ for (const line of lines) console.log(line);
4290
+ return;
4291
+ }
4292
+ if (appSlug) {
4293
+ const appFuncs = await fetchAppFunctions(creds.gateway_url, creds.tenant_jwt, appSlug, isVerbose);
4294
+ if (appFuncs === null) {
4295
+ console.error(`[wawesome] Error: App '${appSlug}' not found.`);
4296
+ process.exit(1);
4297
+ }
4298
+ const functionSchedules = [];
4299
+ for (const item of appFuncs) {
4300
+ const scheds = await fetchFunctionSchedules(creds.gateway_url, creds.tenant_jwt, item.function.name, appSlug, isVerbose);
4301
+ functionSchedules.push({
4302
+ functionName: item.function.name,
4303
+ schedules: scheds || []
4304
+ });
4305
+ }
4306
+ const lines = formatAppSchedules(appSlug, functionSchedules);
4307
+ for (const line of lines) console.log(line);
4308
+ return;
4309
+ }
4310
+ console.error("[wawesome] Error: Missing function or app name.");
4311
+ console.error("[wawesome] Usage: wawesome cron list [function-or-app] or run inside a function directory with wawesome-function.json.");
4312
+ process.exit(1);
4313
+ }
4314
+ async function pauseScheduleCommand(target, subtarget, options = {}) {
4315
+ const isVerbose = Boolean(options.verbose);
4316
+ const creds = requireCredentials();
4317
+ const config = readFunctionConfig();
4318
+ let funcName;
4319
+ let scheduleName;
4320
+ if (target && subtarget) {
4321
+ funcName = target;
4322
+ scheduleName = subtarget;
4323
+ } else if (target) {
4324
+ scheduleName = target;
4325
+ funcName = options.function || config?.function;
4326
+ }
4327
+ if (options.function) funcName = options.function;
4328
+ const appSlug = options.app || config?.app;
4329
+ if (!scheduleName) {
4330
+ console.error("[wawesome] Error: Missing schedule name.");
4331
+ console.error("[wawesome] Usage: wawesome cron pause <schedule-name> [--reason <reason>] or wawesome cron pause <function> <schedule-name>");
4332
+ process.exit(1);
4333
+ }
4334
+ if (!funcName) {
4335
+ console.error("[wawesome] Error: Missing function name.");
4336
+ console.error("[wawesome] Usage: wawesome cron pause <function> <schedule-name> or specify -f/--function <name>.");
4337
+ process.exit(1);
4338
+ }
4339
+ const res = await pauseScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, options.reason, isVerbose);
4340
+ if (!res.ok) {
4341
+ const errorBody = await res.text();
4342
+ if (res.status === 401) {
4343
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4344
+ process.exit(1);
4345
+ }
4346
+ if (res.status === 404) {
4347
+ console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
4348
+ process.exit(1);
4349
+ }
4350
+ const refusal = rejectionOf(errorBody, res.status, `Failed to pause schedule (HTTP ${res.status}).`);
4351
+ console.error(`[wawesome] Error: ${refusal.message}`);
4352
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4353
+ if (advice) console.error(`[wawesome] ${advice}`);
4354
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4355
+ process.exit(1);
4356
+ }
4357
+ const data = await res.json();
4358
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4359
+ console.log(`[wawesome] ✔ Paused schedule '${data.schedule.name}' on '${displayTarget}'.`);
4360
+ console.log(` State: ${whenItRuns(data.schedule)} (survives future deploys)`);
4361
+ if (data.schedule.pause_reason) console.log(` Reason: ${data.schedule.pause_reason}`);
4362
+ if (data.cancelled_runs && data.cancelled_runs.length > 0) {
4363
+ const count = data.cancelled_runs.length;
4364
+ console.log(` Cancelled ${count} queued run${count === 1 ? "" : "s"} (never started).`);
4365
+ }
4366
+ if (data.running_runs && data.running_runs.length > 0) {
4367
+ const count = data.running_runs.length;
4368
+ console.log(` Left ${count} in-flight run${count === 1 ? "" : "s"} to complete.`);
4369
+ }
4370
+ }
4371
+ async function resumeScheduleCommand(target, subtarget, options = {}) {
4372
+ const isVerbose = Boolean(options.verbose);
4373
+ const creds = requireCredentials();
4374
+ const config = readFunctionConfig();
4375
+ let funcName;
4376
+ let scheduleName;
4377
+ if (target && subtarget) {
4378
+ funcName = target;
4379
+ scheduleName = subtarget;
4380
+ } else if (target) {
4381
+ scheduleName = target;
4382
+ funcName = options.function || config?.function;
4383
+ }
4384
+ if (options.function) funcName = options.function;
4385
+ const appSlug = options.app || config?.app;
4386
+ if (!scheduleName) {
4387
+ console.error("[wawesome] Error: Missing schedule name.");
4388
+ console.error("[wawesome] Usage: wawesome cron resume <schedule-name> or wawesome cron resume <function> <schedule-name>");
4389
+ process.exit(1);
4390
+ }
4391
+ if (!funcName) {
4392
+ console.error("[wawesome] Error: Missing function name.");
4393
+ console.error("[wawesome] Usage: wawesome cron resume <function> <schedule-name> or specify -f/--function <name>.");
4394
+ process.exit(1);
4395
+ }
4396
+ const res = await resumeScheduleApi(creds.gateway_url, creds.tenant_jwt, funcName, scheduleName, appSlug, isVerbose);
4397
+ if (!res.ok) {
4398
+ const errorBody = await res.text();
4399
+ if (res.status === 401) {
4400
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4401
+ process.exit(1);
4402
+ }
4403
+ if (res.status === 404) {
4404
+ console.error(`[wawesome] Error: Schedule '${scheduleName}' or function '${funcName}' not found.`);
4405
+ process.exit(1);
4406
+ }
4407
+ const refusal = rejectionOf(errorBody, res.status, `Failed to resume schedule (HTTP ${res.status}).`);
4408
+ console.error(`[wawesome] Error: ${refusal.message}`);
4409
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4410
+ if (advice) console.error(`[wawesome] ${advice}`);
4411
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4412
+ process.exit(1);
4413
+ }
4414
+ const data = await res.json();
4415
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4416
+ console.log(`[wawesome] ✔ Resumed schedule '${data.schedule.name}' on '${displayTarget}'.`);
4417
+ console.log(` ${whenItRuns(data.schedule)} (no backfill)`);
4418
+ }
4419
+ async function runHistoryCommand(target, options = {}) {
4420
+ const isVerbose = Boolean(options.verbose);
4421
+ const creds = requireCredentials();
4422
+ const config = readFunctionConfig();
4423
+ const funcName = target || options.function || config?.function;
4424
+ const appSlug = options.app || config?.app;
4425
+ if (!funcName) {
4426
+ console.error("[wawesome] Error: Missing function name.");
4427
+ console.error("[wawesome] Usage: wawesome cron history [function-name] or run inside a function directory with wawesome-function.json.");
4428
+ process.exit(1);
4429
+ }
4430
+ const limit = options.limit ? Number(options.limit) : 50;
4431
+ const stateFilter = options.state || options.status;
4432
+ const res = await fetchBackgroundRunsApi(creds.gateway_url, creds.tenant_jwt, funcName, appSlug, {
4433
+ state: stateFilter,
4434
+ limit
4435
+ }, isVerbose);
4436
+ if (!res.ok) {
4437
+ const errorBody = await res.text();
4438
+ if (res.status === 401) {
4439
+ console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
4440
+ process.exit(1);
4441
+ }
4442
+ if (res.status === 404) {
4443
+ console.error(`[wawesome] Error: Function '${funcName}' not found.`);
4444
+ process.exit(1);
4445
+ }
4446
+ const refusal = rejectionOf(errorBody, res.status, `Failed to fetch run history (HTTP ${res.status}).`);
4447
+ console.error(`[wawesome] Error: ${refusal.message}`);
4448
+ const advice = planLimitAdvice(refusal.reason) || (res.status === 402 ? `Where to resolve it: ${billingPageUrl()}` : "");
4449
+ if (advice) console.error(`[wawesome] ${advice}`);
4450
+ if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
4451
+ process.exit(1);
4452
+ }
4453
+ const data = await res.json();
4454
+ const displayTarget = appSlug ? `${appSlug}/${funcName}` : funcName;
4455
+ if (!data.runs || data.runs.length === 0) {
4456
+ console.log(`[wawesome] No run history found for '${displayTarget}'.`);
4457
+ return;
4458
+ }
4459
+ const total = data.total ?? data.runs.length;
4460
+ console.log(`\n📜 \x1b[1mRun history for '${displayTarget}' (Showing ${data.runs.length} of ${total} records)\x1b[0m\n`);
4461
+ const tableLines = formatRunHistoryTable(data.runs);
4462
+ for (const line of tableLines) console.log(line);
4463
+ console.log("");
4464
+ }
4465
+ async function cronCommand(action, target, subtarget, options = {}) {
4466
+ const normAction = (action || "").toLowerCase().trim();
4467
+ if (!normAction || normAction === "list" || normAction === "ls") return listSchedulesCommand(target, options);
4468
+ if (normAction === "pause") return pauseScheduleCommand(target, subtarget, options);
4469
+ if (normAction === "resume") return resumeScheduleCommand(target, subtarget, options);
4470
+ if (normAction === "history" || normAction === "runs" || normAction === "log" || normAction === "logs") return runHistoryCommand(target, options);
4471
+ return listSchedulesCommand(action, options);
4472
+ }
4473
+ //#endregion
3702
4474
  //#region src/index.ts
3703
4475
  const cli = cac("wawesome");
3704
4476
  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));
@@ -3741,6 +4513,40 @@ cli.command("logs [function-name-or-invocation-id]", "View invocation history, f
3741
4513
  With a function name, --follow streams the function's output continuously across
3742
4514
  invocations — new output appears each time the function runs, no need to catch a
3743
4515
  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));
4516
+ cli.command("invoke [function]", "Fire a background run of a serverless function").usage(`invoke [function] [options]
4517
+
4518
+ Fire a Function run now through the authenticated API, without deploying anything
4519
+ or waiting for a scheduled tick.
4520
+
4521
+ By default, follows the run's output live and prints the outcome with duration.
4522
+
4523
+ Examples:
4524
+ wawesome invoke # invoke function in current directory
4525
+ wawesome invoke my-function # invoke 'my-function'
4526
+ wawesome invoke --no-follow # fire and exit without following output
4527
+ wawesome invoke -m POST -d '{"k":"v"}' # send custom HTTP method and body`).option("-m, --method <method>", "HTTP method to send to the function (default: POST)").option("-b, --body <body>", "Request body to send to the function").option("-d, --data <data>", "Alias for --body").option("--no-follow", "Do not follow the run's output live").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-v, --verbose", "Enable verbose debug output").action((target, options) => invokeCommand(target, options));
4528
+ cli.command("cron [action] [target] [subtarget]", "Manage schedules and read run history").usage(`cron <action> [options]
4529
+
4530
+ Actions:
4531
+ list [function] List schedules for a function or app (default)
4532
+ pause <schedule> Pause a schedule by name
4533
+ resume <schedule> Resume a paused schedule
4534
+ history [function] Read run history for scheduled and manual runs
4535
+
4536
+ Examples:
4537
+ wawesome cron # list schedules for current function
4538
+ wawesome cron list my-function # list schedules for 'my-function'
4539
+ wawesome cron list --app my-app # list schedules across all functions in 'my-app'
4540
+ wawesome cron pause nightly-reconcile # pause a schedule
4541
+ wawesome cron pause nightly-reconcile -r "maint" # pause with reason
4542
+ wawesome cron resume nightly-reconcile # resume a schedule
4543
+ wawesome cron history # view run history
4544
+ wawesome cron history my-function --state failed # filter run history by state`).option("-r, --reason <reason>", "Reason for pausing a schedule").option("-f, --function <function>", "Function name override").option("-a, --app <app>", "App slug override (defaults to wawesome-function.json)").option("-s, --state <state>", "Filter run history by state (pending, running, dispatched, skipped, missed, cancelled, lost, failed)").option("--status <status>", "Alias for --state").option("-l, --limit <limit>", "Limit number of history rows (default: 50)").option("-v, --verbose", "Enable verbose debug output").action((action, target, subtarget, options) => cronCommand(action, target, subtarget, options));
4545
+ cli.command("cron list [target]", "List schedules for a function or app").alias("cron ls").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((target, options) => listSchedulesCommand(target, options));
4546
+ cli.command("cron pause <schedule>", "Pause a schedule by name").option("-r, --reason <reason>", "Reason for pausing a schedule").option("-f, --function <function>", "Function name override").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((schedule, options) => pauseScheduleCommand(schedule, void 0, options));
4547
+ cli.command("cron resume <schedule>", "Resume a paused schedule").option("-f, --function <function>", "Function name override").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((schedule, options) => resumeScheduleCommand(schedule, void 0, options));
4548
+ cli.command("cron history [target]", "Read background run history").alias("cron runs").option("-s, --state <state>", "Filter run history by state (pending, running, dispatched, skipped, missed, cancelled, lost, failed)").option("--status <status>", "Alias for --state").option("-l, --limit <limit>", "Limit number of history rows (default: 50)").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((target, options) => runHistoryCommand(target, options));
4549
+ cli.command("schedules [action] [target]", "Alias for 'cron'").option("-a, --app <app>", "App slug override").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => cronCommand(action, target, void 0, options));
3744
4550
  cli.help();
3745
4551
  cli.version(CLI_VERSION);
3746
4552
  cli.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wawesome",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI tool for building and deploying serverless functions on wawesome.io platform",
5
5
  "type": "module",
6
6
  "bin": {