@wervt/cli 0.1.0 → 0.2.1

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.
Files changed (2) hide show
  1. package/dist/index.mjs +201 -8
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
+ import { styleText } from "node:util";
4
5
  import * as p from "@clack/prompts";
5
6
  import { defineCommand, runMain } from "citty";
6
7
  import { detectPackageManager } from "nypm";
@@ -70,6 +71,52 @@ async function saveConfig(config) {
70
71
  return file;
71
72
  }
72
73
  //#endregion
74
+ //#region src/source.ts
75
+ async function git(dir, ...args) {
76
+ try {
77
+ const result = await x("git", args, {
78
+ nodeOptions: { cwd: dir },
79
+ throwOnError: false
80
+ });
81
+ return result.exitCode === 0 ? result.stdout.trim() || void 0 : void 0;
82
+ } catch {
83
+ return;
84
+ }
85
+ }
86
+ /** `git@github.com:owner/repo.git` and `https://…/repo.git` as `https://github.com/owner/repo`. */
87
+ function webUrl(remote) {
88
+ if (!remote) return void 0;
89
+ const ssh = remote.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/);
90
+ if (ssh) return `https://${ssh[1]}/${ssh[2]}`;
91
+ const https = remote.match(/^https:\/\/(?:[^@/]+@)?(.+?)(?:\.git)?$/);
92
+ return https ? `https://${https[1]}` : void 0;
93
+ }
94
+ async function deploySource(dir) {
95
+ const env = process.env;
96
+ const message = await git(dir, "log", "-1", "--format=%s");
97
+ if (env.GITHUB_ACTIONS === "true") {
98
+ const repo = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`;
99
+ return {
100
+ commit: env.GITHUB_SHA,
101
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
102
+ message,
103
+ repo,
104
+ actor: env.GITHUB_ACTOR,
105
+ run: `${repo}/actions/runs/${env.GITHUB_RUN_ID}`
106
+ };
107
+ }
108
+ const status = await git(dir, "status", "--porcelain", "--", ".");
109
+ const commit = await git(dir, "rev-parse", "HEAD");
110
+ return {
111
+ commit,
112
+ branch: await git(dir, "branch", "--show-current"),
113
+ message,
114
+ dirty: commit ? Boolean(status) : void 0,
115
+ repo: webUrl(await git(dir, "remote", "get-url", "origin")),
116
+ actor: await git(dir, "config", "user.name")
117
+ };
118
+ }
119
+ //#endregion
73
120
  //#region src/index.ts
74
121
  const connection = {
75
122
  url: {
@@ -78,7 +125,7 @@ const connection = {
78
125
  },
79
126
  token: {
80
127
  type: "string",
81
- description: "Deploy token (or WERVT_TOKEN)"
128
+ description: "Admin, read or deploy token (or WERVT_TOKEN)"
82
129
  }
83
130
  };
84
131
  async function api(path, flags, init = {}) {
@@ -91,7 +138,12 @@ async function api(path, flags, init = {}) {
91
138
  });
92
139
  if (!res.ok) {
93
140
  const body = await res.text();
94
- throw new Error(`${res.status} ${res.statusText}: ${body}`);
141
+ let message = body;
142
+ try {
143
+ message = JSON.parse(body).error ?? body;
144
+ } catch {}
145
+ console.error(styleText("red", `Error (${res.status}): ${message}`));
146
+ process.exit(1);
95
147
  }
96
148
  return res;
97
149
  }
@@ -152,9 +204,13 @@ const deploy = defineCommand({
152
204
  const bundle = await createBundle(dir);
153
205
  spinner.stop(`Packed ${bundle.files} files (${(bundle.data.byteLength / 1024).toFixed(0)} kB)`);
154
206
  spinner.start("Uploading");
207
+ const source = await deploySource(dir);
155
208
  const res = await api(`/v1/apps/${name}/deploys`, args, {
156
209
  method: "POST",
157
- headers: { "content-type": "application/gzip" },
210
+ headers: {
211
+ "content-type": "application/gzip",
212
+ "x-wervt-source": encodeURIComponent(JSON.stringify(source))
213
+ },
158
214
  body: new Uint8Array(bundle.data)
159
215
  });
160
216
  const labels = {
@@ -178,6 +234,7 @@ const deploy = defineCommand({
178
234
  if (event.done) {
179
235
  spinner.stop(`Release ${event.release}`);
180
236
  p.outro(`Deployed → ${event.url}`);
237
+ if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `url=${event.url}\nrelease=${event.release}\n`);
181
238
  return;
182
239
  }
183
240
  spinner.message(labels[event.step] ?? event.step);
@@ -230,7 +287,18 @@ const releases = defineCommand({
230
287
  },
231
288
  async run({ args }) {
232
289
  const info = await (await api(`/v1/apps/${args.app}`, args)).json();
233
- for (const release of [...info.releases].reverse()) console.log(`${release}${release === info.release ? " (active)" : ""}`);
290
+ const history = new Map(info.history?.map((entry) => [entry.release, entry]));
291
+ for (const release of [...info.releases].reverse()) {
292
+ const { source, deployedBy } = history.get(release) ?? {};
293
+ const details = [
294
+ source?.commit ? `${source.commit.slice(0, 7)}${source.dirty ? "+" : ""}` : void 0,
295
+ source?.branch,
296
+ source?.actor ?? deployedBy,
297
+ source?.message && styleText("dim", source.message)
298
+ ].filter(Boolean);
299
+ const active = release === info.release ? styleText("green", " (active)") : "";
300
+ console.log(`${release}${active}${details.length ? ` ${details.join(" ")}` : ""}`);
301
+ }
234
302
  }
235
303
  });
236
304
  const activate = defineCommand({
@@ -372,7 +440,53 @@ const env = defineCommand({
372
440
  })
373
441
  }) : await api(`/v1/apps/${args.app}/env`, args)).json();
374
442
  console.log(keys.length ? keys.join("\n") : "No variables set.");
375
- if (Object.keys(set).length || unset.length) console.log("Applies to newly started isolates (next deploy or after idle timeout).");
443
+ if (Object.keys(set).length || unset.length) console.log("Applies from the next request.");
444
+ }
445
+ });
446
+ const tokens = defineCommand({
447
+ meta: {
448
+ name: "tokens",
449
+ description: "List, create or revoke an app's deploy tokens (for CI)"
450
+ },
451
+ args: {
452
+ app: {
453
+ type: "positional",
454
+ required: true,
455
+ description: "App name"
456
+ },
457
+ create: {
458
+ type: "string",
459
+ description: "Create a token with this name, e.g. github"
460
+ },
461
+ revoke: {
462
+ type: "string",
463
+ description: "Revoke the token with this id"
464
+ },
465
+ ...connection
466
+ },
467
+ async run({ args }) {
468
+ const path = `/v1/apps/${args.app}/tokens`;
469
+ if (args.create) {
470
+ const token = await (await api(path, args, {
471
+ method: "POST",
472
+ headers: { "content-type": "application/json" },
473
+ body: JSON.stringify({ name: args.create })
474
+ })).json();
475
+ console.error(`Deploy token "${token.name}" for ${args.app} (shown once):`);
476
+ console.log(token.token);
477
+ console.error(styleText("dim", "It can deploy this app and nothing else. Store it as the WERVT_TOKEN secret."));
478
+ return;
479
+ }
480
+ if (args.revoke) {
481
+ await api(`${path}/${args.revoke}`, args, { method: "DELETE" });
482
+ return console.log(`Revoked ${args.revoke}`);
483
+ }
484
+ const list = await (await api(path, args)).json();
485
+ if (!list.length) return console.log(`No deploy tokens for ${args.app}.`);
486
+ for (const token of list) {
487
+ const used = token.lastUsedAt ? `used ${token.lastUsedAt.slice(0, 10)}` : "never used";
488
+ console.log(`${token.id} ${token.name.padEnd(20)} created ${token.createdAt.slice(0, 10)} ${used}`);
489
+ }
376
490
  }
377
491
  });
378
492
  const remove = defineCommand({
@@ -409,6 +523,83 @@ const remove = defineCommand({
409
523
  console.log(`Deleted ${args.app}${dropDb ? " and its database" : ""}`);
410
524
  }
411
525
  });
526
+ const LEVEL_COLORS = {
527
+ error: "red",
528
+ warn: "yellow",
529
+ info: "cyan",
530
+ debug: "gray"
531
+ };
532
+ /** `09:07:52.060 error <message>`, or the entry as one JSON line. */
533
+ function printLog(entry, asJson) {
534
+ if (asJson) return console.log(JSON.stringify(entry));
535
+ const time = new Date(entry._time).toISOString().slice(11, 23);
536
+ const level = String(entry.level ?? "info");
537
+ const color = LEVEL_COLORS[level] ?? "white";
538
+ console.log(`${styleText("dim", time)} ${styleText(color, level.padEnd(5))} ${entry._msg ?? ""}`);
539
+ }
540
+ const logs = defineCommand({
541
+ meta: {
542
+ name: "logs",
543
+ description: "Show an app's logs: console output, request events and isolate events"
544
+ },
545
+ args: {
546
+ app: {
547
+ type: "positional",
548
+ required: true,
549
+ description: "App name"
550
+ },
551
+ query: {
552
+ type: "positional",
553
+ required: false,
554
+ description: "LogsQL filter, e.g. 'level:error' or 'status:>=500' (default: all)"
555
+ },
556
+ since: {
557
+ type: "string",
558
+ default: "1h",
559
+ description: "How far back, e.g. 15m, 6h, 2d"
560
+ },
561
+ until: {
562
+ type: "string",
563
+ description: "End of the range (default: now)"
564
+ },
565
+ limit: {
566
+ type: "string",
567
+ default: "100",
568
+ description: "Newest entries to show (max 1000)"
569
+ },
570
+ follow: {
571
+ type: "boolean",
572
+ alias: "f",
573
+ description: "Keep printing new entries"
574
+ },
575
+ json: {
576
+ type: "boolean",
577
+ description: "One JSON object per line, with every field"
578
+ },
579
+ ...connection
580
+ },
581
+ async run({ args }) {
582
+ const params = new URLSearchParams();
583
+ if (args.query) params.set("query", args.query);
584
+ if (args.follow) {
585
+ params.set("since", args.since);
586
+ const res = await api(`/v1/apps/${args.app}/logs/tail?${params.toString()}`, args);
587
+ let rest = "";
588
+ for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
589
+ const lines = (rest + chunk).split("\n");
590
+ rest = lines.pop() ?? "";
591
+ for (const line of lines) if (line) printLog(JSON.parse(line), Boolean(args.json));
592
+ }
593
+ return;
594
+ }
595
+ params.set("start", args.since);
596
+ if (args.until) params.set("end", args.until);
597
+ params.set("limit", args.limit);
598
+ const entries = await (await api(`/v1/apps/${args.app}/logs?${params.toString()}`, args)).json();
599
+ if (!entries.length && !args.json) return console.error(`No logs in the last ${args.since}.`);
600
+ for (const entry of entries.reverse()) printLog(entry, Boolean(args.json));
601
+ }
602
+ });
412
603
  const login = defineCommand({
413
604
  meta: {
414
605
  name: "login",
@@ -423,12 +614,12 @@ const login = defineCommand({
423
614
  token: {
424
615
  type: "string",
425
616
  required: true,
426
- description: "Deploy token"
617
+ description: "Admin, read or deploy token"
427
618
  }
428
619
  },
429
620
  async run({ args }) {
430
621
  const res = await fetch(`${args.url.replace(/\/$/, "")}/v1/apps`, { headers: { authorization: `Bearer ${args.token}` } });
431
- if (!res.ok) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
622
+ if (!res.ok && res.status !== 403) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
432
623
  const file = await saveConfig({
433
624
  url: args.url,
434
625
  token: args.token
@@ -445,11 +636,13 @@ runMain(defineCommand({
445
636
  deploy,
446
637
  apps,
447
638
  releases,
639
+ logs,
448
640
  activate,
449
641
  rollback,
450
642
  reprovision,
451
643
  access,
452
644
  env,
645
+ tokens,
453
646
  delete: remove,
454
647
  login
455
648
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Deploy and manage apps on wervt.",
5
5
  "keywords": [
6
6
  "agents",