@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 +260 -2
- package/package.json +1 -1
- package/skill/SKILL.md +15 -1
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.
|
|
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
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)
|