@wervt/cli 0.2.14 → 0.2.16

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
@@ -8,7 +8,7 @@ import { defineCommand, runMain } from "citty";
8
8
  import { detectPackageManager } from "nypm";
9
9
  import { x } from "tinyexec";
10
10
  import { packTar } from "modern-tar";
11
- //#region src/bundle.ts
11
+ // src/bundle.ts
12
12
  /** Files under `dir`. Follows symlinks: Nitro can leave traced dependencies in
13
13
  * server/node_modules as links into the local pnpm store, which won't exist on the server. */
14
14
  async function walk(dir) {
@@ -51,8 +51,7 @@ async function createBundle(appDir) {
51
51
  files: files.length
52
52
  };
53
53
  }
54
- //#endregion
55
- //#region src/config.ts
54
+ // src/config.ts
56
55
  const file = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "wervt", "config.json");
57
56
  /** Flags > WERVT_URL / WERVT_TOKEN > ~/.config/wervt/config.json. */
58
57
  async function resolveConfig(flags) {
@@ -74,8 +73,62 @@ async function saveConfig(config) {
74
73
  await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
75
74
  return file;
76
75
  }
77
- //#endregion
78
- //#region src/source.ts
76
+ // src/format.ts
77
+ function formatBytes(bytes) {
78
+ if (bytes < 1024) return `${bytes} B`;
79
+ const units = [
80
+ "KB",
81
+ "MB",
82
+ "GB"
83
+ ];
84
+ let value = bytes / 1024;
85
+ let unit = 0;
86
+ while (value >= 1024 && unit < units.length - 1) {
87
+ value /= 1024;
88
+ unit++;
89
+ }
90
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
91
+ }
92
+ const list = (value) => Array.isArray(value) ? value.map(String).join(", ") : "";
93
+ const text = (value) => typeof value === "string" ? value : "";
94
+ /** What an audit entry changed, in a few words: `env.update` → `set API_URL, TOKEN`. */
95
+ function auditSummary(entry) {
96
+ const d = entry.details ?? {};
97
+ switch (entry.action) {
98
+ case "deploy":
99
+ case "deploy.failed": {
100
+ const migrations = list(d.migrations);
101
+ return [
102
+ text(d.release),
103
+ migrations && `migrated ${migrations}`,
104
+ text(d.checkError) && `checks failed: ${text(d.checkError)}`,
105
+ entry.action === "deploy.failed" && text(d.error).split("\n")[0]
106
+ ].filter(Boolean).join(" ");
107
+ }
108
+ case "rollback":
109
+ case "activate": return `${text(d.previous) || "none"} → ${text(d.release)}`;
110
+ case "env.update": return [list(d.set) && `set ${list(d.set)}`, list(d.unset) && `unset ${list(d.unset)}`].filter(Boolean).join("; ");
111
+ case "access.update": return [
112
+ text(d.visibility),
113
+ list(d.allow) && `allow ${list(d.allow)}`,
114
+ list(d.add) && `add ${list(d.add)}`,
115
+ list(d.remove) && `remove ${list(d.remove)}`
116
+ ].filter(Boolean).join("; ");
117
+ case "owners.update": return [
118
+ text(d.owner) && `owner ${text(d.owner)}`,
119
+ list(d.added) && `added ${list(d.added)}`,
120
+ list(d.removed) && `removed ${list(d.removed)}`
121
+ ].filter(Boolean).join("; ");
122
+ case "token.create":
123
+ case "token.revoke": return text(d.name) || text(d.id);
124
+ case "client.create":
125
+ case "client.link":
126
+ case "client.delete": return text(d.name) || text(d.clientId);
127
+ case "app.delete": return `database ${text(d.database)}`;
128
+ default: return entry.account ?? "";
129
+ }
130
+ }
131
+ // src/source.ts
79
132
  async function git(dir, ...args) {
80
133
  try {
81
134
  const result = await x("git", args, {
@@ -120,8 +173,7 @@ async function deploySource(dir) {
120
173
  actor: await git(dir, "config", "user.name")
121
174
  };
122
175
  }
123
- //#endregion
124
- //#region src/index.ts
176
+ // src/index.ts
125
177
  const interactive = process.stdout.isTTY === true && !process.env.CI;
126
178
  function progress() {
127
179
  if (interactive) return p.spinner();
@@ -294,6 +346,10 @@ const deploy = defineCommand({
294
346
  process.exitCode = 1;
295
347
  continue;
296
348
  }
349
+ if (event.step === "quota-warning") {
350
+ p.log.warn(event.message);
351
+ continue;
352
+ }
297
353
  if (event.step === "storage-failed") {
298
354
  p.log.warn(`Storage isn't set up: ${event.message}. The app runs; files won't work yet.`);
299
355
  continue;
@@ -333,6 +389,7 @@ const rollback = defineCommand({
333
389
  console.log(`${args.app} is now on release ${result.release}`);
334
390
  }
335
391
  });
392
+ const firstLine = (text) => text.split("\n")[0];
336
393
  const releases = defineCommand({
337
394
  meta: {
338
395
  name: "releases",
@@ -349,16 +406,75 @@ const releases = defineCommand({
349
406
  async run({ args }) {
350
407
  const info = await (await api(`/v1/apps/${args.app}`, args)).json();
351
408
  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) ?? {};
409
+ const failed = new Map(info.failedDeploys?.map((entry) => [entry.release, entry]));
410
+ const ids = [...info.releases, ...failed.keys()].sort((a, b) => b.localeCompare(a));
411
+ for (const release of ids) {
412
+ const failure = failed.get(release);
413
+ const kept = history.get(release);
414
+ const source = failure?.source ?? kept?.source;
415
+ const commit = source?.commit ? `${source.commit.slice(0, 7)}${source.dirty ? "+" : ""}` : void 0;
416
+ const applied = kept?.migrations?.applied.length;
354
417
  const details = [
355
- source?.commit ? `${source.commit.slice(0, 7)}${source.dirty ? "+" : ""}` : void 0,
418
+ commit,
356
419
  source?.branch,
357
- source?.actor ?? deployedBy,
420
+ source?.actor ?? failure?.actor ?? kept?.deployedBy,
421
+ kept?.size && formatBytes(kept.size.bundle),
422
+ applied && `${applied} migration${applied === 1 ? "" : "s"}`,
358
423
  source?.message && styleText("dim", source.message)
359
424
  ].filter(Boolean);
360
- const active = release === info.release ? styleText("green", " (active)") : "";
361
- console.log(`${release}${active}${details.length ? ` ${details.join(" ")}` : ""}`);
425
+ const status = release === info.release ? styleText("green", " (active)") : failure ? styleText("red", " (failed)") : kept?.bootError ? styleText("red", " (didn't start)") : "";
426
+ const checks = kept?.checkError ? styleText("yellow", " (checks failed)") : "";
427
+ console.log(`${release}${status}${checks}${details.length ? ` ${details.join(" ")}` : ""}`);
428
+ const error = failure?.error ?? kept?.bootError ?? kept?.checkError;
429
+ if (error) console.log(styleText("dim", ` ${firstLine(error)}`));
430
+ }
431
+ }
432
+ });
433
+ const audit = defineCommand({
434
+ meta: {
435
+ name: "audit",
436
+ description: "Show who deployed, rolled back, deleted or changed access, env and tokens"
437
+ },
438
+ args: {
439
+ app: {
440
+ type: "positional",
441
+ required: false,
442
+ description: "App name (default: all you see)"
443
+ },
444
+ action: {
445
+ type: "string",
446
+ description: "Only these actions, comma-separated; deploy* for all deploys"
447
+ },
448
+ limit: {
449
+ type: "string",
450
+ default: "50",
451
+ description: "Newest entries to show (max 1000)"
452
+ },
453
+ json: {
454
+ type: "boolean",
455
+ description: "One JSON object per line"
456
+ },
457
+ ...connection
458
+ },
459
+ async run({ args }) {
460
+ const params = new URLSearchParams({ limit: args.limit });
461
+ if (args.action) params.set("action", args.action);
462
+ const entries = await (await api(`${args.app ? `/v1/apps/${args.app}/audit` : "/v1/audit"}?${params.toString()}`, args)).json();
463
+ if (!entries.length && !args.json) return console.error("No audit entries.");
464
+ for (const entry of entries.reverse()) {
465
+ if (args.json) {
466
+ console.log(JSON.stringify(entry));
467
+ continue;
468
+ }
469
+ const time = new Date(entry.at).toISOString().slice(0, 16).replace("T", " ");
470
+ const color = entry.action === "deploy.failed" ? "red" : "cyan";
471
+ console.log([
472
+ styleText("dim", time),
473
+ styleText(color, entry.action.padEnd(15)),
474
+ (entry.app ?? "-").padEnd(16),
475
+ entry.actor,
476
+ styleText("dim", auditSummary(entry))
477
+ ].join(" "));
362
478
  }
363
479
  }
364
480
  });
@@ -441,6 +557,10 @@ const access = defineCommand({
441
557
  type: "boolean",
442
558
  description: "Make the app private, and let people with access invite others in the app"
443
559
  },
560
+ "invite-by": {
561
+ type: "string",
562
+ description: "Who may invite on an invite-only app: anyone (with access, the default) or owners (owner, members and admins)"
563
+ },
444
564
  add: {
445
565
  type: "string",
446
566
  description: "Allow emails or *@domain (comma separated)"
@@ -456,6 +576,7 @@ const access = defineCommand({
456
576
  if (args.public) change.visibility = "public";
457
577
  if (args.private) change.visibility = "private";
458
578
  if (args.invite) change.visibility = "invite";
579
+ if (args["invite-by"]) change.inviteBy = args["invite-by"];
459
580
  if (args.add) change.add = toList(args.add);
460
581
  if (args.remove) change.remove = toList(args.remove);
461
582
  const result = await (Object.keys(change).length ? await api(`/v1/apps/${args.app}/access`, args, {
@@ -468,6 +589,7 @@ const access = defineCommand({
468
589
  if (result.visibility === "public") return;
469
590
  console.log(result.allow.length ? `allowed: ${result.allow.join(", ")}` : "allowed: admins only");
470
591
  if (result.visibility === "invite") {
592
+ console.log(result.inviteBy === "owners" ? "who invites: the owner, members and admins" : "who invites: everyone with access");
471
593
  const invited = result.invited ?? [];
472
594
  console.log(invited.length ? "invited:" : "invited: nobody yet");
473
595
  for (const invite of invited) console.log(` ${invite.email} (by ${invite.by}, ${invite.at.slice(0, 10)})`);
@@ -542,7 +664,7 @@ const env = defineCommand({
542
664
  return [pair.slice(0, i), pair.slice(i + 1)];
543
665
  }));
544
666
  const unset = toList(args.unset);
545
- const { keys } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
667
+ const { keys, warnings = [] } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
546
668
  method: "PATCH",
547
669
  headers: { "content-type": "application/json" },
548
670
  body: JSON.stringify({
@@ -551,13 +673,38 @@ const env = defineCommand({
551
673
  })
552
674
  }) : await api(`/v1/apps/${args.app}/env`, args)).json();
553
675
  console.log(keys.length ? keys.join("\n") : "No variables set.");
676
+ for (const warning of warnings) console.error(styleText("yellow", `Warning: ${warning}`));
554
677
  if (Object.keys(set).length || unset.length) console.log("Applies from the next request.");
555
678
  }
556
679
  });
680
+ const SIZE_FLAGS = [
681
+ "database",
682
+ "storage",
683
+ "releases"
684
+ ];
685
+ /** Bytes as people read them: `1.5 GB`, `200 MB`; `no limit` for null. */
686
+ function size(bytes) {
687
+ if (bytes === null || bytes === void 0) return "no limit";
688
+ const units = [
689
+ "B",
690
+ "KB",
691
+ "MB",
692
+ "GB",
693
+ "TB"
694
+ ];
695
+ let value = bytes;
696
+ let unit = 0;
697
+ while (value >= 1024 && unit < units.length - 1) {
698
+ value /= 1024;
699
+ unit++;
700
+ }
701
+ return `${Number(value.toFixed(value < 10 && unit ? 1 : 0))} ${units[unit]}`;
702
+ }
703
+ const describeLimits = (limits) => `${limits.apps ?? "no limit"} apps, database ${size(limits.database)} and files ${size(limits.storage)} per app, releases ${size(limits.releases)}`;
557
704
  const accounts = defineCommand({
558
705
  meta: {
559
706
  name: "accounts",
560
- description: "Admins: list accounts and their app limits, or set one"
707
+ description: "Admins: list accounts and their limits, or change them"
561
708
  },
562
709
  args: {
563
710
  email: {
@@ -569,21 +716,35 @@ const accounts = defineCommand({
569
716
  type: "string",
570
717
  description: "Apps the account may own: a number, \"none\" for no limit, \"default\""
571
718
  },
719
+ database: {
720
+ type: "string",
721
+ description: "Database size per app, e.g. 2GB; past it deploys are refused. \"none\", \"default\""
722
+ },
723
+ storage: {
724
+ type: "string",
725
+ description: "Files per app (bucket quota), e.g. 10GB. \"none\", \"default\""
726
+ },
727
+ releases: {
728
+ type: "string",
729
+ description: "Space all releases of their apps may take, e.g. 2GB. \"none\", \"default\""
730
+ },
572
731
  ...connection
573
732
  },
574
733
  async run({ args }) {
575
734
  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);
735
+ const change = {};
736
+ if (args.apps) change.apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
737
+ for (const flag of SIZE_FLAGS) if (args[flag]) change[flag] = args[flag] === "none" ? null : args[flag];
738
+ if (!Object.keys(change).length) throw new Error("Pass --apps, --database, --storage or --releases (a value, none or default)");
578
739
  const result = await (await api(`/v1/accounts/${encodeURIComponent(args.email)}`, args, {
579
740
  method: "PATCH",
580
741
  headers: { "content-type": "application/json" },
581
- body: JSON.stringify({ apps })
742
+ body: JSON.stringify(change)
582
743
  })).json();
583
- return console.log(`${result.email}: ${result.limits.apps ?? "no limit"} apps`);
744
+ return console.log(`${result.email}: ${describeLimits(result.limits)}`);
584
745
  }
585
746
  const result = await (await api("/v1/accounts", args)).json();
586
- console.log(`default: ${result.defaults.apps ?? "no limit"} apps per account`);
747
+ console.log(`default: ${describeLimits(result.defaults)}`);
587
748
  for (const account of result.accounts) {
588
749
  const list = account.apps.length ? ` ${account.apps.join(", ")}` : "";
589
750
  if (account.admin) {
@@ -593,6 +754,7 @@ const accounts = defineCommand({
593
754
  const limit = account.limits.apps ?? "no limit";
594
755
  const own = "apps" in account.custom ? "" : " (default)";
595
756
  console.log(`${account.email} ${account.apps.length}/${limit}${own}${list}`);
757
+ 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
758
  }
597
759
  }
598
760
  });
@@ -642,6 +804,236 @@ const tokens = defineCommand({
642
804
  }
643
805
  }
644
806
  });
807
+ const appArg = {
808
+ type: "positional",
809
+ required: true,
810
+ description: "App name"
811
+ };
812
+ const clients = defineCommand({
813
+ meta: {
814
+ name: "clients",
815
+ description: "Manage the OAuth clients of an app's native apps (sign-in with wervt)"
816
+ },
817
+ subCommands: {
818
+ list: defineCommand({
819
+ meta: {
820
+ name: "list",
821
+ description: "List the app's native app clients"
822
+ },
823
+ args: {
824
+ app: appArg,
825
+ ...connection
826
+ },
827
+ async run({ args }) {
828
+ const list = await (await api(`/v1/apps/${args.app}/clients`, args)).json();
829
+ if (!list.length) return console.log(`No native app clients for ${args.app}.`);
830
+ for (const client of list) {
831
+ const used = client.lastUsedAt ? `used ${client.lastUsedAt.slice(0, 10)}` : "never used";
832
+ console.log(`${client.clientId} ${client.name} created ${client.createdAt.slice(0, 10)} ${used}`);
833
+ for (const uri of client.redirectUris) console.log(styleText("dim", ` ${uri}`));
834
+ }
835
+ }
836
+ }),
837
+ create: defineCommand({
838
+ meta: {
839
+ name: "create",
840
+ description: "Register a native app for sign-in, e.g. --redirect dev.example.app:/auth"
841
+ },
842
+ args: {
843
+ app: appArg,
844
+ name: {
845
+ type: "string",
846
+ required: true,
847
+ description: "Shown on the consent page"
848
+ },
849
+ redirect: {
850
+ type: "string",
851
+ required: true,
852
+ description: "Redirect URI; comma-separated for several"
853
+ },
854
+ ...connection
855
+ },
856
+ async run({ args }) {
857
+ const client = await (await api(`/v1/apps/${args.app}/clients`, args, {
858
+ method: "POST",
859
+ headers: { "content-type": "application/json" },
860
+ body: JSON.stringify({
861
+ name: args.name,
862
+ redirectUris: toList(args.redirect)
863
+ })
864
+ })).json();
865
+ console.error(`Client "${client.name}" for ${args.app}:`);
866
+ console.log(client.clientId);
867
+ console.error(styleText("dim", "A public client (PKCE, no secret): put the id in the app config."));
868
+ }
869
+ }),
870
+ delete: defineCommand({
871
+ meta: {
872
+ name: "delete",
873
+ description: "Delete a client; everyone signed in through it is signed out"
874
+ },
875
+ args: {
876
+ app: appArg,
877
+ clientId: {
878
+ type: "positional",
879
+ required: true,
880
+ description: "Client id"
881
+ },
882
+ yes: {
883
+ type: "boolean",
884
+ description: "Skip the confirmation"
885
+ },
886
+ ...connection
887
+ },
888
+ async run({ args }) {
889
+ if (!args.yes) {
890
+ const ok = await p.confirm({
891
+ message: `Delete ${args.clientId}? Every install signed in through it is signed out.`,
892
+ initialValue: false
893
+ });
894
+ if (p.isCancel(ok) || !ok) return p.cancel("Cancelled");
895
+ }
896
+ await api(`/v1/apps/${args.app}/clients/${args.clientId}`, args, { method: "DELETE" });
897
+ console.log(`Deleted ${args.clientId}`);
898
+ }
899
+ }),
900
+ link: defineCommand({
901
+ meta: {
902
+ name: "link",
903
+ description: "Admins: assign a client that registered itself to the app"
904
+ },
905
+ args: {
906
+ app: appArg,
907
+ clientId: {
908
+ type: "positional",
909
+ required: true,
910
+ description: "Client id"
911
+ },
912
+ ...connection
913
+ },
914
+ async run({ args }) {
915
+ await api(`/v1/apps/${args.app}/clients/${args.clientId}/link`, args, { method: "POST" });
916
+ console.log(`${args.clientId} belongs to ${args.app} now`);
917
+ }
918
+ })
919
+ }
920
+ });
921
+ const token = defineCommand({
922
+ meta: {
923
+ name: "token",
924
+ description: "Mint a short-lived access token for your account on an app, to test a private app (curl, Playwright)"
925
+ },
926
+ args: {
927
+ app: {
928
+ type: "positional",
929
+ required: true,
930
+ description: "App name"
931
+ },
932
+ ttl: {
933
+ type: "string",
934
+ default: "15m",
935
+ description: "Lifetime, 1m to 1h (default: 15m)"
936
+ },
937
+ cookie: {
938
+ type: "boolean",
939
+ default: false,
940
+ description: "Print a cookie for Playwright's context.addCookies() instead of the token"
941
+ },
942
+ json: {
943
+ type: "boolean",
944
+ default: false,
945
+ description: "Print token, cookie and expiry as JSON"
946
+ },
947
+ ...connection
948
+ },
949
+ async run({ args }) {
950
+ const minted = await (await api(`/v1/apps/${args.app}/agent-tokens`, args, {
951
+ method: "POST",
952
+ headers: { "content-type": "application/json" },
953
+ body: JSON.stringify({ ttl: args.ttl })
954
+ })).json();
955
+ if (args.json) return console.log(JSON.stringify(minted, null, 2));
956
+ console.log(args.cookie ? JSON.stringify(minted.cookie) : minted.token);
957
+ 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/…`)));
958
+ }
959
+ });
960
+ function printIdentity(status) {
961
+ if (!status.configured) console.log("Workload identity is not configured on this server.");
962
+ 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`);
963
+ console.log(`issuer: ${status.issuer}`);
964
+ console.log(`subject: ${status.subject ?? "(the app has no owner yet)"}`);
965
+ console.log(`audiences: ${status.audiences.join(", ")}`);
966
+ }
967
+ const identityApp = (enabled) => defineCommand({
968
+ meta: {
969
+ name: enabled === void 0 ? "status" : enabled ? "enable" : "disable",
970
+ 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"
971
+ },
972
+ args: {
973
+ app: {
974
+ type: "positional",
975
+ required: true,
976
+ description: "App name"
977
+ },
978
+ ...connection
979
+ },
980
+ async run({ args }) {
981
+ printIdentity(await (enabled === void 0 ? await api(`/v1/apps/${args.app}/identity`, args) : await api(`/v1/apps/${args.app}/identity`, args, {
982
+ method: "PATCH",
983
+ headers: { "content-type": "application/json" },
984
+ body: JSON.stringify({ enabled })
985
+ })).json());
986
+ }
987
+ });
988
+ const identity = defineCommand({
989
+ meta: {
990
+ name: "identity",
991
+ description: "Workload identity: apps authenticate to AI providers without API keys"
992
+ },
993
+ subCommands: {
994
+ status: identityApp(),
995
+ enable: identityApp(true),
996
+ disable: identityApp(false),
997
+ jwks: defineCommand({
998
+ meta: {
999
+ name: "jwks",
1000
+ description: "Print the issuer's public keys, for providers that can't fetch them"
1001
+ },
1002
+ args: connection,
1003
+ async run({ args }) {
1004
+ const res = await api("/.well-known/jwks.json", args);
1005
+ console.log(JSON.stringify(await res.json(), null, 2));
1006
+ }
1007
+ }),
1008
+ keygen: defineCommand({
1009
+ meta: {
1010
+ name: "keygen",
1011
+ description: "Print a new private signing key for WERVT_IDENTITY_KEY (runs locally)"
1012
+ },
1013
+ args: { kid: {
1014
+ type: "string",
1015
+ description: "Key id (default: wervt-<date>)"
1016
+ } },
1017
+ async run({ args }) {
1018
+ const pair = await crypto.subtle.generateKey({
1019
+ name: "ECDSA",
1020
+ namedCurve: "P-256"
1021
+ }, true, ["sign", "verify"]);
1022
+ const { kty, crv, x, y, d } = await crypto.subtle.exportKey("jwk", pair.privateKey);
1023
+ const kid = args.kid ?? `wervt-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
1024
+ console.log(JSON.stringify({
1025
+ kty,
1026
+ crv,
1027
+ x,
1028
+ y,
1029
+ d,
1030
+ kid
1031
+ }));
1032
+ console.error(styleText("dim", "Private: set it as WERVT_IDENTITY_KEY on control only, never in an app or a repo."));
1033
+ }
1034
+ })
1035
+ }
1036
+ });
645
1037
  const tasks = defineCommand({
646
1038
  meta: {
647
1039
  name: "tasks",
@@ -722,7 +1114,7 @@ const remove = defineCommand({
722
1114
  const dropDb = Boolean(args["drop-database"]);
723
1115
  if (!args.yes) {
724
1116
  const ok = await p.confirm({
725
- message: `Delete ${args.app}${dropDb ? " and ALL its data" : " (database is kept)"}?`,
1117
+ message: `Delete ${args.app}${dropDb ? " and ALL its data" : " (data is kept, the name stays yours)"}?`,
726
1118
  initialValue: false
727
1119
  });
728
1120
  if (p.isCancel(ok) || !ok) return p.cancel("Cancelled");
@@ -1030,6 +1422,7 @@ runMain(defineCommand({
1030
1422
  deploy,
1031
1423
  apps,
1032
1424
  releases,
1425
+ audit,
1033
1426
  logs,
1034
1427
  activate,
1035
1428
  rollback,
@@ -1039,6 +1432,9 @@ runMain(defineCommand({
1039
1432
  accounts,
1040
1433
  env,
1041
1434
  tokens,
1435
+ token,
1436
+ clients,
1437
+ identity,
1042
1438
  tasks,
1043
1439
  stats,
1044
1440
  delete: remove,
@@ -1047,5 +1443,4 @@ runMain(defineCommand({
1047
1443
  skill
1048
1444
  }
1049
1445
  }));
1050
- //#endregion
1051
1446
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Deploy and manage apps on wervt.",
5
5
  "keywords": [
6
6
  "agents",
@@ -30,8 +30,8 @@
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.8.1",
32
32
  "citty": "^0.2.2",
33
- "modern-tar": "^0.8.5",
34
- "nypm": "^0.6.10",
33
+ "modern-tar": "^0.8.6",
34
+ "nypm": "^0.6.12",
35
35
  "tinyexec": "^1.3.1"
36
36
  },
37
37
  "scripts": {
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