@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 +133 -12
- package/package.json +2 -1
- package/skill/SKILL.md +103 -0
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
625
|
-
token
|
|
743
|
+
url,
|
|
744
|
+
token
|
|
626
745
|
});
|
|
627
|
-
|
|
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.
|
|
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>`.
|