@wervt/cli 0.2.2 → 0.2.4
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 +137 -11
- package/package.json +1 -1
- package/skill/SKILL.md +8 -4
package/dist/index.mjs
CHANGED
|
@@ -1,6 +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 } from "node:os";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { styleText } from "node:util";
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
@@ -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 });
|
|
@@ -489,6 +493,61 @@ const tokens = defineCommand({
|
|
|
489
493
|
}
|
|
490
494
|
}
|
|
491
495
|
});
|
|
496
|
+
const tasks = defineCommand({
|
|
497
|
+
meta: {
|
|
498
|
+
name: "tasks",
|
|
499
|
+
description: "List an app's tasks and schedules, or run one now"
|
|
500
|
+
},
|
|
501
|
+
args: {
|
|
502
|
+
app: {
|
|
503
|
+
type: "positional",
|
|
504
|
+
required: true,
|
|
505
|
+
description: "App name"
|
|
506
|
+
},
|
|
507
|
+
run: {
|
|
508
|
+
type: "string",
|
|
509
|
+
description: "Run this task now and wait for it"
|
|
510
|
+
},
|
|
511
|
+
payload: {
|
|
512
|
+
type: "string",
|
|
513
|
+
description: "JSON payload for --run"
|
|
514
|
+
},
|
|
515
|
+
json: {
|
|
516
|
+
type: "boolean",
|
|
517
|
+
description: "Print JSON"
|
|
518
|
+
},
|
|
519
|
+
...connection
|
|
520
|
+
},
|
|
521
|
+
async run({ args }) {
|
|
522
|
+
if (args.run) {
|
|
523
|
+
const payload = args.payload ? JSON.parse(args.payload) : {};
|
|
524
|
+
const result = await (await api(`/v1/apps/${args.app}/tasks/${encodeURIComponent(args.run)}/run`, args, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: { "content-type": "application/json" },
|
|
527
|
+
body: JSON.stringify({ payload })
|
|
528
|
+
})).json();
|
|
529
|
+
if (args.json) console.log(JSON.stringify(result));
|
|
530
|
+
else {
|
|
531
|
+
const color = result.status === "ok" ? "green" : result.status === "error" ? "red" : "yellow";
|
|
532
|
+
const took = result.durationMs === void 0 ? "" : ` in ${result.durationMs} ms`;
|
|
533
|
+
console.log(`${styleText(color, result.status)}${took}${result.error ? `: ${result.error}` : ""}`);
|
|
534
|
+
if (result.result !== void 0 && result.result !== null) console.log(JSON.stringify(result.result, null, 2));
|
|
535
|
+
}
|
|
536
|
+
if (result.status !== "ok") process.exitCode = 1;
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const list = await (await api(`/v1/apps/${args.app}/tasks`, args)).json();
|
|
540
|
+
if (args.json) return console.log(JSON.stringify(list));
|
|
541
|
+
if (!list.tasks.length) return console.log(`${args.app} has no tasks.`);
|
|
542
|
+
console.log(styleText("dim", `Schedules in ${list.timezone}`));
|
|
543
|
+
for (const task of list.tasks) {
|
|
544
|
+
const schedules = task.schedules.length ? task.schedules.map((s) => `${s.cron}${s.next ? styleText("dim", ` (next ${s.next})`) : ""}`).join(", ") : styleText("dim", "manual only");
|
|
545
|
+
console.log(`${styleText("bold", task.name)}${task.running ? styleText("cyan", " running") : ""} ${schedules}`);
|
|
546
|
+
if (task.description) console.log(` ${styleText("dim", task.description)}`);
|
|
547
|
+
}
|
|
548
|
+
console.log(styleText("dim", `\nRecent runs: wervt logs ${args.app} 'type:=Task' --since 1d`));
|
|
549
|
+
}
|
|
550
|
+
});
|
|
492
551
|
const remove = defineCommand({
|
|
493
552
|
meta: {
|
|
494
553
|
name: "delete",
|
|
@@ -649,31 +708,97 @@ const skill = defineCommand({
|
|
|
649
708
|
}
|
|
650
709
|
}) }
|
|
651
710
|
});
|
|
711
|
+
/** Opens a URL in the default browser; quietly does nothing where that isn't possible. */
|
|
712
|
+
async function openBrowser(url) {
|
|
713
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
714
|
+
"cmd",
|
|
715
|
+
"/c",
|
|
716
|
+
"start",
|
|
717
|
+
"",
|
|
718
|
+
url
|
|
719
|
+
] : ["xdg-open", url];
|
|
720
|
+
try {
|
|
721
|
+
await x(command, args, {
|
|
722
|
+
throwOnError: false,
|
|
723
|
+
nodeOptions: { stdio: "ignore" }
|
|
724
|
+
});
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
/** Browser login: an admin approves this machine in the dashboard, the CLI gets its own token. */
|
|
728
|
+
async function browserLogin(url) {
|
|
729
|
+
const start = await fetch(`${url}/v1/login`, {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { "content-type": "application/json" },
|
|
732
|
+
body: JSON.stringify({ name: hostname() })
|
|
733
|
+
});
|
|
734
|
+
if (!start.ok) throw new Error(`Could not start the login: ${start.status} ${await start.text()}`);
|
|
735
|
+
const login = await start.json();
|
|
736
|
+
p.note(`${styleText("bold", login.userCode)}\n\nEnter it at ${login.verifyUrl}`, "Your one-time code");
|
|
737
|
+
const go = await p.confirm({
|
|
738
|
+
message: "Open the browser?",
|
|
739
|
+
initialValue: true
|
|
740
|
+
});
|
|
741
|
+
if (p.isCancel(go)) throw new Error("Cancelled");
|
|
742
|
+
if (go) await openBrowser(login.verifyUrl);
|
|
743
|
+
const spinner = p.spinner();
|
|
744
|
+
spinner.start("Waiting for approval in the dashboard");
|
|
745
|
+
const deadline = new Date(login.expiresAt).getTime();
|
|
746
|
+
while (Date.now() < deadline) {
|
|
747
|
+
await new Promise((done) => setTimeout(done, login.interval * 1e3));
|
|
748
|
+
const res = await fetch(`${url}/v1/login/poll`, {
|
|
749
|
+
method: "POST",
|
|
750
|
+
headers: { "content-type": "application/json" },
|
|
751
|
+
body: JSON.stringify({ deviceCode: login.deviceCode })
|
|
752
|
+
}).catch(() => void 0);
|
|
753
|
+
if (!res?.ok) continue;
|
|
754
|
+
const result = await res.json();
|
|
755
|
+
if (result.status === "pending") continue;
|
|
756
|
+
if (result.status === "approved" && result.token) {
|
|
757
|
+
spinner.stop("Approved");
|
|
758
|
+
return result.token;
|
|
759
|
+
}
|
|
760
|
+
spinner.error(result.status === "denied" ? "Denied in the dashboard" : "The login expired");
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
spinner.error("The login expired");
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
652
766
|
const login = defineCommand({
|
|
653
767
|
meta: {
|
|
654
768
|
name: "login",
|
|
655
|
-
description: "
|
|
769
|
+
description: "Sign in: approve this machine in the dashboard (or pass a token)"
|
|
656
770
|
},
|
|
657
771
|
args: {
|
|
658
772
|
url: {
|
|
659
773
|
type: "string",
|
|
660
|
-
|
|
661
|
-
description: "Control plane URL"
|
|
774
|
+
description: "Control plane URL, e.g. https://api.example.com"
|
|
662
775
|
},
|
|
663
776
|
token: {
|
|
664
777
|
type: "string",
|
|
665
|
-
|
|
666
|
-
description: "Admin, read or deploy token"
|
|
778
|
+
description: "Save this token instead (admin, read or a deploy token, e.g. for CI)"
|
|
667
779
|
}
|
|
668
780
|
},
|
|
669
781
|
async run({ args }) {
|
|
670
|
-
|
|
782
|
+
p.intro("wervt login");
|
|
783
|
+
let url = args.url ?? process.env.WERVT_URL ?? await savedUrl();
|
|
784
|
+
if (!url) {
|
|
785
|
+
const answer = await p.text({
|
|
786
|
+
message: "Control plane URL",
|
|
787
|
+
placeholder: "https://api.example.com",
|
|
788
|
+
validate: (value) => /^https?:\/\/\S+$/.test(value ?? "") ? void 0 : "An http(s) URL"
|
|
789
|
+
});
|
|
790
|
+
if (p.isCancel(answer)) return p.cancel("Cancelled");
|
|
791
|
+
url = answer;
|
|
792
|
+
}
|
|
793
|
+
url = url.replace(/\/$/, "");
|
|
794
|
+
const token = args.token ?? await browserLogin(url);
|
|
795
|
+
const res = await fetch(`${url}/v1/apps`, { headers: { authorization: `Bearer ${token}` } });
|
|
671
796
|
if (!res.ok && res.status !== 403) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
|
|
672
797
|
const file = await saveConfig({
|
|
673
|
-
url
|
|
674
|
-
token
|
|
798
|
+
url,
|
|
799
|
+
token
|
|
675
800
|
});
|
|
676
|
-
|
|
801
|
+
p.outro(`Signed in to ${url} (saved to ${file})`);
|
|
677
802
|
}
|
|
678
803
|
});
|
|
679
804
|
runMain(defineCommand({
|
|
@@ -692,6 +817,7 @@ runMain(defineCommand({
|
|
|
692
817
|
access,
|
|
693
818
|
env,
|
|
694
819
|
tokens,
|
|
820
|
+
tasks,
|
|
695
821
|
delete: remove,
|
|
696
822
|
login,
|
|
697
823
|
whoami,
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -18,9 +18,10 @@ npx -y @wervt/cli whoami
|
|
|
18
18
|
|
|
19
19
|
It prints the control plane URL and what the saved token can do (`admin`, `read`, or a deploy
|
|
20
20
|
token for one app). Shipping new apps needs `admin`. If it says "Not logged in", ask the user to
|
|
21
|
-
run
|
|
22
|
-
|
|
23
|
-
keychains, and never print or ask to paste
|
|
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.
|
|
24
25
|
|
|
25
26
|
## 2. Scaffold
|
|
26
27
|
|
|
@@ -32,7 +33,8 @@ cd <name>
|
|
|
32
33
|
The app comes with `AGENTS.md`, skills in `.claude/skills/` and a deploy workflow. **Read
|
|
33
34
|
`AGENTS.md` before writing code** and follow it: server routes, valibot validation, Drizzle schema
|
|
34
35
|
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
|
|
36
|
+
(no filesystem, no child processes, no long-running work, no WebSockets). Periodic work goes
|
|
37
|
+
into tasks (`defineTask` + `nitro.scheduledTasks`), which wervt runs on schedule.
|
|
36
38
|
|
|
37
39
|
Before picking a name, run `wervt apps` so you don't overwrite an existing app. If the user named an
|
|
38
40
|
existing app, work in its repository instead.
|
|
@@ -89,6 +91,8 @@ release lists the commit.
|
|
|
89
91
|
pnpm exec wervt apps # every app, active release, URL
|
|
90
92
|
pnpm exec wervt releases <name> # newest first, with commit and author
|
|
91
93
|
pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
|
|
94
|
+
pnpm exec wervt tasks <name> # tasks, schedules, next runs
|
|
95
|
+
pnpm exec wervt tasks <name> --run <task> # run one now
|
|
92
96
|
pnpm exec wervt rollback <name> # the previous release (migrations stay)
|
|
93
97
|
```
|
|
94
98
|
|