@wervt/cli 0.2.14 → 0.2.15

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 CHANGED
@@ -16,5 +16,5 @@ vp exec wervt logs my-app
16
16
  ```
17
17
 
18
18
  Without [Vite+](https://viteplus.dev), use `npx @wervt/cli` and `pnpm exec wervt`. `wervt --help`
19
- lists every command: `apps`, `releases`, `rollback`, `activate`, `env`, `tokens`, `members`,
19
+ lists every command: `apps`, `releases`, `audit`, `rollback`, `activate`, `env`, `tokens`, `token`, `members`,
20
20
  `tasks`, `stats`, `delete`, `whoami`, … Docs: [wervt.app](https://wervt.app/getting-started/quickstart).
package/dist/index.mjs CHANGED
@@ -75,6 +75,59 @@ async function saveConfig(config) {
75
75
  return file;
76
76
  }
77
77
  //#endregion
78
+ //#region src/format.ts
79
+ function formatBytes(bytes) {
80
+ if (bytes < 1024) return `${bytes} B`;
81
+ const units = [
82
+ "KB",
83
+ "MB",
84
+ "GB"
85
+ ];
86
+ let value = bytes / 1024;
87
+ let unit = 0;
88
+ while (value >= 1024 && unit < units.length - 1) {
89
+ value /= 1024;
90
+ unit++;
91
+ }
92
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
93
+ }
94
+ const list = (value) => Array.isArray(value) ? value.map(String).join(", ") : "";
95
+ const text = (value) => typeof value === "string" ? value : "";
96
+ /** What an audit entry changed, in a few words: `env.update` → `set API_URL, TOKEN`. */
97
+ function auditSummary(entry) {
98
+ const d = entry.details ?? {};
99
+ switch (entry.action) {
100
+ case "deploy":
101
+ case "deploy.failed": {
102
+ const migrations = list(d.migrations);
103
+ return [
104
+ text(d.release),
105
+ migrations && `migrated ${migrations}`,
106
+ text(d.checkError) && `checks failed: ${text(d.checkError)}`,
107
+ entry.action === "deploy.failed" && text(d.error).split("\n")[0]
108
+ ].filter(Boolean).join(" ");
109
+ }
110
+ case "rollback":
111
+ case "activate": return `${text(d.previous) || "none"} → ${text(d.release)}`;
112
+ case "env.update": return [list(d.set) && `set ${list(d.set)}`, list(d.unset) && `unset ${list(d.unset)}`].filter(Boolean).join("; ");
113
+ case "access.update": return [
114
+ text(d.visibility),
115
+ list(d.allow) && `allow ${list(d.allow)}`,
116
+ list(d.add) && `add ${list(d.add)}`,
117
+ list(d.remove) && `remove ${list(d.remove)}`
118
+ ].filter(Boolean).join("; ");
119
+ case "owners.update": return [
120
+ text(d.owner) && `owner ${text(d.owner)}`,
121
+ list(d.added) && `added ${list(d.added)}`,
122
+ list(d.removed) && `removed ${list(d.removed)}`
123
+ ].filter(Boolean).join("; ");
124
+ case "token.create":
125
+ case "token.revoke": return text(d.name) || text(d.id);
126
+ case "app.delete": return `database ${text(d.database)}`;
127
+ default: return entry.account ?? "";
128
+ }
129
+ }
130
+ //#endregion
78
131
  //#region src/source.ts
79
132
  async function git(dir, ...args) {
80
133
  try {
@@ -294,6 +347,10 @@ const deploy = defineCommand({
294
347
  process.exitCode = 1;
295
348
  continue;
296
349
  }
350
+ if (event.step === "quota-warning") {
351
+ p.log.warn(event.message);
352
+ continue;
353
+ }
297
354
  if (event.step === "storage-failed") {
298
355
  p.log.warn(`Storage isn't set up: ${event.message}. The app runs; files won't work yet.`);
299
356
  continue;
@@ -333,6 +390,7 @@ const rollback = defineCommand({
333
390
  console.log(`${args.app} is now on release ${result.release}`);
334
391
  }
335
392
  });
393
+ const firstLine = (text) => text.split("\n")[0];
336
394
  const releases = defineCommand({
337
395
  meta: {
338
396
  name: "releases",
@@ -349,16 +407,75 @@ const releases = defineCommand({
349
407
  async run({ args }) {
350
408
  const info = await (await api(`/v1/apps/${args.app}`, args)).json();
351
409
  const history = new Map(info.history?.map((entry) => [entry.release, entry]));
352
- for (const release of [...info.releases].reverse()) {
353
- const { source, deployedBy } = history.get(release) ?? {};
410
+ const failed = new Map(info.failedDeploys?.map((entry) => [entry.release, entry]));
411
+ const ids = [...info.releases, ...failed.keys()].sort((a, b) => b.localeCompare(a));
412
+ for (const release of ids) {
413
+ const failure = failed.get(release);
414
+ const kept = history.get(release);
415
+ const source = failure?.source ?? kept?.source;
416
+ const commit = source?.commit ? `${source.commit.slice(0, 7)}${source.dirty ? "+" : ""}` : void 0;
417
+ const applied = kept?.migrations?.applied.length;
354
418
  const details = [
355
- source?.commit ? `${source.commit.slice(0, 7)}${source.dirty ? "+" : ""}` : void 0,
419
+ commit,
356
420
  source?.branch,
357
- source?.actor ?? deployedBy,
421
+ source?.actor ?? failure?.actor ?? kept?.deployedBy,
422
+ kept?.size && formatBytes(kept.size.bundle),
423
+ applied && `${applied} migration${applied === 1 ? "" : "s"}`,
358
424
  source?.message && styleText("dim", source.message)
359
425
  ].filter(Boolean);
360
- const active = release === info.release ? styleText("green", " (active)") : "";
361
- console.log(`${release}${active}${details.length ? ` ${details.join(" ")}` : ""}`);
426
+ const status = release === info.release ? styleText("green", " (active)") : failure ? styleText("red", " (failed)") : kept?.bootError ? styleText("red", " (didn't start)") : "";
427
+ const checks = kept?.checkError ? styleText("yellow", " (checks failed)") : "";
428
+ console.log(`${release}${status}${checks}${details.length ? ` ${details.join(" ")}` : ""}`);
429
+ const error = failure?.error ?? kept?.bootError ?? kept?.checkError;
430
+ if (error) console.log(styleText("dim", ` ${firstLine(error)}`));
431
+ }
432
+ }
433
+ });
434
+ const audit = defineCommand({
435
+ meta: {
436
+ name: "audit",
437
+ description: "Show who deployed, rolled back, deleted or changed access, env and tokens"
438
+ },
439
+ args: {
440
+ app: {
441
+ type: "positional",
442
+ required: false,
443
+ description: "App name (default: all you see)"
444
+ },
445
+ action: {
446
+ type: "string",
447
+ description: "Only these actions, comma-separated; deploy* for all deploys"
448
+ },
449
+ limit: {
450
+ type: "string",
451
+ default: "50",
452
+ description: "Newest entries to show (max 1000)"
453
+ },
454
+ json: {
455
+ type: "boolean",
456
+ description: "One JSON object per line"
457
+ },
458
+ ...connection
459
+ },
460
+ async run({ args }) {
461
+ const params = new URLSearchParams({ limit: args.limit });
462
+ if (args.action) params.set("action", args.action);
463
+ const entries = await (await api(`${args.app ? `/v1/apps/${args.app}/audit` : "/v1/audit"}?${params.toString()}`, args)).json();
464
+ if (!entries.length && !args.json) return console.error("No audit entries.");
465
+ for (const entry of entries.reverse()) {
466
+ if (args.json) {
467
+ console.log(JSON.stringify(entry));
468
+ continue;
469
+ }
470
+ const time = new Date(entry.at).toISOString().slice(0, 16).replace("T", " ");
471
+ const color = entry.action === "deploy.failed" ? "red" : "cyan";
472
+ console.log([
473
+ styleText("dim", time),
474
+ styleText(color, entry.action.padEnd(15)),
475
+ (entry.app ?? "-").padEnd(16),
476
+ entry.actor,
477
+ styleText("dim", auditSummary(entry))
478
+ ].join(" "));
362
479
  }
363
480
  }
364
481
  });
@@ -441,6 +558,10 @@ const access = defineCommand({
441
558
  type: "boolean",
442
559
  description: "Make the app private, and let people with access invite others in the app"
443
560
  },
561
+ "invite-by": {
562
+ type: "string",
563
+ description: "Who may invite on an invite-only app: anyone (with access, the default) or owners (owner, members and admins)"
564
+ },
444
565
  add: {
445
566
  type: "string",
446
567
  description: "Allow emails or *@domain (comma separated)"
@@ -456,6 +577,7 @@ const access = defineCommand({
456
577
  if (args.public) change.visibility = "public";
457
578
  if (args.private) change.visibility = "private";
458
579
  if (args.invite) change.visibility = "invite";
580
+ if (args["invite-by"]) change.inviteBy = args["invite-by"];
459
581
  if (args.add) change.add = toList(args.add);
460
582
  if (args.remove) change.remove = toList(args.remove);
461
583
  const result = await (Object.keys(change).length ? await api(`/v1/apps/${args.app}/access`, args, {
@@ -468,6 +590,7 @@ const access = defineCommand({
468
590
  if (result.visibility === "public") return;
469
591
  console.log(result.allow.length ? `allowed: ${result.allow.join(", ")}` : "allowed: admins only");
470
592
  if (result.visibility === "invite") {
593
+ console.log(result.inviteBy === "owners" ? "who invites: the owner, members and admins" : "who invites: everyone with access");
471
594
  const invited = result.invited ?? [];
472
595
  console.log(invited.length ? "invited:" : "invited: nobody yet");
473
596
  for (const invite of invited) console.log(` ${invite.email} (by ${invite.by}, ${invite.at.slice(0, 10)})`);
@@ -542,7 +665,7 @@ const env = defineCommand({
542
665
  return [pair.slice(0, i), pair.slice(i + 1)];
543
666
  }));
544
667
  const unset = toList(args.unset);
545
- const { keys } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
668
+ const { keys, warnings = [] } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
546
669
  method: "PATCH",
547
670
  headers: { "content-type": "application/json" },
548
671
  body: JSON.stringify({
@@ -551,13 +674,38 @@ const env = defineCommand({
551
674
  })
552
675
  }) : await api(`/v1/apps/${args.app}/env`, args)).json();
553
676
  console.log(keys.length ? keys.join("\n") : "No variables set.");
677
+ for (const warning of warnings) console.error(styleText("yellow", `Warning: ${warning}`));
554
678
  if (Object.keys(set).length || unset.length) console.log("Applies from the next request.");
555
679
  }
556
680
  });
681
+ const SIZE_FLAGS = [
682
+ "database",
683
+ "storage",
684
+ "releases"
685
+ ];
686
+ /** Bytes as people read them: `1.5 GB`, `200 MB`; `no limit` for null. */
687
+ function size(bytes) {
688
+ if (bytes === null || bytes === void 0) return "no limit";
689
+ const units = [
690
+ "B",
691
+ "KB",
692
+ "MB",
693
+ "GB",
694
+ "TB"
695
+ ];
696
+ let value = bytes;
697
+ let unit = 0;
698
+ while (value >= 1024 && unit < units.length - 1) {
699
+ value /= 1024;
700
+ unit++;
701
+ }
702
+ return `${Number(value.toFixed(value < 10 && unit ? 1 : 0))} ${units[unit]}`;
703
+ }
704
+ const describeLimits = (limits) => `${limits.apps ?? "no limit"} apps, database ${size(limits.database)} and files ${size(limits.storage)} per app, releases ${size(limits.releases)}`;
557
705
  const accounts = defineCommand({
558
706
  meta: {
559
707
  name: "accounts",
560
- description: "Admins: list accounts and their app limits, or set one"
708
+ description: "Admins: list accounts and their limits, or change them"
561
709
  },
562
710
  args: {
563
711
  email: {
@@ -569,21 +717,35 @@ const accounts = defineCommand({
569
717
  type: "string",
570
718
  description: "Apps the account may own: a number, \"none\" for no limit, \"default\""
571
719
  },
720
+ database: {
721
+ type: "string",
722
+ description: "Database size per app, e.g. 2GB; past it deploys are refused. \"none\", \"default\""
723
+ },
724
+ storage: {
725
+ type: "string",
726
+ description: "Files per app (bucket quota), e.g. 10GB. \"none\", \"default\""
727
+ },
728
+ releases: {
729
+ type: "string",
730
+ description: "Space all releases of their apps may take, e.g. 2GB. \"none\", \"default\""
731
+ },
572
732
  ...connection
573
733
  },
574
734
  async run({ args }) {
575
735
  if (args.email) {
576
- if (!args.apps) throw new Error("Pass --apps <n|none|default> to change the limit");
577
- const apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
736
+ const change = {};
737
+ if (args.apps) change.apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
738
+ for (const flag of SIZE_FLAGS) if (args[flag]) change[flag] = args[flag] === "none" ? null : args[flag];
739
+ if (!Object.keys(change).length) throw new Error("Pass --apps, --database, --storage or --releases (a value, none or default)");
578
740
  const result = await (await api(`/v1/accounts/${encodeURIComponent(args.email)}`, args, {
579
741
  method: "PATCH",
580
742
  headers: { "content-type": "application/json" },
581
- body: JSON.stringify({ apps })
743
+ body: JSON.stringify(change)
582
744
  })).json();
583
- return console.log(`${result.email}: ${result.limits.apps ?? "no limit"} apps`);
745
+ return console.log(`${result.email}: ${describeLimits(result.limits)}`);
584
746
  }
585
747
  const result = await (await api("/v1/accounts", args)).json();
586
- console.log(`default: ${result.defaults.apps ?? "no limit"} apps per account`);
748
+ console.log(`default: ${describeLimits(result.defaults)}`);
587
749
  for (const account of result.accounts) {
588
750
  const list = account.apps.length ? ` ${account.apps.join(", ")}` : "";
589
751
  if (account.admin) {
@@ -593,6 +755,7 @@ const accounts = defineCommand({
593
755
  const limit = account.limits.apps ?? "no limit";
594
756
  const own = "apps" in account.custom ? "" : " (default)";
595
757
  console.log(`${account.email} ${account.apps.length}/${limit}${own}${list}`);
758
+ if (SIZE_FLAGS.some((flag) => flag in account.custom)) console.log(styleText("dim", ` database ${size(account.limits.database)}, files ${size(account.limits.storage)} per app, releases ${size(account.limits.releases)}`));
596
759
  }
597
760
  }
598
761
  });
@@ -642,6 +805,122 @@ const tokens = defineCommand({
642
805
  }
643
806
  }
644
807
  });
808
+ const token = defineCommand({
809
+ meta: {
810
+ name: "token",
811
+ description: "Mint a short-lived access token for your account on an app, to test a private app (curl, Playwright)"
812
+ },
813
+ args: {
814
+ app: {
815
+ type: "positional",
816
+ required: true,
817
+ description: "App name"
818
+ },
819
+ ttl: {
820
+ type: "string",
821
+ default: "15m",
822
+ description: "Lifetime, 1m to 1h (default: 15m)"
823
+ },
824
+ cookie: {
825
+ type: "boolean",
826
+ default: false,
827
+ description: "Print a cookie for Playwright's context.addCookies() instead of the token"
828
+ },
829
+ json: {
830
+ type: "boolean",
831
+ default: false,
832
+ description: "Print token, cookie and expiry as JSON"
833
+ },
834
+ ...connection
835
+ },
836
+ async run({ args }) {
837
+ const minted = await (await api(`/v1/apps/${args.app}/agent-tokens`, args, {
838
+ method: "POST",
839
+ headers: { "content-type": "application/json" },
840
+ body: JSON.stringify({ ttl: args.ttl })
841
+ })).json();
842
+ if (args.json) return console.log(JSON.stringify(minted, null, 2));
843
+ console.log(args.cookie ? JSON.stringify(minted.cookie) : minted.token);
844
+ console.error(styleText("dim", `${minted.app} as ${minted.user} until ${minted.expiresAt} (token ${minted.id}). ` + (args.cookie ? `Playwright: await context.addCookies([cookie]), then open ${minted.url}` : `curl -H "Authorization: Bearer $TOKEN" ${minted.url}/api/…`)));
845
+ }
846
+ });
847
+ function printIdentity(status) {
848
+ if (!status.configured) console.log("Workload identity is not configured on this server.");
849
+ console.log(status.enabled ? `${status.app} has a workload identity (since ${status.enabledAt?.slice(0, 10)}, by ${status.enabledBy})` : `${status.app} has no workload identity`);
850
+ console.log(`issuer: ${status.issuer}`);
851
+ console.log(`subject: ${status.subject ?? "(the app has no owner yet)"}`);
852
+ console.log(`audiences: ${status.audiences.join(", ")}`);
853
+ }
854
+ const identityApp = (enabled) => defineCommand({
855
+ meta: {
856
+ name: enabled === void 0 ? "status" : enabled ? "enable" : "disable",
857
+ description: enabled === void 0 ? "Show an app's workload identity and the values a provider rule needs" : enabled ? "Owners: let the app get identity tokens, for provider rules that trust it" : "Owners: stop signing identity tokens for the app"
858
+ },
859
+ args: {
860
+ app: {
861
+ type: "positional",
862
+ required: true,
863
+ description: "App name"
864
+ },
865
+ ...connection
866
+ },
867
+ async run({ args }) {
868
+ printIdentity(await (enabled === void 0 ? await api(`/v1/apps/${args.app}/identity`, args) : await api(`/v1/apps/${args.app}/identity`, args, {
869
+ method: "PATCH",
870
+ headers: { "content-type": "application/json" },
871
+ body: JSON.stringify({ enabled })
872
+ })).json());
873
+ }
874
+ });
875
+ const identity = defineCommand({
876
+ meta: {
877
+ name: "identity",
878
+ description: "Workload identity: apps authenticate to AI providers without API keys"
879
+ },
880
+ subCommands: {
881
+ status: identityApp(),
882
+ enable: identityApp(true),
883
+ disable: identityApp(false),
884
+ jwks: defineCommand({
885
+ meta: {
886
+ name: "jwks",
887
+ description: "Print the issuer's public keys, for providers that can't fetch them"
888
+ },
889
+ args: connection,
890
+ async run({ args }) {
891
+ const res = await api("/.well-known/jwks.json", args);
892
+ console.log(JSON.stringify(await res.json(), null, 2));
893
+ }
894
+ }),
895
+ keygen: defineCommand({
896
+ meta: {
897
+ name: "keygen",
898
+ description: "Print a new private signing key for WERVT_IDENTITY_KEY (runs locally)"
899
+ },
900
+ args: { kid: {
901
+ type: "string",
902
+ description: "Key id (default: wervt-<date>)"
903
+ } },
904
+ async run({ args }) {
905
+ const pair = await crypto.subtle.generateKey({
906
+ name: "ECDSA",
907
+ namedCurve: "P-256"
908
+ }, true, ["sign", "verify"]);
909
+ const { kty, crv, x, y, d } = await crypto.subtle.exportKey("jwk", pair.privateKey);
910
+ const kid = args.kid ?? `wervt-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
911
+ console.log(JSON.stringify({
912
+ kty,
913
+ crv,
914
+ x,
915
+ y,
916
+ d,
917
+ kid
918
+ }));
919
+ console.error(styleText("dim", "Private: set it as WERVT_IDENTITY_KEY on control only, never in an app or a repo."));
920
+ }
921
+ })
922
+ }
923
+ });
645
924
  const tasks = defineCommand({
646
925
  meta: {
647
926
  name: "tasks",
@@ -1030,6 +1309,7 @@ runMain(defineCommand({
1030
1309
  deploy,
1031
1310
  apps,
1032
1311
  releases,
1312
+ audit,
1033
1313
  logs,
1034
1314
  activate,
1035
1315
  rollback,
@@ -1039,6 +1319,8 @@ runMain(defineCommand({
1039
1319
  accounts,
1040
1320
  env,
1041
1321
  tokens,
1322
+ token,
1323
+ identity,
1042
1324
  tasks,
1043
1325
  stats,
1044
1326
  delete: remove,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Deploy and manage apps on wervt.",
5
5
  "keywords": [
6
6
  "agents",
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.8.1",
32
32
  "citty": "^0.2.2",
33
- "modern-tar": "^0.8.5",
33
+ "modern-tar": "^0.8.6",
34
34
  "nypm": "^0.6.10",
35
35
  "tinyexec": "^1.3.1"
36
36
  },
package/skill/SKILL.md CHANGED
@@ -66,19 +66,37 @@ pnpm exec wervt deploy # build, upload, migrate, acti
66
66
  pnpm exec wervt access <name> --public # new apps are private (owner, members, admins)
67
67
  pnpm exec wervt access <name> --add a@x.com,*@company.com # or let specific people in
68
68
  pnpm exec wervt access <name> --invite # private, and people with access invite others (useWervtAccess)
69
+ pnpm exec wervt access <name> --invite-by owners # only owner, members and admins may invite
69
70
  pnpm exec wervt members <name> --add a@x.com # who may deploy and manage it (the first deploy owns it)
70
- pnpm exec wervt env <name> --set KEY=value # server env vars, applied from the next request
71
+ pnpm exec wervt env <name> --set NUXT_API_KEY=… # runtimeConfig.apiKey, applied from the next request
71
72
  ```
72
73
 
73
74
  - Ask before making an app public if it shows personal or private data. "Ship it" alone doesn't
74
75
  decide that. Private, with the user's email allowed, is the safe default.
76
+ - App configuration and secrets go through Nuxt's `runtimeConfig`, never `process.env` in app
77
+ code: declare the key with a default in `nuxt.config.ts` (`runtimeConfig: { apiKey: '' }`), read
78
+ it with `useRuntimeConfig()` and set it as `NUXT_` + the key in SCREAMING_SNAKE_CASE
79
+ (`apiKey` → `NUXT_API_KEY`, `public.mapStyle` → `NUXT_PUBLIC_MAP_STYLE`). Keys missing from
80
+ `runtimeConfig` are ignored.
75
81
  - Secrets for `wervt env` (API keys) come from the user. Ask them to run the command themselves
76
- (`! pnpm exec wervt env <name> --set API_KEY=…`) instead of pasting the value to you.
82
+ (`! pnpm exec wervt env <name> --set NUXT_API_KEY=…`) instead of pasting the value to you.
77
83
  - `wervt deploy` requests `/` once the release started and fails (exit 1, with the first logged
78
84
  error) on a 5xx. Add the pages you changed: `--check /,/todos,/api/todos`. The checks are
79
85
  anonymous, so pages behind a sign-in only show they don't crash without a user.
80
86
  - Then open the URL `wervt deploy` printed and check
81
87
  `pnpm exec wervt logs <name> 'level:error' --since 15m`. Fix and redeploy until both are clean.
88
+ - Check what's behind the login yourself, don't ask the user to. `wervt token` mints a token for
89
+ your CLI account on this app (15 minutes, `--ttl 1h` at most), and the app sees that account:
90
+
91
+ ```bash
92
+ TOKEN=$(pnpm exec wervt token <name>)
93
+ curl -sH "Authorization: Bearer $TOKEN" <url>/api/… # protected API routes
94
+ pnpm exec wervt token <name> --cookie > /tmp/wervt-cookie.json # for a headless browser
95
+ ```
96
+
97
+ In Playwright, `await context.addCookies([JSON.parse(cookieJson)])`, then open the URL to check
98
+ pages, icons and uploads. Works for owners, members and admins signed in with `wervt login`
99
+ (not with a deploy or read token). Never paste the token into files or commits.
82
100
 
83
101
  ## 5. Deploy on push (when the user wants a repository)
84
102
 
@@ -101,7 +119,8 @@ release lists the commit.
101
119
 
102
120
  ```bash
103
121
  pnpm exec wervt apps # every app, active release, URL
104
- pnpm exec wervt releases <name> # newest first, with commit and author
122
+ pnpm exec wervt releases <name> # newest first, with commit, author, failed deploys
123
+ pnpm exec wervt audit <name> # who deployed, rolled back, changed env or access
105
124
  pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
106
125
  pnpm exec wervt tasks <name> # tasks, schedules, next runs
107
126
  pnpm exec wervt tasks <name> --run <task> # run one now