@wervt/cli 0.2.20 → 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 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.20",
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.2"
41
+ "@wervt/core": "0.0.3"
40
42
  },
41
43
  "scripts": {
42
44
  "build": "vp pack"
package/skill/SKILL.md CHANGED
@@ -152,6 +152,7 @@ pnpm exec wervt queries <name> # saved queries and their alerts
152
152
  pnpm exec wervt tasks <name> # tasks, schedules, next runs
153
153
  pnpm exec wervt tasks <name> --run <task> # run one now
154
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
155
156
  ```
156
157
 
157
158
  For debugging, use the app's `wervt-logs` skill (logs live 14 days; queries are LogsQL).
@@ -159,6 +160,6 @@ For debugging, use the app's `wervt-logs` skill (logs live 14 days; queries are
159
160
  ## Never without asking
160
161
 
161
162
  `wervt delete` (never with `--drop-database` unless the user says so explicitly), `rollback`,
162
- `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
163
164
  create in this conversation. Outside an app directory, `pnpm exec wervt` isn't installed: use
164
165
  `npx -y @wervt/cli <command>`.