@wervt/cli 0.2.16 → 0.2.17

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/dist/index.mjs CHANGED
@@ -125,9 +125,40 @@ function auditSummary(entry) {
125
125
  case "client.link":
126
126
  case "client.delete": return text(d.name) || text(d.clientId);
127
127
  case "app.delete": return `database ${text(d.database)}`;
128
+ case "domains.add":
129
+ case "domains.remove":
130
+ case "domains.primary": return text(d.domain);
128
131
  default: return entry.account ?? "";
129
132
  }
130
133
  }
134
+ /** A saved query's alert in a few words: `alert at 5 in 5m, firing (7)`. */
135
+ function alertSummary(query) {
136
+ const { alert, state } = query;
137
+ if (!alert) return "no alert";
138
+ const rule = `alert at ${alert.threshold} in ${alert.window}`;
139
+ if (!alert.enabled) return `${rule}, off`;
140
+ if (state?.error) return `${rule}, failing: ${state.error.split("\n")[0]}`;
141
+ if (state?.firing) return `${rule}, firing (${state.lastCount ?? "?"})`;
142
+ return state?.lastFiredAt ? `${rule}, last fired ${state.lastFiredAt.slice(0, 16)}` : rule;
143
+ }
144
+ /** `GET /api/x 500 12ms 3 entries, 1 error` for a request. */
145
+ function requestSummary(group) {
146
+ const what = group.method && group.path ? [
147
+ group.method,
148
+ group.path,
149
+ group.status
150
+ ].filter(Boolean).join(" ") : group.message ?? "(no request event)";
151
+ const counts = [
152
+ `${group.entries} ${group.entries === 1 ? "entry" : "entries"}`,
153
+ group.errors && `${group.errors} ${group.errors === 1 ? "error" : "errors"}`,
154
+ group.warnings && `${group.warnings} ${group.warnings === 1 ? "warning" : "warnings"}`
155
+ ].filter(Boolean);
156
+ return [
157
+ what,
158
+ group.duration,
159
+ counts.join(", ")
160
+ ].filter(Boolean).join(" ");
161
+ }
131
162
  // src/source.ts
132
163
  async function git(dir, ...args) {
133
164
  try {
@@ -277,7 +308,11 @@ const deploy = defineCommand({
277
308
  if ((await x(pm, ["run", "build"], {
278
309
  nodeOptions: {
279
310
  cwd: dir,
280
- stdio: "inherit"
311
+ stdio: "inherit",
312
+ env: {
313
+ ...process.env,
314
+ WERVT_APP: name
315
+ }
281
316
  },
282
317
  throwOnError: false
283
318
  })).exitCode !== 0) throw new Error("Build failed");
@@ -596,6 +631,63 @@ const access = defineCommand({
596
631
  }
597
632
  }
598
633
  });
634
+ const domains = defineCommand({
635
+ meta: {
636
+ name: "domains",
637
+ description: "Show or change an app's own domains (e.g. example.com); <app>.<base domain> then forwards to the primary one"
638
+ },
639
+ args: {
640
+ app: {
641
+ type: "positional",
642
+ required: true,
643
+ description: "App name"
644
+ },
645
+ add: {
646
+ type: "string",
647
+ description: "Add a domain; it serves the app once its DNS points here"
648
+ },
649
+ remove: {
650
+ type: "string",
651
+ description: "Remove a domain"
652
+ },
653
+ primary: {
654
+ type: "string",
655
+ description: "Make a verified domain the app's main address"
656
+ },
657
+ verify: {
658
+ type: "string",
659
+ description: "Check a pending domain's DNS now"
660
+ },
661
+ ...connection
662
+ },
663
+ async run({ args }) {
664
+ const changes = [
665
+ "add",
666
+ "remove",
667
+ "primary",
668
+ "verify"
669
+ ].filter((key) => args[key]);
670
+ if (changes.length > 1) throw new Error("Pass one of --add, --remove, --primary or --verify");
671
+ const change = changes[0];
672
+ const result = await (change ? await api(`/v1/apps/${args.app}/domains`, args, {
673
+ method: "PATCH",
674
+ headers: { "content-type": "application/json" },
675
+ body: JSON.stringify({ [change]: args[change] })
676
+ }) : await api(`/v1/apps/${args.app}/domains`, args)).json();
677
+ if (!result.configured) console.log("This server doesn't support custom domains.");
678
+ if (!result.domains.length) return console.log(`${args.app} has no domains of its own.`);
679
+ for (const d of result.domains) {
680
+ const label = d.status === "active" ? styleText("green", d.primary ? "active, primary" : "active") : styleText("yellow", "pending");
681
+ console.log(`${d.domain} ${label}${d.error ? ` ${styleText("dim", d.error)}` : ""}`);
682
+ }
683
+ const pending = result.domains.filter((d) => d.status === "pending");
684
+ if (!pending.length || !result.target) return;
685
+ console.log(`\nPoint each pending domain at ${result.target}: a CNAME (subdomains), or an A record with its addresses (a bare domain).`);
686
+ console.log("Behind a proxy of your own (Cloudflare), add a TXT record instead, the proxy has to send the traffic on:");
687
+ for (const d of pending) console.log(` ${d.txt.name} TXT ${d.txt.value}`);
688
+ console.log("Pending domains are checked every few minutes, or now with --verify <domain>.");
689
+ }
690
+ });
599
691
  const members = defineCommand({
600
692
  meta: {
601
693
  name: "members",
@@ -728,9 +820,36 @@ const accounts = defineCommand({
728
820
  type: "string",
729
821
  description: "Space all releases of their apps may take, e.g. 2GB. \"none\", \"default\""
730
822
  },
823
+ into: {
824
+ type: "string",
825
+ description: "Merge the account into this one (same person, two sign-ups): its logins, apps and access move over, apps move their data, then it is deleted"
826
+ },
731
827
  ...connection
732
828
  },
733
829
  async run({ args }) {
830
+ if (args.email && args.into) {
831
+ const result = await (await api("/v1/accounts/merge", args, {
832
+ method: "POST",
833
+ headers: { "content-type": "application/json" },
834
+ body: JSON.stringify({
835
+ from: args.email,
836
+ into: args.into
837
+ })
838
+ })).json();
839
+ console.log(`${result.from.email} merged into ${result.into.email}`);
840
+ console.log(` logins moved: ${result.logins.join(", ") || "none"}`);
841
+ if (result.owners.length) console.log(` owner or member of: ${result.owners.join(", ")}`);
842
+ if (result.access.length) console.log(` allowed in: ${result.access.join(", ")}`);
843
+ const payload = JSON.stringify({
844
+ from: result.from,
845
+ into: result.into
846
+ });
847
+ for (const [app, run] of Object.entries(result.apps)) {
848
+ console.log(` ${app}: ${run.status}${run.error ? ` (${run.error})` : ""}`);
849
+ if (run.status !== "ok") console.log(styleText("dim", ` retry: wervt tasks ${app} --run wervt:account-merged --payload '${payload}'`));
850
+ }
851
+ return;
852
+ }
734
853
  if (args.email) {
735
854
  const change = {};
736
855
  if (args.apps) change.apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
@@ -1172,6 +1291,14 @@ const logs = defineCommand({
1172
1291
  alias: "f",
1173
1292
  description: "Keep printing new entries"
1174
1293
  },
1294
+ requests: {
1295
+ type: "boolean",
1296
+ description: "One line per request (requestId): what it was and how much it logged"
1297
+ },
1298
+ saved: {
1299
+ type: "string",
1300
+ description: "Use a saved query by name (see `wervt queries`)"
1301
+ },
1175
1302
  json: {
1176
1303
  type: "boolean",
1177
1304
  description: "One JSON object per line, with every field"
@@ -1180,7 +1307,31 @@ const logs = defineCommand({
1180
1307
  },
1181
1308
  async run({ args }) {
1182
1309
  const params = new URLSearchParams();
1183
- if (args.query) params.set("query", args.query);
1310
+ if (args.saved) {
1311
+ if (args.query) throw new Error("Give either a query or --saved, not both");
1312
+ const saved = await findSavedQuery(args.app, args.saved, args);
1313
+ params.set("query", saved.query);
1314
+ if (saved.levels?.length) params.set("levels", saved.levels.join(","));
1315
+ } else if (args.query) params.set("query", args.query);
1316
+ if (args.requests) {
1317
+ if (args.follow) throw new Error("--requests and --follow do not go together");
1318
+ params.set("start", args.since);
1319
+ if (args.until) params.set("end", args.until);
1320
+ params.set("limit", args.limit);
1321
+ const groups = await (await api(`/v1/apps/${args.app}/logs/requests?${params.toString()}`, args)).json();
1322
+ if (!groups.length && !args.json) return console.error(`No requests with a requestId in the last ${args.since}.`);
1323
+ for (const group of groups.reverse()) {
1324
+ if (args.json) {
1325
+ console.log(JSON.stringify(group));
1326
+ continue;
1327
+ }
1328
+ const time = new Date(group.last).toISOString().slice(11, 23);
1329
+ const color = group.errors ? "red" : group.warnings ? "yellow" : "white";
1330
+ console.log(`${styleText("dim", time)} ${styleText(color, requestSummary(group))} ${styleText("dim", group.requestId)}`);
1331
+ }
1332
+ if (!args.json) console.error(styleText("dim", `\nOne request: wervt logs ${args.app} 'requestId:=<id>' --since ${args.since}`));
1333
+ return;
1334
+ }
1184
1335
  if (args.follow) {
1185
1336
  params.set("since", args.since);
1186
1337
  const res = await api(`/v1/apps/${args.app}/logs/tail?${params.toString()}`, args);
@@ -1200,6 +1351,111 @@ const logs = defineCommand({
1200
1351
  for (const entry of entries.reverse()) printLog(entry, Boolean(args.json));
1201
1352
  }
1202
1353
  });
1354
+ /** `/v1/apps/<app>/logs/queries`, or across apps (admins) without an app. */
1355
+ const queriesPath = (app) => app ? `/v1/apps/${app}/logs/queries` : "/v1/logs/queries";
1356
+ async function findSavedQuery(app, name, flags) {
1357
+ const list = await (await api(queriesPath(app), flags)).json();
1358
+ const found = list.find((query) => query.id === name || query.name.toLowerCase() === name.toLowerCase());
1359
+ if (!found) throw new Error(`No saved query "${name}"${list.length ? `. Saved: ${list.map((q) => q.name).join(", ")}` : ""}`);
1360
+ return found;
1361
+ }
1362
+ const queries = defineCommand({
1363
+ meta: {
1364
+ name: "queries",
1365
+ description: "List, save or delete saved log queries and the alerts on them"
1366
+ },
1367
+ args: {
1368
+ app: {
1369
+ type: "positional",
1370
+ required: false,
1371
+ description: "App name (default: queries across all apps, admins only)"
1372
+ },
1373
+ save: {
1374
+ type: "string",
1375
+ description: "Save a query under this name, or change the one that has it"
1376
+ },
1377
+ query: {
1378
+ type: "string",
1379
+ description: "LogsQL for --save, e.g. 'level:error'"
1380
+ },
1381
+ levels: {
1382
+ type: "string",
1383
+ description: "Only these levels for --save, e.g. error,warn"
1384
+ },
1385
+ alert: {
1386
+ type: "string",
1387
+ description: "on or off: mail the app owner and members when the query matches"
1388
+ },
1389
+ threshold: {
1390
+ type: "string",
1391
+ description: "Alert when at least this many entries match"
1392
+ },
1393
+ window: {
1394
+ type: "string",
1395
+ description: "Alert window, e.g. 5m (1m to 24h)"
1396
+ },
1397
+ cooldown: {
1398
+ type: "string",
1399
+ description: "Quiet time after an alert, e.g. 1h (5m to 7d)"
1400
+ },
1401
+ delete: {
1402
+ type: "string",
1403
+ description: "Delete the saved query with this name"
1404
+ },
1405
+ json: {
1406
+ type: "boolean",
1407
+ description: "Print JSON"
1408
+ },
1409
+ ...connection
1410
+ },
1411
+ async run({ args }) {
1412
+ const path = queriesPath(args.app);
1413
+ const json = { "content-type": "application/json" };
1414
+ if (args.delete) {
1415
+ const found = await findSavedQuery(args.app, args.delete, args);
1416
+ await api(`${path}/${found.id}`, args, { method: "DELETE" });
1417
+ return console.log(`Deleted ${found.name}`);
1418
+ }
1419
+ if (args.save) {
1420
+ if (args.alert !== void 0 && !["on", "off"].includes(args.alert)) throw new Error("--alert is on or off");
1421
+ const alertFields = {
1422
+ ...args.threshold ? { threshold: Number(args.threshold) } : {},
1423
+ ...args.window ? { window: args.window } : {},
1424
+ ...args.cooldown ? { cooldown: args.cooldown } : {}
1425
+ };
1426
+ const alert = args.alert === "off" ? null : args.alert === "on" || Object.keys(alertFields).length ? {
1427
+ enabled: true,
1428
+ ...alertFields
1429
+ } : void 0;
1430
+ const body = {
1431
+ name: args.save,
1432
+ ...args.query !== void 0 ? { query: args.query } : {},
1433
+ ...args.levels !== void 0 ? { levels: args.levels } : {},
1434
+ ...alert !== void 0 ? { alert } : {}
1435
+ };
1436
+ const existing = (await (await api(path, args)).json()).find((q) => q.name.toLowerCase() === args.save.toLowerCase());
1437
+ const saved = await (await api(existing ? `${path}/${existing.id}` : path, args, {
1438
+ method: existing ? "PATCH" : "POST",
1439
+ headers: json,
1440
+ body: JSON.stringify(body)
1441
+ })).json();
1442
+ if (args.json) return console.log(JSON.stringify(saved));
1443
+ return console.log(`${existing ? "Changed" : "Saved"} ${saved.name}: ${saved.query} ${styleText("dim", alertSummary(saved))}`);
1444
+ }
1445
+ const list = await (await api(path, args)).json();
1446
+ if (args.json) return console.log(JSON.stringify(list));
1447
+ if (!list.length) {
1448
+ const target = args.app ? ` ${args.app}` : "";
1449
+ return console.log(`No saved queries. Save one: wervt queries${target} --save Errors --query 'level:error' --alert on`);
1450
+ }
1451
+ for (const query of list) {
1452
+ const levels = query.levels?.length ? styleText("dim", ` [${query.levels.join(",")}]`) : "";
1453
+ const color = query.state?.error ? "red" : query.alert?.enabled && query.state?.firing ? "yellow" : "dim";
1454
+ console.log(`${styleText("bold", query.name)} ${query.query}${levels} ${styleText(color, alertSummary(query))}`);
1455
+ }
1456
+ if (args.app) console.log(styleText("dim", `\nRun one: wervt logs ${args.app} --saved '${list[0].name}'`));
1457
+ }
1458
+ });
1203
1459
  const SPARKS = "▁▂▃▄▅▆▇█";
1204
1460
  const stats = defineCommand({
1205
1461
  meta: {
@@ -1424,10 +1680,12 @@ runMain(defineCommand({
1424
1680
  releases,
1425
1681
  audit,
1426
1682
  logs,
1683
+ queries,
1427
1684
  activate,
1428
1685
  rollback,
1429
1686
  reprovision,
1430
1687
  access,
1688
+ domains,
1431
1689
  members,
1432
1690
  accounts,
1433
1691
  env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Deploy and manage apps on wervt.",
5
5
  "keywords": [
6
6
  "agents",
package/skill/SKILL.md CHANGED
@@ -42,6 +42,8 @@ existing app, work in its repository instead.
42
42
 
43
43
  ## 3. Build
44
44
 
45
+ <!-- build.wervt.app: from here to the end marker, also the MCP server's instructions -->
46
+
45
47
  - Keep the app small and complete: data in Postgres through `useDb()`, writes through API routes,
46
48
  lists through `useShape` so every open tab stays live. Ask only when the request is genuinely
47
49
  ambiguous; otherwise choose sensible defaults and say what you chose.
@@ -54,10 +56,13 @@ existing app, work in its repository instead.
54
56
  MCP clients at `https://<app>.wervt.app/mcp`, signed in through wervt. Details in the app's
55
57
  `AGENTS.md`.
56
58
  - After changing `server/db/schema.ts`: `pnpm db:generate`, and commit the migration.
59
+ - Finish with `pnpm exec vp check --fix` passing.
60
+
61
+ <!-- end -->
62
+
57
63
  - `pnpm dev` runs against a local wervt stack, which the user may not have. When it isn't running,
58
64
  don't set one up: `pnpm build` plus `pnpm exec vp check --fix` are the checks, and the
59
65
  deployed app is the test.
60
- - Finish with `pnpm exec vp check --fix` passing.
61
66
 
62
67
  ## 4. Ship
63
68
 
@@ -69,8 +74,12 @@ pnpm exec wervt access <name> --invite # private, and people with acc
69
74
  pnpm exec wervt access <name> --invite-by owners # only owner, members and admins may invite
70
75
  pnpm exec wervt members <name> --add a@x.com # who may deploy and manage it (the first deploy owns it)
71
76
  pnpm exec wervt env <name> --set NUXT_API_KEY=… # runtimeConfig.apiKey, applied from the next request
77
+ pnpm exec wervt domains <name> --add example.com # its own domain; prints the DNS records to set
78
+ pnpm exec wervt deploy --name <other> # the same code as another app (own data, env, domains)
72
79
  ```
73
80
 
81
+ <!-- build.wervt.app -->
82
+
74
83
  - Ask before making an app public if it shows personal or private data. "Ship it" alone doesn't
75
84
  decide that. Private, with the user's email allowed, is the safe default.
76
85
  - App configuration and secrets go through Nuxt's `runtimeConfig`, never `process.env` in app
@@ -78,6 +87,9 @@ pnpm exec wervt env <name> --set NUXT_API_KEY=… # runtimeConfig.apiKey, appl
78
87
  it with `useRuntimeConfig()` and set it as `NUXT_` + the key in SCREAMING_SNAKE_CASE
79
88
  (`apiKey` → `NUXT_API_KEY`, `public.mapStyle` → `NUXT_PUBLIC_MAP_STYLE`). Keys missing from
80
89
  `runtimeConfig` are ignored.
90
+
91
+ <!-- end -->
92
+
81
93
  - Secrets for `wervt env` (API keys) come from the user. Ask them to run the command themselves
82
94
  (`! pnpm exec wervt env <name> --set NUXT_API_KEY=…`) instead of pasting the value to you.
83
95
  - `wervt deploy` requests `/` once the release started and fails (exit 1, with the first logged
@@ -122,6 +134,8 @@ pnpm exec wervt apps # every app, active release, URL
122
134
  pnpm exec wervt releases <name> # newest first, with commit, author, failed deploys
123
135
  pnpm exec wervt audit <name> # who deployed, rolled back, changed env or access
124
136
  pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
137
+ pnpm exec wervt logs <name> --requests --since 1h # one line per request
138
+ pnpm exec wervt queries <name> # saved queries and their alerts
125
139
  pnpm exec wervt tasks <name> # tasks, schedules, next runs
126
140
  pnpm exec wervt tasks <name> --run <task> # run one now
127
141
  pnpm exec wervt rollback <name> # the previous release (migrations stay)