@wervt/cli 0.2.19 → 0.2.21
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 +1 -1
- package/dist/index.mjs +660 -4
- package/package.json +4 -2
- package/skill/SKILL.md +13 -1
package/README.md
CHANGED
|
@@ -21,4 +21,4 @@ in package.json (any `export default { fetch }` module works too, without `--sta
|
|
|
21
21
|
|
|
22
22
|
Without [Vite+](https://viteplus.dev), use `npx @wervt/cli` and `pnpm exec wervt`. `wervt --help`
|
|
23
23
|
lists every command: `apps`, `releases`, `audit`, `rollback`, `activate`, `env`, `tokens`, `token`, `members`,
|
|
24
|
-
`tasks`, `stats`, `delete`, `whoami`, … Docs: [wervt.app](https://wervt.app/getting-started/quickstart).
|
|
24
|
+
`maintenance`, `updates`, `tasks`, `stats`, `delete`, `whoami`, … Docs: [wervt.app](https://wervt.app/getting-started/quickstart).
|
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import { builtinModules } from "node:module";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { appendFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { homedir, hostname } from "node:os";
|
|
5
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { appendFile, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
5
|
+
import { delimiter, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { styleText } from "node:util";
|
|
7
7
|
import * as p from "@clack/prompts";
|
|
8
8
|
import { defineCommand, runMain } from "citty";
|
|
9
9
|
import { detectPackageManager } from "nypm";
|
|
10
10
|
import { x } from "tinyexec";
|
|
11
11
|
import { packTar } from "modern-tar";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
// src/admin-flag.ts
|
|
15
|
+
const OTHER_CHANGES = [
|
|
16
|
+
"into",
|
|
17
|
+
"apps",
|
|
18
|
+
"database",
|
|
19
|
+
"storage",
|
|
20
|
+
"releases"
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* What `wervt accounts` should do to the admin role, if anything. Refuses combinations instead of
|
|
24
|
+
* dropping a flag: someone who typed `--apps 5` next to `--admin` would otherwise think it applied.
|
|
25
|
+
*/
|
|
26
|
+
function adminFlag(args) {
|
|
27
|
+
const revoke = args["revoke-admin"];
|
|
28
|
+
if (!args.admin && !revoke) return void 0;
|
|
29
|
+
if (args.admin && revoke) throw new Error("Pass --admin or --revoke-admin, not both");
|
|
30
|
+
if (!args.email) throw new Error("Name the account: wervt accounts <email> --admin");
|
|
31
|
+
const other = OTHER_CHANGES.find((flag) => args[flag]);
|
|
32
|
+
if (other) throw new Error(`Change admin on its own, without --${other}`);
|
|
33
|
+
return !!args.admin;
|
|
34
|
+
}
|
|
12
35
|
// ../core/src/manifest.ts
|
|
13
36
|
/** The bundle's `wervt-bundle.json`, next to `server/` and `public/`. */
|
|
14
37
|
const BUNDLE_MANIFEST = "wervt-bundle.json";
|
|
@@ -235,6 +258,80 @@ async function saveConfig(config) {
|
|
|
235
258
|
await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
236
259
|
return file;
|
|
237
260
|
}
|
|
261
|
+
// src/dev.ts
|
|
262
|
+
/** What comes after `--`, untouched, so the command keeps its own flags. */
|
|
263
|
+
const passthrough = (rawArgs) => {
|
|
264
|
+
const at = rawArgs.indexOf("--");
|
|
265
|
+
return at === -1 ? [] : rawArgs.slice(at + 1);
|
|
266
|
+
};
|
|
267
|
+
/** Runs the app's command against its local database until the command exits. */
|
|
268
|
+
async function dev$1(options) {
|
|
269
|
+
const { resetDevDatabase, resolveAppName, setupDev } = await import("@wervt/platform/dev");
|
|
270
|
+
const rootDir = resolve(options.dir);
|
|
271
|
+
const app = await resolveAppName(rootDir, options.name);
|
|
272
|
+
const [command, ...commandArgs] = options.command;
|
|
273
|
+
const envFile = join(rootDir, ".env");
|
|
274
|
+
if (existsSync(envFile)) process.loadEnvFile(envFile);
|
|
275
|
+
if (options.reset) p.log.success(`Dropped \`${await resetDevDatabase(app)}\``);
|
|
276
|
+
if (!command) return;
|
|
277
|
+
const { database } = await readBuildConfig(rootDir);
|
|
278
|
+
let child;
|
|
279
|
+
let closers = [];
|
|
280
|
+
const stopChild = async () => {
|
|
281
|
+
const running = child;
|
|
282
|
+
child = void 0;
|
|
283
|
+
if (!running || running.exitCode !== null) return;
|
|
284
|
+
const exited = new Promise((done) => running.once("exit", done));
|
|
285
|
+
running.kill("SIGTERM");
|
|
286
|
+
await exited;
|
|
287
|
+
};
|
|
288
|
+
const stopSetup = () => {
|
|
289
|
+
for (const close of closers) close();
|
|
290
|
+
closers = [];
|
|
291
|
+
};
|
|
292
|
+
const start = async () => {
|
|
293
|
+
await setupDev({
|
|
294
|
+
rootDir,
|
|
295
|
+
onClose: (fn) => closers.push(fn),
|
|
296
|
+
restart: () => {
|
|
297
|
+
stopSetup();
|
|
298
|
+
stopChild().then(start);
|
|
299
|
+
},
|
|
300
|
+
logger: {
|
|
301
|
+
info: (message) => p.log.info(message),
|
|
302
|
+
success: (message) => p.log.success(message),
|
|
303
|
+
warn: (message, ...args) => p.log.warn([message, ...args].join(" "))
|
|
304
|
+
}
|
|
305
|
+
}, app, {
|
|
306
|
+
seed: false,
|
|
307
|
+
database
|
|
308
|
+
}, { storage: options.storage });
|
|
309
|
+
const started = spawn(command, commandArgs, {
|
|
310
|
+
cwd: rootDir,
|
|
311
|
+
stdio: "inherit",
|
|
312
|
+
env: {
|
|
313
|
+
...process.env,
|
|
314
|
+
PATH: [join(rootDir, "node_modules/.bin"), process.env.PATH].join(delimiter)
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
child = started;
|
|
318
|
+
const finish = (code) => {
|
|
319
|
+
if (child !== started) return;
|
|
320
|
+
stopSetup();
|
|
321
|
+
process.exit(code);
|
|
322
|
+
};
|
|
323
|
+
started.once("error", (e) => {
|
|
324
|
+
p.log.error(`Could not run ${command}: ${e.message}`);
|
|
325
|
+
finish(1);
|
|
326
|
+
});
|
|
327
|
+
started.once("exit", (code, signal) => finish(code ?? (signal ? 0 : 1)));
|
|
328
|
+
};
|
|
329
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
|
|
330
|
+
stopSetup();
|
|
331
|
+
stopChild().then(() => process.exit(0));
|
|
332
|
+
});
|
|
333
|
+
await start();
|
|
334
|
+
}
|
|
238
335
|
// src/format.ts
|
|
239
336
|
function formatBytes(bytes) {
|
|
240
337
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -251,6 +348,16 @@ function formatBytes(bytes) {
|
|
|
251
348
|
}
|
|
252
349
|
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
253
350
|
}
|
|
351
|
+
/** Minutes in a duration like `30m`, `2h` or `1d`; a bare number means minutes. */
|
|
352
|
+
function parseMinutes(value) {
|
|
353
|
+
const match = /^(\d+)(m|h|d)?$/.exec(value.trim());
|
|
354
|
+
if (!match) throw new Error(`Can't read the duration "${value}", try 30m, 2h or 1d`);
|
|
355
|
+
return Number(match[1]) * {
|
|
356
|
+
m: 1,
|
|
357
|
+
h: 60,
|
|
358
|
+
d: 1440
|
|
359
|
+
}[match[2] ?? "m"];
|
|
360
|
+
}
|
|
254
361
|
const list = (value) => Array.isArray(value) ? value.map(String).join(", ") : "";
|
|
255
362
|
const text = (value) => typeof value === "string" ? value : "";
|
|
256
363
|
/** What an audit entry changed, in a few words: `env.update` → `set API_URL, TOKEN`. */
|
|
@@ -276,11 +383,26 @@ function auditSummary(entry) {
|
|
|
276
383
|
list(d.add) && `add ${list(d.add)}`,
|
|
277
384
|
list(d.remove) && `remove ${list(d.remove)}`
|
|
278
385
|
].filter(Boolean).join("; ");
|
|
386
|
+
case "updates.publish":
|
|
387
|
+
case "updates.rollback": return [
|
|
388
|
+
text(d.channel),
|
|
389
|
+
text(d.platform),
|
|
390
|
+
text(d.message),
|
|
391
|
+
d.embedded ? "to the embedded update" : "",
|
|
392
|
+
text(d.republishOf) && `again from ${text(d.republishOf)}`
|
|
393
|
+
].filter(Boolean).join("; ");
|
|
394
|
+
case "maintenance.update": return [
|
|
395
|
+
text(d.state),
|
|
396
|
+
text(d.from) && `from ${text(d.from)}`,
|
|
397
|
+
text(d.until) && `until ${text(d.until)}`,
|
|
398
|
+
list(d.page) && `page ${list(d.page)}`
|
|
399
|
+
].filter(Boolean).join("; ");
|
|
279
400
|
case "owners.update": return [
|
|
280
401
|
text(d.owner) && `owner ${text(d.owner)}`,
|
|
281
402
|
list(d.added) && `added ${list(d.added)}`,
|
|
282
403
|
list(d.removed) && `removed ${list(d.removed)}`
|
|
283
404
|
].filter(Boolean).join("; ");
|
|
405
|
+
case "db.query": return `${d.write === true ? "write" : "read"} ${text(d.command)} ${Number(d.count)}`;
|
|
284
406
|
case "token.create":
|
|
285
407
|
case "token.revoke": return text(d.name) || text(d.id);
|
|
286
408
|
case "client.create":
|
|
@@ -321,6 +443,17 @@ function requestSummary(group) {
|
|
|
321
443
|
counts.join(", ")
|
|
322
444
|
].filter(Boolean).join(" ");
|
|
323
445
|
}
|
|
446
|
+
/** A `wervt db sql` result as an aligned table; a null is shown as `NULL`, newlines as spaces. */
|
|
447
|
+
function sqlTable(columns, rows) {
|
|
448
|
+
const shown = rows.map((row) => row.map((cell) => cell === null ? "NULL" : cell.replace(/\s*\n\s*/g, " ")));
|
|
449
|
+
const widths = columns.map((column, i) => Math.max(column.length, ...shown.map((row) => row[i].length)));
|
|
450
|
+
const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
|
|
451
|
+
return [
|
|
452
|
+
line(columns),
|
|
453
|
+
line(widths.map((w) => "-".repeat(w))),
|
|
454
|
+
...shown.map(line)
|
|
455
|
+
].join("\n");
|
|
456
|
+
}
|
|
324
457
|
// src/source.ts
|
|
325
458
|
async function git(dir, ...args) {
|
|
326
459
|
try {
|
|
@@ -366,6 +499,81 @@ async function deploySource(dir) {
|
|
|
366
499
|
actor: await git(dir, "config", "user.name")
|
|
367
500
|
};
|
|
368
501
|
}
|
|
502
|
+
// src/updates.ts
|
|
503
|
+
const UPDATE_PLATFORMS = ["ios", "android"];
|
|
504
|
+
const CONTENT_TYPES = {
|
|
505
|
+
aac: "audio/aac",
|
|
506
|
+
bmp: "image/bmp",
|
|
507
|
+
css: "text/css",
|
|
508
|
+
gif: "image/gif",
|
|
509
|
+
heic: "image/heic",
|
|
510
|
+
html: "text/html",
|
|
511
|
+
jpeg: "image/jpeg",
|
|
512
|
+
jpg: "image/jpeg",
|
|
513
|
+
json: "application/json",
|
|
514
|
+
m4a: "audio/mp4",
|
|
515
|
+
m4v: "video/mp4",
|
|
516
|
+
mov: "video/quicktime",
|
|
517
|
+
mp3: "audio/mpeg",
|
|
518
|
+
mp4: "video/mp4",
|
|
519
|
+
oga: "audio/ogg",
|
|
520
|
+
ogg: "audio/ogg",
|
|
521
|
+
otf: "font/otf",
|
|
522
|
+
pdf: "application/pdf",
|
|
523
|
+
png: "image/png",
|
|
524
|
+
svg: "image/svg+xml",
|
|
525
|
+
ttf: "font/ttf",
|
|
526
|
+
txt: "text/plain",
|
|
527
|
+
wav: "audio/wav",
|
|
528
|
+
webm: "video/webm",
|
|
529
|
+
webp: "image/webp",
|
|
530
|
+
woff: "font/woff",
|
|
531
|
+
woff2: "font/woff2",
|
|
532
|
+
xml: "application/xml"
|
|
533
|
+
};
|
|
534
|
+
async function describe(out, path, type, extension) {
|
|
535
|
+
const bytes = await readFile(join(out, path));
|
|
536
|
+
const file = {
|
|
537
|
+
hash: createHash("sha256").update(bytes).digest("base64url"),
|
|
538
|
+
key: createHash("md5").update(bytes).digest("hex"),
|
|
539
|
+
contentType: type,
|
|
540
|
+
size: bytes.length,
|
|
541
|
+
path: join(out, path)
|
|
542
|
+
};
|
|
543
|
+
return extension ? {
|
|
544
|
+
...file,
|
|
545
|
+
fileExtension: extension
|
|
546
|
+
} : file;
|
|
547
|
+
}
|
|
548
|
+
/** The bundle and assets `expo export` wrote to `out` for one platform. */
|
|
549
|
+
async function readExport(out, platform) {
|
|
550
|
+
const exported = JSON.parse(await readFile(join(out, "metadata.json"), "utf8")).fileMetadata[platform];
|
|
551
|
+
if (!exported) throw new Error(`The export has nothing for ${platform}`);
|
|
552
|
+
return {
|
|
553
|
+
launchAsset: await describe(out, exported.bundle, "application/javascript"),
|
|
554
|
+
assets: await Promise.all(exported.assets.map(({ path, ext }) => describe(out, path, CONTENT_TYPES[ext.toLowerCase()] ?? "application/octet-stream", ext ? `.${ext}` : void 0)))
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/** Each file once, also when both platforms ship it. */
|
|
558
|
+
function uniqueFiles(files) {
|
|
559
|
+
return [...new Map(files.map((file) => [file.hash, file])).values()];
|
|
560
|
+
}
|
|
561
|
+
/** The file as the control plane records it, without where it lies on this machine. */
|
|
562
|
+
function withoutPath(file) {
|
|
563
|
+
return {
|
|
564
|
+
...file,
|
|
565
|
+
path: void 0
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** The name `updates.codeSigningCertificate` points at unless told otherwise. */
|
|
569
|
+
const DEFAULT_CERTIFICATE = "certs/wervt-updates.pem";
|
|
570
|
+
/** The first `limit` at a time, in order; rejects with the first failure. */
|
|
571
|
+
async function forEachLimit(items, limit, run) {
|
|
572
|
+
let next = 0;
|
|
573
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
574
|
+
while (next < items.length) await run(items[next++]);
|
|
575
|
+
}));
|
|
576
|
+
}
|
|
369
577
|
// src/index.ts
|
|
370
578
|
const interactive = process.stdout.isTTY === true && !process.env.CI;
|
|
371
579
|
function progress() {
|
|
@@ -503,6 +711,7 @@ const deploy = defineCommand({
|
|
|
503
711
|
const query = new URLSearchParams();
|
|
504
712
|
if (args.check !== void 0) query.set("check", args.check);
|
|
505
713
|
if (args["rollback-on-fail"]) query.set("rollbackOnFail", "1");
|
|
714
|
+
if (args.maintenance) query.set("maintenance", "1");
|
|
506
715
|
const res = await api(`/v1/apps/${name}/deploys${query.size ? `?${query}` : ""}`, args, {
|
|
507
716
|
method: "POST",
|
|
508
717
|
headers: {
|
|
@@ -513,6 +722,7 @@ const deploy = defineCommand({
|
|
|
513
722
|
});
|
|
514
723
|
const labels = {
|
|
515
724
|
extract: "Extracting",
|
|
725
|
+
maintenance: "Turning on maintenance mode",
|
|
516
726
|
migrate: "Migrating database",
|
|
517
727
|
storage: "Setting up storage",
|
|
518
728
|
activate: "Activating",
|
|
@@ -745,6 +955,63 @@ const reprovision = defineCommand({
|
|
|
745
955
|
if (results.some((r) => r.error)) process.exitCode = 1;
|
|
746
956
|
}
|
|
747
957
|
});
|
|
958
|
+
const db = defineCommand({
|
|
959
|
+
meta: {
|
|
960
|
+
name: "db",
|
|
961
|
+
description: "Work with an app's database"
|
|
962
|
+
},
|
|
963
|
+
subCommands: { sql: defineCommand({
|
|
964
|
+
meta: {
|
|
965
|
+
name: "sql",
|
|
966
|
+
description: "Run one SQL statement as the app's role: read-only unless you pass --write"
|
|
967
|
+
},
|
|
968
|
+
args: {
|
|
969
|
+
app: {
|
|
970
|
+
type: "positional",
|
|
971
|
+
required: true,
|
|
972
|
+
description: "App name"
|
|
973
|
+
},
|
|
974
|
+
statement: {
|
|
975
|
+
type: "positional",
|
|
976
|
+
required: false,
|
|
977
|
+
description: "SQL statement"
|
|
978
|
+
},
|
|
979
|
+
file: {
|
|
980
|
+
type: "string",
|
|
981
|
+
description: "Read the statement from this file"
|
|
982
|
+
},
|
|
983
|
+
write: {
|
|
984
|
+
type: "boolean",
|
|
985
|
+
description: "Allow changes and commit them"
|
|
986
|
+
},
|
|
987
|
+
preview: {
|
|
988
|
+
type: "string",
|
|
989
|
+
description: "Run against this preview, like pr-12"
|
|
990
|
+
},
|
|
991
|
+
json: {
|
|
992
|
+
type: "boolean",
|
|
993
|
+
description: "Print the result as JSON"
|
|
994
|
+
},
|
|
995
|
+
...connection
|
|
996
|
+
},
|
|
997
|
+
async run({ args }) {
|
|
998
|
+
if (!args.statement === !args.file) throw new Error("Pass a statement or --file");
|
|
999
|
+
const sql = args.file ? await readFile(args.file, "utf8") : args.statement;
|
|
1000
|
+
const result = await (await api(`/v1/apps/${previewName(args.app, args.preview)}/db/query`, args, {
|
|
1001
|
+
method: "POST",
|
|
1002
|
+
headers: { "content-type": "application/json" },
|
|
1003
|
+
body: JSON.stringify({
|
|
1004
|
+
sql,
|
|
1005
|
+
write: args.write === true
|
|
1006
|
+
})
|
|
1007
|
+
})).json();
|
|
1008
|
+
if (args.json) return console.log(JSON.stringify(result));
|
|
1009
|
+
if (result.columns.length) console.log(sqlTable(result.columns, result.rows));
|
|
1010
|
+
const limited = result.truncated ? ` (first ${result.rows.length})` : "";
|
|
1011
|
+
console.error(styleText("dim", `${result.command} ${result.count}${limited} in ${result.ms}ms`));
|
|
1012
|
+
}
|
|
1013
|
+
}) }
|
|
1014
|
+
});
|
|
748
1015
|
const toList = (value) => (Array.isArray(value) ? value : value === void 0 ? [] : [value]).flatMap((v) => String(v).split(","));
|
|
749
1016
|
const access = defineCommand({
|
|
750
1017
|
meta: {
|
|
@@ -808,6 +1075,87 @@ const access = defineCommand({
|
|
|
808
1075
|
}
|
|
809
1076
|
}
|
|
810
1077
|
});
|
|
1078
|
+
const maintenance = defineCommand({
|
|
1079
|
+
meta: {
|
|
1080
|
+
name: "maintenance",
|
|
1081
|
+
description: "Show, plan or end maintenance: visitors get a 503 page, the owner, members and admins still get in"
|
|
1082
|
+
},
|
|
1083
|
+
args: {
|
|
1084
|
+
app: {
|
|
1085
|
+
type: "positional",
|
|
1086
|
+
required: true,
|
|
1087
|
+
description: "App name"
|
|
1088
|
+
},
|
|
1089
|
+
state: {
|
|
1090
|
+
type: "positional",
|
|
1091
|
+
required: false,
|
|
1092
|
+
description: "on or off"
|
|
1093
|
+
},
|
|
1094
|
+
for: {
|
|
1095
|
+
type: "string",
|
|
1096
|
+
description: "End by itself after this long, like 30m, 2h or 1d"
|
|
1097
|
+
},
|
|
1098
|
+
from: {
|
|
1099
|
+
type: "string",
|
|
1100
|
+
description: "Start at this time instead of now, like 2026-12-01T22:00"
|
|
1101
|
+
},
|
|
1102
|
+
until: {
|
|
1103
|
+
type: "string",
|
|
1104
|
+
description: "End by itself at this time"
|
|
1105
|
+
},
|
|
1106
|
+
title: {
|
|
1107
|
+
type: "string",
|
|
1108
|
+
description: "The page's title (empty for the default)"
|
|
1109
|
+
},
|
|
1110
|
+
message: {
|
|
1111
|
+
type: "string",
|
|
1112
|
+
description: "The page's text (empty for the default)"
|
|
1113
|
+
},
|
|
1114
|
+
color: {
|
|
1115
|
+
type: "string",
|
|
1116
|
+
description: "The page's color, like #2f5bff (empty for the brand's)"
|
|
1117
|
+
},
|
|
1118
|
+
link: {
|
|
1119
|
+
type: "string",
|
|
1120
|
+
description: "A contact or status link on the page (empty for none)"
|
|
1121
|
+
},
|
|
1122
|
+
"link-label": {
|
|
1123
|
+
type: "string",
|
|
1124
|
+
description: "The text of that link"
|
|
1125
|
+
},
|
|
1126
|
+
...connection
|
|
1127
|
+
},
|
|
1128
|
+
async run({ args }) {
|
|
1129
|
+
if (args.state !== void 0 && args.state !== "on" && args.state !== "off") throw new Error(`Expected on or off, not "${args.state}"`);
|
|
1130
|
+
const change = {};
|
|
1131
|
+
if (args.state) change.enabled = args.state === "on";
|
|
1132
|
+
if (args.state !== "on" && (args.for ?? args.from ?? args.until) !== void 0) throw new Error("--for, --from and --until plan maintenance: add `on`");
|
|
1133
|
+
if (args.for !== void 0) change.minutes = parseMinutes(args.for);
|
|
1134
|
+
if (args.from !== void 0) change.from = localTime(args.from);
|
|
1135
|
+
if (args.until !== void 0) change.until = localTime(args.until);
|
|
1136
|
+
if (args.title !== void 0) change.title = args.title;
|
|
1137
|
+
if (args.message !== void 0) change.message = args.message;
|
|
1138
|
+
if (args.color !== void 0) change.color = args.color;
|
|
1139
|
+
if (args.link !== void 0) change.linkUrl = args.link;
|
|
1140
|
+
if (args["link-label"] !== void 0) change.linkLabel = args["link-label"];
|
|
1141
|
+
const result = await (Object.keys(change).length ? await api(`/v1/apps/${args.app}/maintenance`, args, {
|
|
1142
|
+
method: "PATCH",
|
|
1143
|
+
headers: { "content-type": "application/json" },
|
|
1144
|
+
body: JSON.stringify(change)
|
|
1145
|
+
}) : await api(`/v1/apps/${args.app}/maintenance`, args)).json();
|
|
1146
|
+
const when = (iso) => new Date(iso).toLocaleString();
|
|
1147
|
+
if (result.deploying) console.log(`${args.app} is in maintenance for a deploy`);
|
|
1148
|
+
if (result.state === "off" && !result.deploying) console.log(`${args.app} is not in maintenance`);
|
|
1149
|
+
if (result.state === "on") console.log(`${args.app} is in maintenance ${result.until ? `until ${when(result.until)}` : "until you turn it off"}`);
|
|
1150
|
+
if (result.state === "scheduled") console.log(`${args.app} goes into maintenance at ${when(result.from)}${result.until ? `, until ${when(result.until)}` : ""}`);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
/** A time as ISO, read in this machine's time zone unless it names one. */
|
|
1154
|
+
function localTime(value) {
|
|
1155
|
+
const time = new Date(value);
|
|
1156
|
+
if (Number.isNaN(time.getTime())) throw new Error(`Can't read the time "${value}"`);
|
|
1157
|
+
return time.toISOString();
|
|
1158
|
+
}
|
|
811
1159
|
const domains = defineCommand({
|
|
812
1160
|
meta: {
|
|
813
1161
|
name: "domains",
|
|
@@ -1002,6 +1350,14 @@ const accounts = defineCommand({
|
|
|
1002
1350
|
type: "string",
|
|
1003
1351
|
description: "Space all releases of their apps may take, e.g. 2GB. \"none\", \"default\""
|
|
1004
1352
|
},
|
|
1353
|
+
admin: {
|
|
1354
|
+
type: "boolean",
|
|
1355
|
+
description: "Make the account an admin (only admins from WERVT_ADMIN_EMAILS can)"
|
|
1356
|
+
},
|
|
1357
|
+
"revoke-admin": {
|
|
1358
|
+
type: "boolean",
|
|
1359
|
+
description: "Take admin away from the account"
|
|
1360
|
+
},
|
|
1005
1361
|
into: {
|
|
1006
1362
|
type: "string",
|
|
1007
1363
|
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"
|
|
@@ -1009,6 +1365,15 @@ const accounts = defineCommand({
|
|
|
1009
1365
|
...connection
|
|
1010
1366
|
},
|
|
1011
1367
|
async run({ args }) {
|
|
1368
|
+
const admin = adminFlag(args);
|
|
1369
|
+
if (admin !== void 0) {
|
|
1370
|
+
const result = await (await api(`/v1/accounts/${encodeURIComponent(args.email ?? "")}`, args, {
|
|
1371
|
+
method: "PATCH",
|
|
1372
|
+
headers: { "content-type": "application/json" },
|
|
1373
|
+
body: JSON.stringify({ admin })
|
|
1374
|
+
})).json();
|
|
1375
|
+
return console.log(`${result.email} ${result.admin ? "is now an admin" : "is no admin now"}`);
|
|
1376
|
+
}
|
|
1012
1377
|
if (args.email && args.into) {
|
|
1013
1378
|
const result = await (await api("/v1/accounts/merge", args, {
|
|
1014
1379
|
method: "POST",
|
|
@@ -1036,7 +1401,7 @@ const accounts = defineCommand({
|
|
|
1036
1401
|
const change = {};
|
|
1037
1402
|
if (args.apps) change.apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
|
|
1038
1403
|
for (const flag of SIZE_FLAGS) if (args[flag]) change[flag] = args[flag] === "none" ? null : args[flag];
|
|
1039
|
-
if (!Object.keys(change).length) throw new Error("Pass --apps, --database, --storage or --releases (a value, none or default)");
|
|
1404
|
+
if (!Object.keys(change).length) throw new Error("Pass --apps, --database, --storage or --releases (a value, none or default), or --admin or --revoke-admin");
|
|
1040
1405
|
const result = await (await api(`/v1/accounts/${encodeURIComponent(args.email)}`, args, {
|
|
1041
1406
|
method: "PATCH",
|
|
1042
1407
|
headers: { "content-type": "application/json" },
|
|
@@ -1219,6 +1584,262 @@ const clients = defineCommand({
|
|
|
1219
1584
|
})
|
|
1220
1585
|
}
|
|
1221
1586
|
});
|
|
1587
|
+
const updatePlatforms = (value) => {
|
|
1588
|
+
const list = toList(value ?? UPDATE_PLATFORMS.join(","));
|
|
1589
|
+
for (const platform of list) if (!UPDATE_PLATFORMS.includes(platform)) throw new Error(`--platform must be ios or android, not ${platform}`);
|
|
1590
|
+
return list;
|
|
1591
|
+
};
|
|
1592
|
+
/** Runs a tool of the Expo project and returns what it printed. */
|
|
1593
|
+
async function expoTool(dir, args) {
|
|
1594
|
+
const result = await x("npx", args, {
|
|
1595
|
+
nodeOptions: { cwd: dir },
|
|
1596
|
+
throwOnError: false
|
|
1597
|
+
});
|
|
1598
|
+
if (result.exitCode !== 0) throw new Error(`npx ${args.join(" ")} failed:\n${result.stderr || result.stdout}`);
|
|
1599
|
+
return result.stdout;
|
|
1600
|
+
}
|
|
1601
|
+
const gitLine = async (dir, args) => {
|
|
1602
|
+
const result = await x("git", args, {
|
|
1603
|
+
nodeOptions: { cwd: dir },
|
|
1604
|
+
throwOnError: false
|
|
1605
|
+
});
|
|
1606
|
+
return result.exitCode === 0 ? result.stdout.trim() : void 0;
|
|
1607
|
+
};
|
|
1608
|
+
const UPLOADS_AT_ONCE = 8;
|
|
1609
|
+
const updates = defineCommand({
|
|
1610
|
+
meta: {
|
|
1611
|
+
name: "updates",
|
|
1612
|
+
description: "Publish over-the-air updates for the Expo app that goes with an app"
|
|
1613
|
+
},
|
|
1614
|
+
subCommands: {
|
|
1615
|
+
publish: defineCommand({
|
|
1616
|
+
meta: {
|
|
1617
|
+
name: "publish",
|
|
1618
|
+
description: "Export the Expo project and publish it to the app's devices"
|
|
1619
|
+
},
|
|
1620
|
+
args: {
|
|
1621
|
+
app: appArg,
|
|
1622
|
+
channel: {
|
|
1623
|
+
type: "string",
|
|
1624
|
+
default: "production",
|
|
1625
|
+
description: "Channel of the builds"
|
|
1626
|
+
},
|
|
1627
|
+
message: {
|
|
1628
|
+
type: "string",
|
|
1629
|
+
description: "Default: the last commit subject"
|
|
1630
|
+
},
|
|
1631
|
+
platform: {
|
|
1632
|
+
type: "string",
|
|
1633
|
+
description: "ios, android or both (default: both)"
|
|
1634
|
+
},
|
|
1635
|
+
dir: {
|
|
1636
|
+
type: "string",
|
|
1637
|
+
default: ".",
|
|
1638
|
+
description: "The Expo project (default: .)"
|
|
1639
|
+
},
|
|
1640
|
+
...connection
|
|
1641
|
+
},
|
|
1642
|
+
async run({ args }) {
|
|
1643
|
+
const dir = resolve(args.dir);
|
|
1644
|
+
const platforms = updatePlatforms(args.platform);
|
|
1645
|
+
p.intro(`wervt updates publish ${args.app}`);
|
|
1646
|
+
const out = await mkdtemp(join(tmpdir(), "wervt-update-"));
|
|
1647
|
+
try {
|
|
1648
|
+
p.log.step("expo export");
|
|
1649
|
+
if ((await x("npx", [
|
|
1650
|
+
"expo",
|
|
1651
|
+
"export",
|
|
1652
|
+
...platforms.flatMap((platform) => ["--platform", platform]),
|
|
1653
|
+
"--output-dir",
|
|
1654
|
+
out
|
|
1655
|
+
], {
|
|
1656
|
+
nodeOptions: {
|
|
1657
|
+
cwd: dir,
|
|
1658
|
+
stdio: "inherit"
|
|
1659
|
+
},
|
|
1660
|
+
throwOnError: false
|
|
1661
|
+
})).exitCode !== 0) throw new Error("expo export failed");
|
|
1662
|
+
const config = JSON.parse(await expoTool(dir, [
|
|
1663
|
+
"expo",
|
|
1664
|
+
"config",
|
|
1665
|
+
"--json",
|
|
1666
|
+
"--type",
|
|
1667
|
+
"public"
|
|
1668
|
+
]));
|
|
1669
|
+
const commit = await gitLine(dir, [
|
|
1670
|
+
"rev-parse",
|
|
1671
|
+
"--short=12",
|
|
1672
|
+
"HEAD"
|
|
1673
|
+
]);
|
|
1674
|
+
const message = args.message ?? await gitLine(dir, [
|
|
1675
|
+
"log",
|
|
1676
|
+
"-1",
|
|
1677
|
+
"--format=%s"
|
|
1678
|
+
]);
|
|
1679
|
+
const prepared = await Promise.all(platforms.map(async (platform) => {
|
|
1680
|
+
const { runtimeVersion } = JSON.parse(await expoTool(dir, [
|
|
1681
|
+
"expo-updates",
|
|
1682
|
+
"runtimeversion:resolve",
|
|
1683
|
+
"--platform",
|
|
1684
|
+
platform
|
|
1685
|
+
]));
|
|
1686
|
+
if (!runtimeVersion) throw new Error(`No runtimeVersion for ${platform}, set it in the app config`);
|
|
1687
|
+
return {
|
|
1688
|
+
platform,
|
|
1689
|
+
runtimeVersion,
|
|
1690
|
+
...await readExport(out, platform)
|
|
1691
|
+
};
|
|
1692
|
+
}));
|
|
1693
|
+
const files = uniqueFiles(prepared.flatMap((u) => [u.launchAsset, ...u.assets]));
|
|
1694
|
+
const { missing } = await (await api(`/v1/apps/${args.app}/updates/missing`, args, {
|
|
1695
|
+
method: "POST",
|
|
1696
|
+
headers: { "content-type": "application/json" },
|
|
1697
|
+
body: JSON.stringify({ hashes: files.map((file) => file.hash) })
|
|
1698
|
+
})).json();
|
|
1699
|
+
const wanted = new Set(missing);
|
|
1700
|
+
const uploads = files.filter((file) => wanted.has(file.hash));
|
|
1701
|
+
const spin = progress();
|
|
1702
|
+
spin.start(`Uploading ${uploads.length} of ${files.length} files`);
|
|
1703
|
+
await forEachLimit(uploads, UPLOADS_AT_ONCE, async (file) => {
|
|
1704
|
+
await api(`/v1/apps/${args.app}/updates/assets/${file.hash}`, args, {
|
|
1705
|
+
method: "PUT",
|
|
1706
|
+
headers: { "content-type": file.contentType },
|
|
1707
|
+
body: await readFile(file.path)
|
|
1708
|
+
});
|
|
1709
|
+
});
|
|
1710
|
+
spin.stop(`Uploaded ${uploads.length} of ${files.length} files`);
|
|
1711
|
+
for (const { platform, runtimeVersion, launchAsset, assets } of prepared) {
|
|
1712
|
+
const created = await (await api(`/v1/apps/${args.app}/updates`, args, {
|
|
1713
|
+
method: "POST",
|
|
1714
|
+
headers: { "content-type": "application/json" },
|
|
1715
|
+
body: JSON.stringify({
|
|
1716
|
+
channel: args.channel,
|
|
1717
|
+
platform,
|
|
1718
|
+
runtimeVersion,
|
|
1719
|
+
launchAsset: withoutPath(launchAsset),
|
|
1720
|
+
assets: assets.map(withoutPath),
|
|
1721
|
+
extra: { expoClient: config },
|
|
1722
|
+
message,
|
|
1723
|
+
commit
|
|
1724
|
+
})
|
|
1725
|
+
})).json();
|
|
1726
|
+
p.log.success(`${platform}: ${created[0].id} (runtime ${runtimeVersion})`);
|
|
1727
|
+
}
|
|
1728
|
+
outro(`Published to ${args.channel}`);
|
|
1729
|
+
} finally {
|
|
1730
|
+
await rm(out, {
|
|
1731
|
+
recursive: true,
|
|
1732
|
+
force: true
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}),
|
|
1737
|
+
list: defineCommand({
|
|
1738
|
+
meta: {
|
|
1739
|
+
name: "list",
|
|
1740
|
+
description: "List published updates, newest first"
|
|
1741
|
+
},
|
|
1742
|
+
args: {
|
|
1743
|
+
app: appArg,
|
|
1744
|
+
channel: {
|
|
1745
|
+
type: "string",
|
|
1746
|
+
description: "Only this channel"
|
|
1747
|
+
},
|
|
1748
|
+
...connection
|
|
1749
|
+
},
|
|
1750
|
+
async run({ args }) {
|
|
1751
|
+
const shown = (await (await api(`/v1/apps/${args.app}/updates`, args)).json()).filter((u) => !args.channel || u.channel === args.channel);
|
|
1752
|
+
if (!shown.length) return console.log(`No updates for ${args.app}.`);
|
|
1753
|
+
for (const update of shown) {
|
|
1754
|
+
const what = update.rollBackToEmbedded ? "back to the embedded update" : `${update.files} files, ${formatBytes(update.bytes)}`;
|
|
1755
|
+
console.log(`${update.createdAt.slice(0, 16).replace("T", " ")} ${update.channel} ${update.platform} ${update.id} ${what}`);
|
|
1756
|
+
const note = [
|
|
1757
|
+
update.message,
|
|
1758
|
+
update.commit,
|
|
1759
|
+
`runtime ${update.runtimeVersion.slice(0, 12)}`,
|
|
1760
|
+
update.republishOf && `again from ${update.republishOf}`
|
|
1761
|
+
];
|
|
1762
|
+
console.log(styleText("dim", ` ${note.filter(Boolean).join(" · ")}`));
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}),
|
|
1766
|
+
rollback: defineCommand({
|
|
1767
|
+
meta: {
|
|
1768
|
+
name: "rollback",
|
|
1769
|
+
description: "Send a channel back to the previous update, or the one built into the app"
|
|
1770
|
+
},
|
|
1771
|
+
args: {
|
|
1772
|
+
app: appArg,
|
|
1773
|
+
channel: {
|
|
1774
|
+
type: "string",
|
|
1775
|
+
default: "production",
|
|
1776
|
+
description: "Channel to roll back"
|
|
1777
|
+
},
|
|
1778
|
+
platform: {
|
|
1779
|
+
type: "string",
|
|
1780
|
+
description: "ios or android (default: both)"
|
|
1781
|
+
},
|
|
1782
|
+
runtime: {
|
|
1783
|
+
type: "string",
|
|
1784
|
+
description: "A runtime version (default: each platform's newest)"
|
|
1785
|
+
},
|
|
1786
|
+
embedded: {
|
|
1787
|
+
type: "boolean",
|
|
1788
|
+
description: "Go back to the update built into the app"
|
|
1789
|
+
},
|
|
1790
|
+
yes: {
|
|
1791
|
+
type: "boolean",
|
|
1792
|
+
description: "Skip the confirmation"
|
|
1793
|
+
},
|
|
1794
|
+
...connection
|
|
1795
|
+
},
|
|
1796
|
+
async run({ args }) {
|
|
1797
|
+
const platforms = args.platform ? updatePlatforms(args.platform) : [];
|
|
1798
|
+
if (platforms.length > 1) throw new Error("--platform takes one platform");
|
|
1799
|
+
if (!args.yes) {
|
|
1800
|
+
const ok = await p.confirm({
|
|
1801
|
+
message: `Roll back ${args.channel} of ${args.app}? Devices take this on their next start.`,
|
|
1802
|
+
initialValue: false
|
|
1803
|
+
});
|
|
1804
|
+
if (p.isCancel(ok) || !ok) return p.cancel("Cancelled");
|
|
1805
|
+
}
|
|
1806
|
+
const created = await (await api(`/v1/apps/${args.app}/updates/rollback`, args, {
|
|
1807
|
+
method: "POST",
|
|
1808
|
+
headers: { "content-type": "application/json" },
|
|
1809
|
+
body: JSON.stringify({
|
|
1810
|
+
channel: args.channel,
|
|
1811
|
+
platform: platforms[0],
|
|
1812
|
+
runtimeVersion: args.runtime,
|
|
1813
|
+
embedded: args.embedded
|
|
1814
|
+
})
|
|
1815
|
+
})).json();
|
|
1816
|
+
for (const update of created) console.log(`${update.platform}: ${update.rollBackToEmbedded ? "back to the embedded update" : `back to ${update.republishOf}`}`);
|
|
1817
|
+
}
|
|
1818
|
+
}),
|
|
1819
|
+
certificate: defineCommand({
|
|
1820
|
+
meta: {
|
|
1821
|
+
name: "certificate",
|
|
1822
|
+
description: "Save the code signing certificate that goes into the build"
|
|
1823
|
+
},
|
|
1824
|
+
args: {
|
|
1825
|
+
app: appArg,
|
|
1826
|
+
out: {
|
|
1827
|
+
type: "string",
|
|
1828
|
+
default: DEFAULT_CERTIFICATE,
|
|
1829
|
+
description: "Where to save it"
|
|
1830
|
+
},
|
|
1831
|
+
...connection
|
|
1832
|
+
},
|
|
1833
|
+
async run({ args }) {
|
|
1834
|
+
const { certificate } = await (await api(`/v1/apps/${args.app}/updates/certificate`, args, { method: "POST" })).json();
|
|
1835
|
+
await mkdir(dirname(resolve(args.out)), { recursive: true });
|
|
1836
|
+
await writeFile(args.out, certificate);
|
|
1837
|
+
console.log(args.out);
|
|
1838
|
+
console.error(styleText("dim", "Public: commit it. Builds with it only take updates signed for this app."));
|
|
1839
|
+
}
|
|
1840
|
+
})
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1222
1843
|
const token = defineCommand({
|
|
1223
1844
|
meta: {
|
|
1224
1845
|
name: "token",
|
|
@@ -1857,6 +2478,37 @@ const login = defineCommand({
|
|
|
1857
2478
|
outro(`Signed in to ${url} (saved to ${file})`);
|
|
1858
2479
|
}
|
|
1859
2480
|
});
|
|
2481
|
+
const dev = defineCommand({
|
|
2482
|
+
meta: {
|
|
2483
|
+
name: "dev",
|
|
2484
|
+
description: "Run an app's dev server against its local database: wervt dev -- <command>"
|
|
2485
|
+
},
|
|
2486
|
+
args: {
|
|
2487
|
+
name: {
|
|
2488
|
+
type: "string",
|
|
2489
|
+
description: "App name (default: from package.json)"
|
|
2490
|
+
},
|
|
2491
|
+
storage: {
|
|
2492
|
+
type: "boolean",
|
|
2493
|
+
description: "Set up the app's local bucket too"
|
|
2494
|
+
},
|
|
2495
|
+
reset: {
|
|
2496
|
+
type: "boolean",
|
|
2497
|
+
description: "Drop the app's local database first; without a command, only that"
|
|
2498
|
+
}
|
|
2499
|
+
},
|
|
2500
|
+
async run({ args, rawArgs }) {
|
|
2501
|
+
const command = passthrough(rawArgs);
|
|
2502
|
+
if (!command.length && !args.reset) throw new Error("Pass a command: wervt dev -- <command>");
|
|
2503
|
+
await dev$1({
|
|
2504
|
+
dir: ".",
|
|
2505
|
+
command,
|
|
2506
|
+
name: args.name,
|
|
2507
|
+
storage: args.storage,
|
|
2508
|
+
reset: args.reset
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
1860
2512
|
const build = defineCommand({
|
|
1861
2513
|
meta: {
|
|
1862
2514
|
name: "build",
|
|
@@ -1905,6 +2557,7 @@ runMain(defineCommand({
|
|
|
1905
2557
|
},
|
|
1906
2558
|
subCommands: {
|
|
1907
2559
|
deploy,
|
|
2560
|
+
dev,
|
|
1908
2561
|
build,
|
|
1909
2562
|
apps,
|
|
1910
2563
|
releases,
|
|
@@ -1914,7 +2567,9 @@ runMain(defineCommand({
|
|
|
1914
2567
|
activate,
|
|
1915
2568
|
rollback,
|
|
1916
2569
|
reprovision,
|
|
2570
|
+
db,
|
|
1917
2571
|
access,
|
|
2572
|
+
maintenance,
|
|
1918
2573
|
domains,
|
|
1919
2574
|
members,
|
|
1920
2575
|
accounts,
|
|
@@ -1922,6 +2577,7 @@ runMain(defineCommand({
|
|
|
1922
2577
|
tokens,
|
|
1923
2578
|
token,
|
|
1924
2579
|
clients,
|
|
2580
|
+
updates,
|
|
1925
2581
|
identity,
|
|
1926
2582
|
tasks,
|
|
1927
2583
|
stats,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wervt/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"description": "Deploy and manage apps on wervt.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -29,14 +29,16 @@
|
|
|
29
29
|
"type": "module",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^1.8.1",
|
|
32
|
+
"@wervt/platform": "^0.0.4",
|
|
32
33
|
"citty": "^0.2.2",
|
|
34
|
+
"drizzle-orm": "1.0.0-rc.5",
|
|
33
35
|
"modern-tar": "^0.8.6",
|
|
34
36
|
"nypm": "^0.6.12",
|
|
35
37
|
"rolldown": "^1.2.16",
|
|
36
38
|
"tinyexec": "^1.3.1"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
39
|
-
"@wervt/core": "0.0.
|
|
41
|
+
"@wervt/core": "0.0.3"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
42
44
|
"build": "vp pack"
|
package/skill/SKILL.md
CHANGED
|
@@ -23,6 +23,17 @@ approve it in the dashboard (CLI page) and the machine gets its own admin sessio
|
|
|
23
23
|
login for them, never look for tokens in files, env or keychains, and never print or ask to paste
|
|
24
24
|
one into the chat.
|
|
25
25
|
|
|
26
|
+
Where the user can't run the login (a cloud sandbox like Claude Code on the web), use wervt's MCP
|
|
27
|
+
servers instead, if they're connected. They act as the user:
|
|
28
|
+
|
|
29
|
+
- The dashboard's (`whoami`, `apps.list`, `logs.query`, `env.update`, …) manages deployed apps:
|
|
30
|
+
everything below except building and deploying.
|
|
31
|
+
- build's (`create-app`, `edit-file`, `run`, `deploy`, …) builds and deploys apps in a cloud
|
|
32
|
+
sandbox, without this machine.
|
|
33
|
+
|
|
34
|
+
Not connected: ask the user to add `https://dashboard.<domain>/mcp` (and, to build,
|
|
35
|
+
`https://build.<domain>/mcp`) as MCP servers.
|
|
36
|
+
|
|
26
37
|
## 2. Scaffold
|
|
27
38
|
|
|
28
39
|
```bash
|
|
@@ -141,6 +152,7 @@ pnpm exec wervt queries <name> # saved queries and their alerts
|
|
|
141
152
|
pnpm exec wervt tasks <name> # tasks, schedules, next runs
|
|
142
153
|
pnpm exec wervt tasks <name> --run <task> # run one now
|
|
143
154
|
pnpm exec wervt rollback <name> # the previous release (migrations stay)
|
|
155
|
+
pnpm exec wervt db sql <name> "select …" # one read-only statement as the app's role
|
|
144
156
|
```
|
|
145
157
|
|
|
146
158
|
For debugging, use the app's `wervt-logs` skill (logs live 14 days; queries are LogsQL).
|
|
@@ -148,6 +160,6 @@ For debugging, use the app's `wervt-logs` skill (logs live 14 days; queries are
|
|
|
148
160
|
## Never without asking
|
|
149
161
|
|
|
150
162
|
`wervt delete` (never with `--drop-database` unless the user says so explicitly), `rollback`,
|
|
151
|
-
`activate`, revoking tokens, making a private app public, or deploying over an app you didn't
|
|
163
|
+
`activate`, `wervt db sql --write`, revoking tokens, making a private app public, or deploying over an app you didn't
|
|
152
164
|
create in this conversation. Outside an app directory, `pnpm exec wervt` isn't installed: use
|
|
153
165
|
`npx -y @wervt/cli <command>`.
|