@wervt/cli 0.2.0 → 0.2.2

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 CHANGED
@@ -1,5 +1,6 @@
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
+ import { homedir } from "node:os";
3
4
  import { dirname, join, relative, resolve } from "node:path";
4
5
  import { styleText } from "node:util";
5
6
  import * as p from "@clack/prompts";
@@ -7,7 +8,6 @@ import { defineCommand, runMain } from "citty";
7
8
  import { detectPackageManager } from "nypm";
8
9
  import { x } from "tinyexec";
9
10
  import { packTar } from "modern-tar";
10
- import { homedir } from "node:os";
11
11
  //#region src/bundle.ts
12
12
  /** Files under `dir`. Follows symlinks: Nitro can leave traced dependencies in
13
13
  * server/node_modules as links into the local pnpm store, which won't exist on the server. */
@@ -71,6 +71,52 @@ async function saveConfig(config) {
71
71
  return file;
72
72
  }
73
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
74
120
  //#region src/index.ts
75
121
  const connection = {
76
122
  url: {
@@ -79,7 +125,7 @@ const connection = {
79
125
  },
80
126
  token: {
81
127
  type: "string",
82
- description: "Deploy or read token (or WERVT_TOKEN)"
128
+ description: "Admin, read or deploy token (or WERVT_TOKEN)"
83
129
  }
84
130
  };
85
131
  async function api(path, flags, init = {}) {
@@ -158,9 +204,13 @@ const deploy = defineCommand({
158
204
  const bundle = await createBundle(dir);
159
205
  spinner.stop(`Packed ${bundle.files} files (${(bundle.data.byteLength / 1024).toFixed(0)} kB)`);
160
206
  spinner.start("Uploading");
207
+ const source = await deploySource(dir);
161
208
  const res = await api(`/v1/apps/${name}/deploys`, args, {
162
209
  method: "POST",
163
- headers: { "content-type": "application/gzip" },
210
+ headers: {
211
+ "content-type": "application/gzip",
212
+ "x-wervt-source": encodeURIComponent(JSON.stringify(source))
213
+ },
164
214
  body: new Uint8Array(bundle.data)
165
215
  });
166
216
  const labels = {
@@ -184,6 +234,7 @@ const deploy = defineCommand({
184
234
  if (event.done) {
185
235
  spinner.stop(`Release ${event.release}`);
186
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`);
187
238
  return;
188
239
  }
189
240
  spinner.message(labels[event.step] ?? event.step);
@@ -236,7 +287,18 @@ const releases = defineCommand({
236
287
  },
237
288
  async run({ args }) {
238
289
  const info = await (await api(`/v1/apps/${args.app}`, args)).json();
239
- 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
+ }
240
302
  }
241
303
  });
242
304
  const activate = defineCommand({
@@ -378,7 +440,53 @@ const env = defineCommand({
378
440
  })
379
441
  }) : await api(`/v1/apps/${args.app}/env`, args)).json();
380
442
  console.log(keys.length ? keys.join("\n") : "No variables set.");
381
- 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
+ }
382
490
  }
383
491
  });
384
492
  const remove = defineCommand({
@@ -492,6 +600,55 @@ const logs = defineCommand({
492
600
  for (const entry of entries.reverse()) printLog(entry, Boolean(args.json));
493
601
  }
494
602
  });
603
+ const whoami = defineCommand({
604
+ meta: {
605
+ name: "whoami",
606
+ description: "Show the control plane and what the token can do"
607
+ },
608
+ args: connection,
609
+ async run({ args }) {
610
+ const { url, token } = await resolveConfig(args);
611
+ const deployApp = token.match(/^wervtd_([a-z0-9-]+)_/)?.[1];
612
+ let role = deployApp ? `deploy token for ${deployApp}` : "unknown";
613
+ if (!deployApp) {
614
+ const res = await fetch(`${url}/v1/databases`, { headers: { authorization: `Bearer ${token}` } });
615
+ if (res.status === 401) throw new Error(`The token is not valid for ${url}`);
616
+ role = res.ok ? "admin" : res.status === 403 ? "read" : `unknown (${res.status})`;
617
+ }
618
+ console.log(`${url}\n${role}`);
619
+ }
620
+ });
621
+ const skill = defineCommand({
622
+ meta: {
623
+ name: "skill",
624
+ description: "Install the wervt agent skill"
625
+ },
626
+ subCommands: { install: defineCommand({
627
+ meta: {
628
+ name: "install",
629
+ description: "Write the wervt skill to ~/.claude/skills/wervt, so agents can build and ship apps"
630
+ },
631
+ args: {
632
+ dir: {
633
+ type: "string",
634
+ description: "Skills directory (default: ~/.claude/skills; .claude/skills for --project)"
635
+ },
636
+ project: {
637
+ type: "boolean",
638
+ description: "Install into this project instead"
639
+ }
640
+ },
641
+ async run({ args }) {
642
+ const base = args.dir ?? (args.project ? ".claude/skills" : join(homedir(), ".claude", "skills"));
643
+ const target = resolve(base, "wervt", "SKILL.md");
644
+ const content = await readFile(new URL("../skill/SKILL.md", import.meta.url), "utf8");
645
+ const previous = await readFile(target, "utf8").catch(() => void 0);
646
+ await mkdir(join(target, ".."), { recursive: true });
647
+ await writeFile(target, content);
648
+ console.log(`${previous === void 0 ? "Installed" : previous === content ? "Up to date" : "Updated"}: ${target}`);
649
+ }
650
+ }) }
651
+ });
495
652
  const login = defineCommand({
496
653
  meta: {
497
654
  name: "login",
@@ -506,12 +663,12 @@ const login = defineCommand({
506
663
  token: {
507
664
  type: "string",
508
665
  required: true,
509
- description: "Deploy token, or the read token"
666
+ description: "Admin, read or deploy token"
510
667
  }
511
668
  },
512
669
  async run({ args }) {
513
670
  const res = await fetch(`${args.url.replace(/\/$/, "")}/v1/apps`, { headers: { authorization: `Bearer ${args.token}` } });
514
- if (!res.ok) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
671
+ if (!res.ok && res.status !== 403) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
515
672
  const file = await saveConfig({
516
673
  url: args.url,
517
674
  token: args.token
@@ -534,8 +691,11 @@ runMain(defineCommand({
534
691
  reprovision,
535
692
  access,
536
693
  env,
694
+ tokens,
537
695
  delete: remove,
538
- login
696
+ login,
697
+ whoami,
698
+ skill
539
699
  }
540
700
  }));
541
701
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Deploy and manage apps on wervt.",
5
5
  "keywords": [
6
6
  "agents",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "bin",
25
25
  "dist",
26
+ "skill",
26
27
  "README.md"
27
28
  ],
28
29
  "type": "module",
package/skill/SKILL.md ADDED
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: wervt
3
+ description: Build a small web app and ship it on the user's wervt server, or deploy, configure and debug an existing wervt app. Use when the user says "build X and ship it", "put it on wervt", "deploy this to wervt", "make it public", "set an env var", or asks why a wervt app fails. Covers scaffolding with create-wervt, deploying with the wervt CLI, access, env vars, GitHub Actions deploys, releases and logs.
4
+ ---
5
+
6
+ # wervt
7
+
8
+ wervt hosts small Nuxt apps on the user's own server: every app gets `https://<app>.<domain>`,
9
+ its own Postgres schema, live data (Electric), sign-in through wervt, and logs. You build the app
10
+ and ship it with the `wervt` CLI. One request ("build a habit tracker and ship it") should end with
11
+ a working URL.
12
+
13
+ ## 1. Check the connection
14
+
15
+ ```bash
16
+ npx -y @wervt/cli whoami
17
+ ```
18
+
19
+ It prints the control plane URL and what the saved token can do (`admin`, `read`, or a deploy
20
+ token for one app). Shipping new apps needs `admin`. If it says "Not logged in", ask the user to
21
+ run `wervt login --url <control plane URL> --token <admin token>` themselves (for example with
22
+ `! npx -y @wervt/cli login --url … --token …`). Never look for tokens in files, env or
23
+ keychains, and never print or ask to paste one into the chat.
24
+
25
+ ## 2. Scaffold
26
+
27
+ ```bash
28
+ pnpm create wervt <name> # a-z, 0-9 and dashes; becomes the subdomain
29
+ cd <name>
30
+ ```
31
+
32
+ The app comes with `AGENTS.md`, skills in `.claude/skills/` and a deploy workflow. **Read
33
+ `AGENTS.md` before writing code** and follow it: server routes, valibot validation, Drizzle schema
34
+ and migrations, live data with shapes, users, logging, and what the isolate runtime can't do
35
+ (no filesystem, no child processes, no long-running work, no WebSockets, no cron).
36
+
37
+ Before picking a name, run `wervt apps` so you don't overwrite an existing app. If the user named an
38
+ existing app, work in its repository instead.
39
+
40
+ ## 3. Build
41
+
42
+ - Keep the app small and complete: data in Postgres through `useDb()`, writes through API routes,
43
+ lists through `useShape` so every open tab stays live. Ask only when the request is genuinely
44
+ ambiguous; otherwise choose sensible defaults and say what you chose.
45
+ - Users: wervt signs people in. Use `useWervtUser()`; never build login, passwords or OAuth.
46
+ - After changing `server/db/schema.ts`: `pnpm db:generate`, and commit the migration.
47
+ - `pnpm dev` runs against a local wervt stack, which the user may not have. When it isn't running,
48
+ don't set one up: `pnpm build` plus `pnpm exec vp check --fix` are the checks, and the
49
+ deployed app is the test.
50
+ - Finish with `pnpm exec vp check --fix` passing.
51
+
52
+ ## 4. Ship
53
+
54
+ ```bash
55
+ pnpm exec wervt deploy # build, upload, migrate, activate
56
+ pnpm exec wervt access <name> --public # new apps are private (signed-in admins only)
57
+ pnpm exec wervt access <name> --add a@x.com,*@company.com # or let specific people in
58
+ pnpm exec wervt env <name> --set KEY=value # server env vars, applied from the next request
59
+ ```
60
+
61
+ - Ask before making an app public if it shows personal or private data. "Ship it" alone doesn't
62
+ decide that. Private, with the user's email allowed, is the safe default.
63
+ - Secrets for `wervt env` (API keys) come from the user. Ask them to run the command themselves
64
+ (`! pnpm exec wervt env <name> --set API_KEY=…`) instead of pasting the value to you.
65
+ - Then open the URL `wervt deploy` printed (curl it: expect 200, or a redirect to sign-in for
66
+ private apps) and check `pnpm exec wervt logs <name> 'level:error' --since 15m`. Fix and
67
+ redeploy until both are clean.
68
+
69
+ ## 5. Deploy on push (when the user wants a repository)
70
+
71
+ Only when asked, or when the user wants a GitHub repo for the app:
72
+
73
+ ```bash
74
+ git init -b main && git add -A && git commit -m "Initial app"
75
+ gh repo create <name> --private --source . --remote origin
76
+ pnpm exec wervt tokens <name> --create github | gh secret set WERVT_TOKEN -R <owner>/<name>
77
+ gh variable set WERVT_URL -R <owner>/<name> --body <control plane URL from whoami>
78
+ git push -u origin main
79
+ ```
80
+
81
+ The pipe hands the deploy token straight to GitHub; it never appears in the output. A deploy token
82
+ can only deploy that one app. Set the secret and variable **before** the first push, or the first
83
+ workflow run fails. Then `gh run watch` and check `pnpm exec wervt releases <name>`: the new
84
+ release lists the commit.
85
+
86
+ ## Existing apps
87
+
88
+ ```bash
89
+ pnpm exec wervt apps # every app, active release, URL
90
+ pnpm exec wervt releases <name> # newest first, with commit and author
91
+ pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
92
+ pnpm exec wervt rollback <name> # the previous release (migrations stay)
93
+ ```
94
+
95
+ For debugging, use the app's `wervt-logs` skill (logs live 14 days; queries are LogsQL).
96
+
97
+ ## Never without asking
98
+
99
+ `wervt delete` (never with `--drop-database` unless the user says so explicitly), `rollback`,
100
+ `activate`, revoking tokens, making a private app public, or deploying over an app you didn't
101
+ create in this conversation. Outside an app directory, `pnpm exec wervt` isn't installed: use
102
+ `npx -y @wervt/cli <command>`.