@wervt/cli 0.2.1 → 0.2.3

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
2
  import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname } 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. */
@@ -59,12 +59,16 @@ async function resolveConfig(flags) {
59
59
  const saved = JSON.parse(await readFile(file, "utf8").catch(() => "{}"));
60
60
  const url = flags.url ?? process.env.WERVT_URL ?? saved.url;
61
61
  const token = flags.token ?? process.env.WERVT_TOKEN ?? saved.token;
62
- if (!url || !token) throw new Error("Not logged in. Run `wervt login --url <control url> --token <token>`.");
62
+ if (!url || !token) throw new Error("Not logged in. Run `wervt login --url <control url>`.");
63
63
  return {
64
64
  url: url.replace(/\/$/, ""),
65
65
  token
66
66
  };
67
67
  }
68
+ /** The saved control plane URL, if any. */
69
+ async function savedUrl() {
70
+ return JSON.parse(await readFile(file, "utf8").catch(() => "{}")).url;
71
+ }
68
72
  async function saveConfig(config) {
69
73
  await mkdir(dirname(file), { recursive: true });
70
74
  await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
@@ -600,31 +604,146 @@ const logs = defineCommand({
600
604
  for (const entry of entries.reverse()) printLog(entry, Boolean(args.json));
601
605
  }
602
606
  });
607
+ const whoami = defineCommand({
608
+ meta: {
609
+ name: "whoami",
610
+ description: "Show the control plane and what the token can do"
611
+ },
612
+ args: connection,
613
+ async run({ args }) {
614
+ const { url, token } = await resolveConfig(args);
615
+ const deployApp = token.match(/^wervtd_([a-z0-9-]+)_/)?.[1];
616
+ let role = deployApp ? `deploy token for ${deployApp}` : "unknown";
617
+ if (!deployApp) {
618
+ const res = await fetch(`${url}/v1/databases`, { headers: { authorization: `Bearer ${token}` } });
619
+ if (res.status === 401) throw new Error(`The token is not valid for ${url}`);
620
+ role = res.ok ? "admin" : res.status === 403 ? "read" : `unknown (${res.status})`;
621
+ }
622
+ console.log(`${url}\n${role}`);
623
+ }
624
+ });
625
+ const skill = defineCommand({
626
+ meta: {
627
+ name: "skill",
628
+ description: "Install the wervt agent skill"
629
+ },
630
+ subCommands: { install: defineCommand({
631
+ meta: {
632
+ name: "install",
633
+ description: "Write the wervt skill to ~/.claude/skills/wervt, so agents can build and ship apps"
634
+ },
635
+ args: {
636
+ dir: {
637
+ type: "string",
638
+ description: "Skills directory (default: ~/.claude/skills; .claude/skills for --project)"
639
+ },
640
+ project: {
641
+ type: "boolean",
642
+ description: "Install into this project instead"
643
+ }
644
+ },
645
+ async run({ args }) {
646
+ const base = args.dir ?? (args.project ? ".claude/skills" : join(homedir(), ".claude", "skills"));
647
+ const target = resolve(base, "wervt", "SKILL.md");
648
+ const content = await readFile(new URL("../skill/SKILL.md", import.meta.url), "utf8");
649
+ const previous = await readFile(target, "utf8").catch(() => void 0);
650
+ await mkdir(join(target, ".."), { recursive: true });
651
+ await writeFile(target, content);
652
+ console.log(`${previous === void 0 ? "Installed" : previous === content ? "Up to date" : "Updated"}: ${target}`);
653
+ }
654
+ }) }
655
+ });
656
+ /** Opens a URL in the default browser; quietly does nothing where that isn't possible. */
657
+ async function openBrowser(url) {
658
+ const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
659
+ "cmd",
660
+ "/c",
661
+ "start",
662
+ "",
663
+ url
664
+ ] : ["xdg-open", url];
665
+ try {
666
+ await x(command, args, {
667
+ throwOnError: false,
668
+ nodeOptions: { stdio: "ignore" }
669
+ });
670
+ } catch {}
671
+ }
672
+ /** Browser login: an admin approves this machine in the dashboard, the CLI gets its own token. */
673
+ async function browserLogin(url) {
674
+ const start = await fetch(`${url}/v1/login`, {
675
+ method: "POST",
676
+ headers: { "content-type": "application/json" },
677
+ body: JSON.stringify({ name: hostname() })
678
+ });
679
+ if (!start.ok) throw new Error(`Could not start the login: ${start.status} ${await start.text()}`);
680
+ const login = await start.json();
681
+ p.note(`${styleText("bold", login.userCode)}\n\nEnter it at ${login.verifyUrl}`, "Your one-time code");
682
+ const go = await p.confirm({
683
+ message: "Open the browser?",
684
+ initialValue: true
685
+ });
686
+ if (p.isCancel(go)) throw new Error("Cancelled");
687
+ if (go) await openBrowser(login.verifyUrl);
688
+ const spinner = p.spinner();
689
+ spinner.start("Waiting for approval in the dashboard");
690
+ const deadline = new Date(login.expiresAt).getTime();
691
+ while (Date.now() < deadline) {
692
+ await new Promise((done) => setTimeout(done, login.interval * 1e3));
693
+ const res = await fetch(`${url}/v1/login/poll`, {
694
+ method: "POST",
695
+ headers: { "content-type": "application/json" },
696
+ body: JSON.stringify({ deviceCode: login.deviceCode })
697
+ }).catch(() => void 0);
698
+ if (!res?.ok) continue;
699
+ const result = await res.json();
700
+ if (result.status === "pending") continue;
701
+ if (result.status === "approved" && result.token) {
702
+ spinner.stop("Approved");
703
+ return result.token;
704
+ }
705
+ spinner.error(result.status === "denied" ? "Denied in the dashboard" : "The login expired");
706
+ process.exit(1);
707
+ }
708
+ spinner.error("The login expired");
709
+ process.exit(1);
710
+ }
603
711
  const login = defineCommand({
604
712
  meta: {
605
713
  name: "login",
606
- description: "Save the control plane URL and token"
714
+ description: "Sign in: approve this machine in the dashboard (or pass a token)"
607
715
  },
608
716
  args: {
609
717
  url: {
610
718
  type: "string",
611
- required: true,
612
- description: "Control plane URL"
719
+ description: "Control plane URL, e.g. https://api.example.com"
613
720
  },
614
721
  token: {
615
722
  type: "string",
616
- required: true,
617
- description: "Admin, read or deploy token"
723
+ description: "Save this token instead (admin, read or a deploy token, e.g. for CI)"
618
724
  }
619
725
  },
620
726
  async run({ args }) {
621
- const res = await fetch(`${args.url.replace(/\/$/, "")}/v1/apps`, { headers: { authorization: `Bearer ${args.token}` } });
727
+ p.intro("wervt login");
728
+ let url = args.url ?? process.env.WERVT_URL ?? await savedUrl();
729
+ if (!url) {
730
+ const answer = await p.text({
731
+ message: "Control plane URL",
732
+ placeholder: "https://api.example.com",
733
+ validate: (value) => /^https?:\/\/\S+$/.test(value ?? "") ? void 0 : "An http(s) URL"
734
+ });
735
+ if (p.isCancel(answer)) return p.cancel("Cancelled");
736
+ url = answer;
737
+ }
738
+ url = url.replace(/\/$/, "");
739
+ const token = args.token ?? await browserLogin(url);
740
+ const res = await fetch(`${url}/v1/apps`, { headers: { authorization: `Bearer ${token}` } });
622
741
  if (!res.ok && res.status !== 403) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
623
742
  const file = await saveConfig({
624
- url: args.url,
625
- token: args.token
743
+ url,
744
+ token
626
745
  });
627
- console.log(`Saved to ${file}`);
746
+ p.outro(`Signed in to ${url} (saved to ${file})`);
628
747
  }
629
748
  });
630
749
  runMain(defineCommand({
@@ -644,7 +763,9 @@ runMain(defineCommand({
644
763
  env,
645
764
  tokens,
646
765
  delete: remove,
647
- login
766
+ login,
767
+ whoami,
768
+ skill
648
769
  }
649
770
  }));
650
771
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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,103 @@
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 `! npx -y @wervt/cli login --url <control plane URL>` themselves: it shows a code, they
22
+ approve it in the dashboard (CLI page) and the machine gets its own admin session. Never run the
23
+ login for them, never look for tokens in files, env or keychains, and never print or ask to paste
24
+ one into the chat.
25
+
26
+ ## 2. Scaffold
27
+
28
+ ```bash
29
+ pnpm create wervt <name> # a-z, 0-9 and dashes; becomes the subdomain
30
+ cd <name>
31
+ ```
32
+
33
+ The app comes with `AGENTS.md`, skills in `.claude/skills/` and a deploy workflow. **Read
34
+ `AGENTS.md` before writing code** and follow it: server routes, valibot validation, Drizzle schema
35
+ and migrations, live data with shapes, users, logging, and what the isolate runtime can't do
36
+ (no filesystem, no child processes, no long-running work, no WebSockets, no cron).
37
+
38
+ Before picking a name, run `wervt apps` so you don't overwrite an existing app. If the user named an
39
+ existing app, work in its repository instead.
40
+
41
+ ## 3. Build
42
+
43
+ - Keep the app small and complete: data in Postgres through `useDb()`, writes through API routes,
44
+ lists through `useShape` so every open tab stays live. Ask only when the request is genuinely
45
+ ambiguous; otherwise choose sensible defaults and say what you chose.
46
+ - Users: wervt signs people in. Use `useWervtUser()`; never build login, passwords or OAuth.
47
+ - After changing `server/db/schema.ts`: `pnpm db:generate`, and commit the migration.
48
+ - `pnpm dev` runs against a local wervt stack, which the user may not have. When it isn't running,
49
+ don't set one up: `pnpm build` plus `pnpm exec vp check --fix` are the checks, and the
50
+ deployed app is the test.
51
+ - Finish with `pnpm exec vp check --fix` passing.
52
+
53
+ ## 4. Ship
54
+
55
+ ```bash
56
+ pnpm exec wervt deploy # build, upload, migrate, activate
57
+ pnpm exec wervt access <name> --public # new apps are private (signed-in admins only)
58
+ pnpm exec wervt access <name> --add a@x.com,*@company.com # or let specific people in
59
+ pnpm exec wervt env <name> --set KEY=value # server env vars, applied from the next request
60
+ ```
61
+
62
+ - Ask before making an app public if it shows personal or private data. "Ship it" alone doesn't
63
+ decide that. Private, with the user's email allowed, is the safe default.
64
+ - Secrets for `wervt env` (API keys) come from the user. Ask them to run the command themselves
65
+ (`! pnpm exec wervt env <name> --set API_KEY=…`) instead of pasting the value to you.
66
+ - Then open the URL `wervt deploy` printed (curl it: expect 200, or a redirect to sign-in for
67
+ private apps) and check `pnpm exec wervt logs <name> 'level:error' --since 15m`. Fix and
68
+ redeploy until both are clean.
69
+
70
+ ## 5. Deploy on push (when the user wants a repository)
71
+
72
+ Only when asked, or when the user wants a GitHub repo for the app:
73
+
74
+ ```bash
75
+ git init -b main && git add -A && git commit -m "Initial app"
76
+ gh repo create <name> --private --source . --remote origin
77
+ pnpm exec wervt tokens <name> --create github | gh secret set WERVT_TOKEN -R <owner>/<name>
78
+ gh variable set WERVT_URL -R <owner>/<name> --body <control plane URL from whoami>
79
+ git push -u origin main
80
+ ```
81
+
82
+ The pipe hands the deploy token straight to GitHub; it never appears in the output. A deploy token
83
+ can only deploy that one app. Set the secret and variable **before** the first push, or the first
84
+ workflow run fails. Then `gh run watch` and check `pnpm exec wervt releases <name>`: the new
85
+ release lists the commit.
86
+
87
+ ## Existing apps
88
+
89
+ ```bash
90
+ pnpm exec wervt apps # every app, active release, URL
91
+ pnpm exec wervt releases <name> # newest first, with commit and author
92
+ pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
93
+ pnpm exec wervt rollback <name> # the previous release (migrations stay)
94
+ ```
95
+
96
+ For debugging, use the app's `wervt-logs` skill (logs live 14 days; queries are LogsQL).
97
+
98
+ ## Never without asking
99
+
100
+ `wervt delete` (never with `--drop-database` unless the user says so explicitly), `rollback`,
101
+ `activate`, revoking tokens, making a private app public, or deploying over an app you didn't
102
+ create in this conversation. Outside an app directory, `pnpm exec wervt` isn't installed: use
103
+ `npx -y @wervt/cli <command>`.