@wervt/cli 0.2.16 → 0.2.18

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
@@ -15,6 +15,10 @@ vp exec wervt access my-app --public # or --add friend@example.com
15
15
  vp exec wervt logs my-app
16
16
  ```
17
17
 
18
+ Nitro apps without Nuxt use `@wervt/nitro`. A static site sets `"build": "… && wervt build --static dist"`
19
+ in package.json (any `export default { fetch }` module works too, without `--static`); see
20
+ [Platform contract](https://wervt.app/api/platform-contract#wervt-build).
21
+
18
22
  Without [Vite+](https://viteplus.dev), use `npx @wervt/cli` and `pnpm exec wervt`. `wervt --help`
19
23
  lists every command: `apps`, `releases`, `audit`, `rollback`, `activate`, `env`, `tokens`, `token`, `members`,
20
24
  `tasks`, `stats`, `delete`, `whoami`, … Docs: [wervt.app](https://wervt.app/getting-started/quickstart).
package/dist/index.mjs CHANGED
@@ -1,13 +1,160 @@
1
+ import { builtinModules } from "node:module";
1
2
  import { existsSync } from "node:fs";
2
- import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { appendFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { homedir, hostname } from "node:os";
4
- import { dirname, join, relative, resolve } from "node:path";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { styleText } from "node:util";
6
7
  import * as p from "@clack/prompts";
7
8
  import { defineCommand, runMain } from "citty";
8
9
  import { detectPackageManager } from "nypm";
9
10
  import { x } from "tinyexec";
10
11
  import { packTar } from "modern-tar";
12
+ // ../core/src/manifest.ts
13
+ /** The bundle's `wervt-bundle.json`, next to `server/` and `public/`. */
14
+ const BUNDLE_MANIFEST = "wervt-bundle.json";
15
+ // src/static-server.ts
16
+ /** The source of `server/index.mjs`. */
17
+ function staticServer({ pages, fallback }) {
18
+ return `const pages = ${JSON.stringify(pages)}
19
+ const fallback = ${JSON.stringify(fallback ?? null)}
20
+ const html = (body, status = 200) =>
21
+ new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } })
22
+
23
+ export default {
24
+ fetch(request) {
25
+ let path
26
+ try {
27
+ path = decodeURIComponent(new URL(request.url).pathname).replace(/^\\/+|\\/+$/g, '')
28
+ } catch {
29
+ return html(pages['404.html'] ?? 'Not found', 404)
30
+ }
31
+ for (const page of path ? [path + '/index.html', path + '.html'] : ['index.html'])
32
+ if (Object.hasOwn(pages, page)) return html(pages[page])
33
+ const wantsHtml = (request.headers.get('accept') ?? '').includes('text/html')
34
+ if (wantsHtml && fallback && Object.hasOwn(pages, fallback)) return html(pages[fallback])
35
+ return html(pages['404.html'] ?? 'Not found', 404)
36
+ },
37
+ }
38
+ `;
39
+ }
40
+ // src/build.ts
41
+ const ENTRIES = [
42
+ "server.ts",
43
+ "src/server.ts",
44
+ "src/index.ts",
45
+ "server.js",
46
+ "src/index.js"
47
+ ];
48
+ const exists$1 = (path) => stat(path).then(() => true, () => false);
49
+ async function walk$1(dir) {
50
+ const entries = await readdir(dir, { withFileTypes: true });
51
+ return (await Promise.all(entries.map(async (e) => e.isDirectory() ? walk$1(join(dir, e.name)) : [join(dir, e.name)]))).flat();
52
+ }
53
+ /** The app's `wervt` settings from its package.json. */
54
+ async function readBuildConfig(appDir) {
55
+ return JSON.parse(await readFile(join(appDir, "package.json"), "utf8").catch(() => "{}")).wervt ?? {};
56
+ }
57
+ async function build$1(appDir, options = {}) {
58
+ const config = {
59
+ ...await readBuildConfig(appDir),
60
+ ...definedOnly(options)
61
+ };
62
+ const out = join(appDir, options.out ?? ".output");
63
+ await rm(out, {
64
+ recursive: true,
65
+ force: true
66
+ });
67
+ await mkdir(join(out, "server"), { recursive: true });
68
+ let kind;
69
+ if (config.static) {
70
+ kind = "static";
71
+ await buildStatic(join(appDir, config.static), out, config.fallback);
72
+ } else {
73
+ kind = "server";
74
+ const entry = config.entry ?? await findEntry(appDir);
75
+ if (!entry) throw new Error(`No server entry. Add one of ${ENTRIES.join(", ")}, or set "wervt": { "entry" } in package.json (or "static" for a static site).`);
76
+ await bundleServer(join(appDir, entry), join(out, "server/index.mjs"));
77
+ const publicDir = join(appDir, config.public ?? "public");
78
+ if (await exists$1(publicDir)) await cp(publicDir, join(out, "public"), { recursive: true });
79
+ }
80
+ const manifest = bundleManifest(config, kind);
81
+ await writeFile(join(out, BUNDLE_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
82
+ return {
83
+ kind,
84
+ out,
85
+ manifest
86
+ };
87
+ }
88
+ async function findEntry(appDir) {
89
+ for (const entry of ENTRIES) if (await exists$1(join(appDir, entry))) return entry;
90
+ }
91
+ function bundleManifest(config, kind) {
92
+ const manifest = {
93
+ version: 2,
94
+ framework: config.framework ?? (kind === "static" ? "static" : "fetch")
95
+ };
96
+ if (config.database !== void 0) manifest.database = config.database;
97
+ if (config.immutable || config.noCache) manifest.public = {
98
+ immutable: config.immutable,
99
+ noCache: config.noCache
100
+ };
101
+ if (config.tasks || config.schedules) manifest.tasks = {
102
+ tasks: config.tasks ?? [],
103
+ schedules: config.schedules ?? [],
104
+ ...config.timezone ? { timezone: config.timezone } : {}
105
+ };
106
+ if (config.shapes) manifest.shapes = config.shapes;
107
+ if (config.preview) manifest.preview = config.preview;
108
+ if (config.brand) manifest.brand = config.brand;
109
+ return manifest;
110
+ }
111
+ async function buildStatic(siteDir, out, fallback = "index.html") {
112
+ if (!await exists$1(siteDir)) throw new Error(`No ${siteDir} to deploy. Build the site first.`);
113
+ await cp(siteDir, join(out, "public"), { recursive: true });
114
+ const pages = {};
115
+ for (const file of await walk$1(siteDir)) if (file.endsWith(".html")) pages[relative(siteDir, file).split(sep).join("/")] = await readFile(file, "utf8");
116
+ await writeFile(join(out, "server/index.mjs"), staticServer({
117
+ pages,
118
+ fallback: fallback || void 0
119
+ }));
120
+ }
121
+ const builtins = new Set(builtinModules);
122
+ /**
123
+ * One ES module for Supabase Edge Runtime (Deno): every dependency inlined, Node built-ins
124
+ * imported with `node:` (Deno rejects bare `crypto`), and the Node globals Deno lacks injected.
125
+ */
126
+ async function bundleServer(entry, file) {
127
+ const { build: rolldown } = await import("rolldown");
128
+ await rolldown({
129
+ input: entry,
130
+ platform: "node",
131
+ logLevel: "warn",
132
+ plugins: [{
133
+ name: "wervt:node-prefix",
134
+ resolveId(id) {
135
+ const bare = id.startsWith("node:") ? id.slice(5) : id;
136
+ if (builtins.has(bare) || builtins.has(bare.split("/")[0])) return {
137
+ id: `node:${bare}`,
138
+ external: true
139
+ };
140
+ return null;
141
+ }
142
+ }],
143
+ transform: { inject: {
144
+ Buffer: ["node:buffer", "Buffer"],
145
+ setImmediate: ["node:timers", "setImmediate"],
146
+ clearImmediate: ["node:timers", "clearImmediate"]
147
+ } },
148
+ output: {
149
+ file,
150
+ format: "esm",
151
+ codeSplitting: false
152
+ }
153
+ });
154
+ }
155
+ function definedOnly(value) {
156
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
157
+ }
11
158
  // src/bundle.ts
12
159
  /** Files under `dir`. Follows symlinks: Nitro can leave traced dependencies in
13
160
  * server/node_modules as links into the local pnpm store, which won't exist on the server. */
@@ -23,14 +170,18 @@ async function gzip(data) {
23
170
  return new Uint8Array(await new Response(stream).arrayBuffer());
24
171
  }
25
172
  const exists = (path) => stat(path).then(() => true, () => false);
26
- /** Packs `.output/server`, `.output/public` and `drizzle/` into the bundle the control plane expects. */
27
- async function createBundle(appDir) {
173
+ /**
174
+ * Packs the build output's `server/`, `public/` and `wervt-bundle.json` (in `out`, `.output` by
175
+ * default) and the app's `drizzle/` into the bundle the control plane expects.
176
+ */
177
+ async function createBundle(appDir, out = ".output") {
178
+ const outDir = join(appDir, out);
28
179
  const roots = [
29
- [join(appDir, ".output/server"), "server"],
30
- [join(appDir, ".output/public"), "public"],
180
+ [join(outDir, "server"), "server"],
181
+ [join(outDir, "public"), "public"],
31
182
  [join(appDir, "drizzle"), "drizzle"]
32
183
  ];
33
- if (!await exists(roots[0][0])) throw new Error("No .output/server. Build the app first.");
184
+ if (!await exists(roots[0][0])) throw new Error(`No ${join(out, "server")}. Build the app first.`);
34
185
  const files = [];
35
186
  for (const [dir, prefix] of roots) {
36
187
  if (!await exists(dir)) continue;
@@ -46,6 +197,17 @@ async function createBundle(appDir) {
46
197
  });
47
198
  }
48
199
  }
200
+ const manifest = join(outDir, BUNDLE_MANIFEST);
201
+ if (await exists(manifest)) {
202
+ const data = await readFile(manifest);
203
+ files.push({
204
+ header: {
205
+ name: BUNDLE_MANIFEST,
206
+ size: data.byteLength
207
+ },
208
+ body: data
209
+ });
210
+ }
49
211
  return {
50
212
  data: await gzip(await packTar(files)),
51
213
  files: files.length
@@ -125,9 +287,40 @@ function auditSummary(entry) {
125
287
  case "client.link":
126
288
  case "client.delete": return text(d.name) || text(d.clientId);
127
289
  case "app.delete": return `database ${text(d.database)}`;
290
+ case "domains.add":
291
+ case "domains.remove":
292
+ case "domains.primary": return text(d.domain);
128
293
  default: return entry.account ?? "";
129
294
  }
130
295
  }
296
+ /** A saved query's alert in a few words: `alert at 5 in 5m, firing (7)`. */
297
+ function alertSummary(query) {
298
+ const { alert, state } = query;
299
+ if (!alert) return "no alert";
300
+ const rule = `alert at ${alert.threshold} in ${alert.window}`;
301
+ if (!alert.enabled) return `${rule}, off`;
302
+ if (state?.error) return `${rule}, failing: ${state.error.split("\n")[0]}`;
303
+ if (state?.firing) return `${rule}, firing (${state.lastCount ?? "?"})`;
304
+ return state?.lastFiredAt ? `${rule}, last fired ${state.lastFiredAt.slice(0, 16)}` : rule;
305
+ }
306
+ /** `GET /api/x 500 12ms 3 entries, 1 error` for a request. */
307
+ function requestSummary(group) {
308
+ const what = group.method && group.path ? [
309
+ group.method,
310
+ group.path,
311
+ group.status
312
+ ].filter(Boolean).join(" ") : group.message ?? "(no request event)";
313
+ const counts = [
314
+ `${group.entries} ${group.entries === 1 ? "entry" : "entries"}`,
315
+ group.errors && `${group.errors} ${group.errors === 1 ? "error" : "errors"}`,
316
+ group.warnings && `${group.warnings} ${group.warnings === 1 ? "warning" : "warnings"}`
317
+ ].filter(Boolean);
318
+ return [
319
+ what,
320
+ group.duration,
321
+ counts.join(", ")
322
+ ].filter(Boolean).join(" ");
323
+ }
131
324
  // src/source.ts
132
325
  async function git(dir, ...args) {
133
326
  try {
@@ -256,6 +449,10 @@ const deploy = defineCommand({
256
449
  default: true,
257
450
  description: "Build before deploying"
258
451
  },
452
+ out: {
453
+ type: "string",
454
+ description: "Build output to pack (default: .output)"
455
+ },
259
456
  check: {
260
457
  type: "string",
261
458
  description: "Pages to request once the release started, a 5xx fails (default: /). Shape routes are checked too; --check= turns all checks off"
@@ -277,14 +474,18 @@ const deploy = defineCommand({
277
474
  if ((await x(pm, ["run", "build"], {
278
475
  nodeOptions: {
279
476
  cwd: dir,
280
- stdio: "inherit"
477
+ stdio: "inherit",
478
+ env: {
479
+ ...process.env,
480
+ WERVT_APP: name
481
+ }
281
482
  },
282
483
  throwOnError: false
283
484
  })).exitCode !== 0) throw new Error("Build failed");
284
485
  }
285
486
  const spinner = progress();
286
487
  spinner.start("Packing");
287
- const bundle = await createBundle(dir);
488
+ const bundle = await createBundle(dir, args.out);
288
489
  spinner.stop(`Packed ${bundle.files} files (${(bundle.data.byteLength / 1024).toFixed(0)} kB)`);
289
490
  spinner.start("Uploading");
290
491
  const source = await deploySource(dir);
@@ -596,6 +797,63 @@ const access = defineCommand({
596
797
  }
597
798
  }
598
799
  });
800
+ const domains = defineCommand({
801
+ meta: {
802
+ name: "domains",
803
+ description: "Show or change an app's own domains (e.g. example.com); <app>.<base domain> then forwards to the primary one"
804
+ },
805
+ args: {
806
+ app: {
807
+ type: "positional",
808
+ required: true,
809
+ description: "App name"
810
+ },
811
+ add: {
812
+ type: "string",
813
+ description: "Add a domain; it serves the app once its DNS points here"
814
+ },
815
+ remove: {
816
+ type: "string",
817
+ description: "Remove a domain"
818
+ },
819
+ primary: {
820
+ type: "string",
821
+ description: "Make a verified domain the app's main address"
822
+ },
823
+ verify: {
824
+ type: "string",
825
+ description: "Check a pending domain's DNS now"
826
+ },
827
+ ...connection
828
+ },
829
+ async run({ args }) {
830
+ const changes = [
831
+ "add",
832
+ "remove",
833
+ "primary",
834
+ "verify"
835
+ ].filter((key) => args[key]);
836
+ if (changes.length > 1) throw new Error("Pass one of --add, --remove, --primary or --verify");
837
+ const change = changes[0];
838
+ const result = await (change ? await api(`/v1/apps/${args.app}/domains`, args, {
839
+ method: "PATCH",
840
+ headers: { "content-type": "application/json" },
841
+ body: JSON.stringify({ [change]: args[change] })
842
+ }) : await api(`/v1/apps/${args.app}/domains`, args)).json();
843
+ if (!result.configured) console.log("This server doesn't support custom domains.");
844
+ if (!result.domains.length) return console.log(`${args.app} has no domains of its own.`);
845
+ for (const d of result.domains) {
846
+ const label = d.status === "active" ? styleText("green", d.primary ? "active, primary" : "active") : styleText("yellow", "pending");
847
+ console.log(`${d.domain} ${label}${d.error ? ` ${styleText("dim", d.error)}` : ""}`);
848
+ }
849
+ const pending = result.domains.filter((d) => d.status === "pending");
850
+ if (!pending.length || !result.target) return;
851
+ console.log(`\nPoint each pending domain at ${result.target}: a CNAME (subdomains), or an A record with its addresses (a bare domain).`);
852
+ console.log("Behind a proxy of your own (Cloudflare), add a TXT record instead, the proxy has to send the traffic on:");
853
+ for (const d of pending) console.log(` ${d.txt.name} TXT ${d.txt.value}`);
854
+ console.log("Pending domains are checked every few minutes, or now with --verify <domain>.");
855
+ }
856
+ });
599
857
  const members = defineCommand({
600
858
  meta: {
601
859
  name: "members",
@@ -728,9 +986,36 @@ const accounts = defineCommand({
728
986
  type: "string",
729
987
  description: "Space all releases of their apps may take, e.g. 2GB. \"none\", \"default\""
730
988
  },
989
+ into: {
990
+ type: "string",
991
+ 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"
992
+ },
731
993
  ...connection
732
994
  },
733
995
  async run({ args }) {
996
+ if (args.email && args.into) {
997
+ const result = await (await api("/v1/accounts/merge", args, {
998
+ method: "POST",
999
+ headers: { "content-type": "application/json" },
1000
+ body: JSON.stringify({
1001
+ from: args.email,
1002
+ into: args.into
1003
+ })
1004
+ })).json();
1005
+ console.log(`${result.from.email} merged into ${result.into.email}`);
1006
+ console.log(` logins moved: ${result.logins.join(", ") || "none"}`);
1007
+ if (result.owners.length) console.log(` owner or member of: ${result.owners.join(", ")}`);
1008
+ if (result.access.length) console.log(` allowed in: ${result.access.join(", ")}`);
1009
+ const payload = JSON.stringify({
1010
+ from: result.from,
1011
+ into: result.into
1012
+ });
1013
+ for (const [app, run] of Object.entries(result.apps)) {
1014
+ console.log(` ${app}: ${run.status}${run.error ? ` (${run.error})` : ""}`);
1015
+ if (run.status !== "ok") console.log(styleText("dim", ` retry: wervt tasks ${app} --run wervt:account-merged --payload '${payload}'`));
1016
+ }
1017
+ return;
1018
+ }
734
1019
  if (args.email) {
735
1020
  const change = {};
736
1021
  if (args.apps) change.apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
@@ -1172,6 +1457,14 @@ const logs = defineCommand({
1172
1457
  alias: "f",
1173
1458
  description: "Keep printing new entries"
1174
1459
  },
1460
+ requests: {
1461
+ type: "boolean",
1462
+ description: "One line per request (requestId): what it was and how much it logged"
1463
+ },
1464
+ saved: {
1465
+ type: "string",
1466
+ description: "Use a saved query by name (see `wervt queries`)"
1467
+ },
1175
1468
  json: {
1176
1469
  type: "boolean",
1177
1470
  description: "One JSON object per line, with every field"
@@ -1180,7 +1473,31 @@ const logs = defineCommand({
1180
1473
  },
1181
1474
  async run({ args }) {
1182
1475
  const params = new URLSearchParams();
1183
- if (args.query) params.set("query", args.query);
1476
+ if (args.saved) {
1477
+ if (args.query) throw new Error("Give either a query or --saved, not both");
1478
+ const saved = await findSavedQuery(args.app, args.saved, args);
1479
+ params.set("query", saved.query);
1480
+ if (saved.levels?.length) params.set("levels", saved.levels.join(","));
1481
+ } else if (args.query) params.set("query", args.query);
1482
+ if (args.requests) {
1483
+ if (args.follow) throw new Error("--requests and --follow do not go together");
1484
+ params.set("start", args.since);
1485
+ if (args.until) params.set("end", args.until);
1486
+ params.set("limit", args.limit);
1487
+ const groups = await (await api(`/v1/apps/${args.app}/logs/requests?${params.toString()}`, args)).json();
1488
+ if (!groups.length && !args.json) return console.error(`No requests with a requestId in the last ${args.since}.`);
1489
+ for (const group of groups.reverse()) {
1490
+ if (args.json) {
1491
+ console.log(JSON.stringify(group));
1492
+ continue;
1493
+ }
1494
+ const time = new Date(group.last).toISOString().slice(11, 23);
1495
+ const color = group.errors ? "red" : group.warnings ? "yellow" : "white";
1496
+ console.log(`${styleText("dim", time)} ${styleText(color, requestSummary(group))} ${styleText("dim", group.requestId)}`);
1497
+ }
1498
+ if (!args.json) console.error(styleText("dim", `\nOne request: wervt logs ${args.app} 'requestId:=<id>' --since ${args.since}`));
1499
+ return;
1500
+ }
1184
1501
  if (args.follow) {
1185
1502
  params.set("since", args.since);
1186
1503
  const res = await api(`/v1/apps/${args.app}/logs/tail?${params.toString()}`, args);
@@ -1200,6 +1517,111 @@ const logs = defineCommand({
1200
1517
  for (const entry of entries.reverse()) printLog(entry, Boolean(args.json));
1201
1518
  }
1202
1519
  });
1520
+ /** `/v1/apps/<app>/logs/queries`, or across apps (admins) without an app. */
1521
+ const queriesPath = (app) => app ? `/v1/apps/${app}/logs/queries` : "/v1/logs/queries";
1522
+ async function findSavedQuery(app, name, flags) {
1523
+ const list = await (await api(queriesPath(app), flags)).json();
1524
+ const found = list.find((query) => query.id === name || query.name.toLowerCase() === name.toLowerCase());
1525
+ if (!found) throw new Error(`No saved query "${name}"${list.length ? `. Saved: ${list.map((q) => q.name).join(", ")}` : ""}`);
1526
+ return found;
1527
+ }
1528
+ const queries = defineCommand({
1529
+ meta: {
1530
+ name: "queries",
1531
+ description: "List, save or delete saved log queries and the alerts on them"
1532
+ },
1533
+ args: {
1534
+ app: {
1535
+ type: "positional",
1536
+ required: false,
1537
+ description: "App name (default: queries across all apps, admins only)"
1538
+ },
1539
+ save: {
1540
+ type: "string",
1541
+ description: "Save a query under this name, or change the one that has it"
1542
+ },
1543
+ query: {
1544
+ type: "string",
1545
+ description: "LogsQL for --save, e.g. 'level:error'"
1546
+ },
1547
+ levels: {
1548
+ type: "string",
1549
+ description: "Only these levels for --save, e.g. error,warn"
1550
+ },
1551
+ alert: {
1552
+ type: "string",
1553
+ description: "on or off: mail the app owner and members when the query matches"
1554
+ },
1555
+ threshold: {
1556
+ type: "string",
1557
+ description: "Alert when at least this many entries match"
1558
+ },
1559
+ window: {
1560
+ type: "string",
1561
+ description: "Alert window, e.g. 5m (1m to 24h)"
1562
+ },
1563
+ cooldown: {
1564
+ type: "string",
1565
+ description: "Quiet time after an alert, e.g. 1h (5m to 7d)"
1566
+ },
1567
+ delete: {
1568
+ type: "string",
1569
+ description: "Delete the saved query with this name"
1570
+ },
1571
+ json: {
1572
+ type: "boolean",
1573
+ description: "Print JSON"
1574
+ },
1575
+ ...connection
1576
+ },
1577
+ async run({ args }) {
1578
+ const path = queriesPath(args.app);
1579
+ const json = { "content-type": "application/json" };
1580
+ if (args.delete) {
1581
+ const found = await findSavedQuery(args.app, args.delete, args);
1582
+ await api(`${path}/${found.id}`, args, { method: "DELETE" });
1583
+ return console.log(`Deleted ${found.name}`);
1584
+ }
1585
+ if (args.save) {
1586
+ if (args.alert !== void 0 && !["on", "off"].includes(args.alert)) throw new Error("--alert is on or off");
1587
+ const alertFields = {
1588
+ ...args.threshold ? { threshold: Number(args.threshold) } : {},
1589
+ ...args.window ? { window: args.window } : {},
1590
+ ...args.cooldown ? { cooldown: args.cooldown } : {}
1591
+ };
1592
+ const alert = args.alert === "off" ? null : args.alert === "on" || Object.keys(alertFields).length ? {
1593
+ enabled: true,
1594
+ ...alertFields
1595
+ } : void 0;
1596
+ const body = {
1597
+ name: args.save,
1598
+ ...args.query !== void 0 ? { query: args.query } : {},
1599
+ ...args.levels !== void 0 ? { levels: args.levels } : {},
1600
+ ...alert !== void 0 ? { alert } : {}
1601
+ };
1602
+ const existing = (await (await api(path, args)).json()).find((q) => q.name.toLowerCase() === args.save.toLowerCase());
1603
+ const saved = await (await api(existing ? `${path}/${existing.id}` : path, args, {
1604
+ method: existing ? "PATCH" : "POST",
1605
+ headers: json,
1606
+ body: JSON.stringify(body)
1607
+ })).json();
1608
+ if (args.json) return console.log(JSON.stringify(saved));
1609
+ return console.log(`${existing ? "Changed" : "Saved"} ${saved.name}: ${saved.query} ${styleText("dim", alertSummary(saved))}`);
1610
+ }
1611
+ const list = await (await api(path, args)).json();
1612
+ if (args.json) return console.log(JSON.stringify(list));
1613
+ if (!list.length) {
1614
+ const target = args.app ? ` ${args.app}` : "";
1615
+ return console.log(`No saved queries. Save one: wervt queries${target} --save Errors --query 'level:error' --alert on`);
1616
+ }
1617
+ for (const query of list) {
1618
+ const levels = query.levels?.length ? styleText("dim", ` [${query.levels.join(",")}]`) : "";
1619
+ const color = query.state?.error ? "red" : query.alert?.enabled && query.state?.firing ? "yellow" : "dim";
1620
+ console.log(`${styleText("bold", query.name)} ${query.query}${levels} ${styleText(color, alertSummary(query))}`);
1621
+ }
1622
+ if (args.app) console.log(styleText("dim", `\nRun one: wervt logs ${args.app} --saved '${list[0].name}'`));
1623
+ }
1624
+ });
1203
1625
  const SPARKS = "▁▂▃▄▅▆▇█";
1204
1626
  const stats = defineCommand({
1205
1627
  meta: {
@@ -1413,6 +1835,47 @@ const login = defineCommand({
1413
1835
  outro(`Signed in to ${url} (saved to ${file})`);
1414
1836
  }
1415
1837
  });
1838
+ const build = defineCommand({
1839
+ meta: {
1840
+ name: "build",
1841
+ description: "Build a fetch handler (Hono, plain `export default { fetch }`) or a static site into a bundle"
1842
+ },
1843
+ args: {
1844
+ dir: {
1845
+ type: "positional",
1846
+ required: false,
1847
+ description: "App directory (default: .)"
1848
+ },
1849
+ entry: {
1850
+ type: "string",
1851
+ description: "Server module (default: server.ts, src/server.ts, src/index.ts)"
1852
+ },
1853
+ static: {
1854
+ type: "string",
1855
+ description: "Folder of a static site to deploy instead of a server, e.g. dist"
1856
+ },
1857
+ fallback: {
1858
+ type: "string",
1859
+ description: "Static site: page for unknown paths (default: index.html, for SPAs); --fallback= for none"
1860
+ },
1861
+ out: {
1862
+ type: "string",
1863
+ description: "Output directory (default: .output)"
1864
+ }
1865
+ },
1866
+ async run({ args }) {
1867
+ const dir = resolveAppDir(args.dir);
1868
+ const started = performance.now();
1869
+ const result = await build$1(dir, {
1870
+ entry: args.entry,
1871
+ static: args.static,
1872
+ fallback: args.fallback === "" ? false : args.fallback,
1873
+ out: args.out
1874
+ });
1875
+ const ms = Math.round(performance.now() - started);
1876
+ console.log(`Built ${result.kind === "static" ? "a static site" : "the server"} into ${result.out} in ${ms} ms (${result.manifest.framework})`);
1877
+ }
1878
+ });
1416
1879
  runMain(defineCommand({
1417
1880
  meta: {
1418
1881
  name: "wervt",
@@ -1420,14 +1883,17 @@ runMain(defineCommand({
1420
1883
  },
1421
1884
  subCommands: {
1422
1885
  deploy,
1886
+ build,
1423
1887
  apps,
1424
1888
  releases,
1425
1889
  audit,
1426
1890
  logs,
1891
+ queries,
1427
1892
  activate,
1428
1893
  rollback,
1429
1894
  reprovision,
1430
1895
  access,
1896
+ domains,
1431
1897
  members,
1432
1898
  accounts,
1433
1899
  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.18",
4
4
  "description": "Deploy and manage apps on wervt.",
5
5
  "keywords": [
6
6
  "agents",
@@ -32,8 +32,12 @@
32
32
  "citty": "^0.2.2",
33
33
  "modern-tar": "^0.8.6",
34
34
  "nypm": "^0.6.12",
35
+ "rolldown": "^1.2.16",
35
36
  "tinyexec": "^1.3.1"
36
37
  },
38
+ "devDependencies": {
39
+ "@wervt/core": "0.0.2"
40
+ },
37
41
  "scripts": {
38
42
  "build": "vp pack"
39
43
  }
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)