@wervt/cli 0.2.13 → 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
  });
@@ -421,7 +538,7 @@ const toList = (value) => (Array.isArray(value) ? value : value === void 0 ? []
421
538
  const access = defineCommand({
422
539
  meta: {
423
540
  name: "access",
424
- description: "Show or change who can open an app (private apps need a login)"
541
+ description: "Show or change who can open an app (private and invite-only apps need a login); --remove takes back invites too"
425
542
  },
426
543
  args: {
427
544
  app: {
@@ -437,6 +554,14 @@ const access = defineCommand({
437
554
  type: "boolean",
438
555
  description: "Make the app private"
439
556
  },
557
+ invite: {
558
+ type: "boolean",
559
+ description: "Make the app private, and let people with access invite others in the app"
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
+ },
440
565
  add: {
441
566
  type: "string",
442
567
  description: "Allow emails or *@domain (comma separated)"
@@ -451,6 +576,8 @@ const access = defineCommand({
451
576
  const change = {};
452
577
  if (args.public) change.visibility = "public";
453
578
  if (args.private) change.visibility = "private";
579
+ if (args.invite) change.visibility = "invite";
580
+ if (args["invite-by"]) change.inviteBy = args["invite-by"];
454
581
  if (args.add) change.add = toList(args.add);
455
582
  if (args.remove) change.remove = toList(args.remove);
456
583
  const result = await (Object.keys(change).length ? await api(`/v1/apps/${args.app}/access`, args, {
@@ -459,8 +586,15 @@ const access = defineCommand({
459
586
  body: JSON.stringify(change)
460
587
  }) : await api(`/v1/apps/${args.app}/access`, args)).json();
461
588
  if (args.app === "auth") return console.log("auth is the login app and always public");
462
- console.log(`${args.app} is ${result.visibility}`);
463
- if (result.visibility === "private") console.log(result.allow.length ? `allowed: ${result.allow.join(", ")}` : "allowed: admins only");
589
+ console.log(`${args.app} is ${result.visibility === "invite" ? "invite-only" : result.visibility}`);
590
+ if (result.visibility === "public") return;
591
+ console.log(result.allow.length ? `allowed: ${result.allow.join(", ")}` : "allowed: admins only");
592
+ if (result.visibility === "invite") {
593
+ console.log(result.inviteBy === "owners" ? "who invites: the owner, members and admins" : "who invites: everyone with access");
594
+ const invited = result.invited ?? [];
595
+ console.log(invited.length ? "invited:" : "invited: nobody yet");
596
+ for (const invite of invited) console.log(` ${invite.email} (by ${invite.by}, ${invite.at.slice(0, 10)})`);
597
+ }
464
598
  }
465
599
  });
466
600
  const members = defineCommand({
@@ -531,7 +665,7 @@ const env = defineCommand({
531
665
  return [pair.slice(0, i), pair.slice(i + 1)];
532
666
  }));
533
667
  const unset = toList(args.unset);
534
- 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, {
535
669
  method: "PATCH",
536
670
  headers: { "content-type": "application/json" },
537
671
  body: JSON.stringify({
@@ -540,13 +674,38 @@ const env = defineCommand({
540
674
  })
541
675
  }) : await api(`/v1/apps/${args.app}/env`, args)).json();
542
676
  console.log(keys.length ? keys.join("\n") : "No variables set.");
677
+ for (const warning of warnings) console.error(styleText("yellow", `Warning: ${warning}`));
543
678
  if (Object.keys(set).length || unset.length) console.log("Applies from the next request.");
544
679
  }
545
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)}`;
546
705
  const accounts = defineCommand({
547
706
  meta: {
548
707
  name: "accounts",
549
- description: "Admins: list accounts and their app limits, or set one"
708
+ description: "Admins: list accounts and their limits, or change them"
550
709
  },
551
710
  args: {
552
711
  email: {
@@ -558,21 +717,35 @@ const accounts = defineCommand({
558
717
  type: "string",
559
718
  description: "Apps the account may own: a number, \"none\" for no limit, \"default\""
560
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
+ },
561
732
  ...connection
562
733
  },
563
734
  async run({ args }) {
564
735
  if (args.email) {
565
- if (!args.apps) throw new Error("Pass --apps <n|none|default> to change the limit");
566
- 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)");
567
740
  const result = await (await api(`/v1/accounts/${encodeURIComponent(args.email)}`, args, {
568
741
  method: "PATCH",
569
742
  headers: { "content-type": "application/json" },
570
- body: JSON.stringify({ apps })
743
+ body: JSON.stringify(change)
571
744
  })).json();
572
- return console.log(`${result.email}: ${result.limits.apps ?? "no limit"} apps`);
745
+ return console.log(`${result.email}: ${describeLimits(result.limits)}`);
573
746
  }
574
747
  const result = await (await api("/v1/accounts", args)).json();
575
- console.log(`default: ${result.defaults.apps ?? "no limit"} apps per account`);
748
+ console.log(`default: ${describeLimits(result.defaults)}`);
576
749
  for (const account of result.accounts) {
577
750
  const list = account.apps.length ? ` ${account.apps.join(", ")}` : "";
578
751
  if (account.admin) {
@@ -582,6 +755,7 @@ const accounts = defineCommand({
582
755
  const limit = account.limits.apps ?? "no limit";
583
756
  const own = "apps" in account.custom ? "" : " (default)";
584
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)}`));
585
759
  }
586
760
  }
587
761
  });
@@ -631,6 +805,122 @@ const tokens = defineCommand({
631
805
  }
632
806
  }
633
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
+ });
634
924
  const tasks = defineCommand({
635
925
  meta: {
636
926
  name: "tasks",
@@ -1019,6 +1309,7 @@ runMain(defineCommand({
1019
1309
  deploy,
1020
1310
  apps,
1021
1311
  releases,
1312
+ audit,
1022
1313
  logs,
1023
1314
  activate,
1024
1315
  rollback,
@@ -1028,6 +1319,8 @@ runMain(defineCommand({
1028
1319
  accounts,
1029
1320
  env,
1030
1321
  tokens,
1322
+ token,
1323
+ identity,
1031
1324
  tasks,
1032
1325
  stats,
1033
1326
  delete: remove,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.2.13",
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
@@ -65,19 +65,38 @@ existing app, work in its repository instead.
65
65
  pnpm exec wervt deploy # build, upload, migrate, activate
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
+ 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
68
70
  pnpm exec wervt members <name> --add a@x.com # who may deploy and manage it (the first deploy owns it)
69
- 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
70
72
  ```
71
73
 
72
74
  - Ask before making an app public if it shows personal or private data. "Ship it" alone doesn't
73
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.
74
81
  - Secrets for `wervt env` (API keys) come from the user. Ask them to run the command themselves
75
- (`! 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.
76
83
  - `wervt deploy` requests `/` once the release started and fails (exit 1, with the first logged
77
84
  error) on a 5xx. Add the pages you changed: `--check /,/todos,/api/todos`. The checks are
78
85
  anonymous, so pages behind a sign-in only show they don't crash without a user.
79
86
  - Then open the URL `wervt deploy` printed and check
80
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.
81
100
 
82
101
  ## 5. Deploy on push (when the user wants a repository)
83
102
 
@@ -100,7 +119,8 @@ release lists the commit.
100
119
 
101
120
  ```bash
102
121
  pnpm exec wervt apps # every app, active release, URL
103
- 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
104
124
  pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
105
125
  pnpm exec wervt tasks <name> # tasks, schedules, next runs
106
126
  pnpm exec wervt tasks <name> --run <task> # run one now