@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 +169 -9
- package/package.json +2 -1
- package/skill/SKILL.md +102 -0
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: "
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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.
|
|
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>`.
|