@wervt/cli 0.2.14 → 0.2.16
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/README.md +1 -1
- package/dist/index.mjs +417 -22
- package/package.json +3 -3
- package/skill/SKILL.md +22 -3
package/README.md
CHANGED
|
@@ -16,5 +16,5 @@ vp exec wervt logs my-app
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Without [Vite+](https://viteplus.dev), use `npx @wervt/cli` and `pnpm exec wervt`. `wervt --help`
|
|
19
|
-
lists every command: `apps`, `releases`, `rollback`, `activate`, `env`, `tokens`, `members`,
|
|
19
|
+
lists every command: `apps`, `releases`, `audit`, `rollback`, `activate`, `env`, `tokens`, `token`, `members`,
|
|
20
20
|
`tasks`, `stats`, `delete`, `whoami`, … Docs: [wervt.app](https://wervt.app/getting-started/quickstart).
|
package/dist/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { defineCommand, runMain } from "citty";
|
|
|
8
8
|
import { detectPackageManager } from "nypm";
|
|
9
9
|
import { x } from "tinyexec";
|
|
10
10
|
import { packTar } from "modern-tar";
|
|
11
|
-
|
|
11
|
+
// 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. */
|
|
14
14
|
async function walk(dir) {
|
|
@@ -51,8 +51,7 @@ async function createBundle(appDir) {
|
|
|
51
51
|
files: files.length
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
//#region src/config.ts
|
|
54
|
+
// src/config.ts
|
|
56
55
|
const file = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "wervt", "config.json");
|
|
57
56
|
/** Flags > WERVT_URL / WERVT_TOKEN > ~/.config/wervt/config.json. */
|
|
58
57
|
async function resolveConfig(flags) {
|
|
@@ -74,8 +73,62 @@ async function saveConfig(config) {
|
|
|
74
73
|
await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
75
74
|
return file;
|
|
76
75
|
}
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// src/format.ts
|
|
77
|
+
function formatBytes(bytes) {
|
|
78
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
79
|
+
const units = [
|
|
80
|
+
"KB",
|
|
81
|
+
"MB",
|
|
82
|
+
"GB"
|
|
83
|
+
];
|
|
84
|
+
let value = bytes / 1024;
|
|
85
|
+
let unit = 0;
|
|
86
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
87
|
+
value /= 1024;
|
|
88
|
+
unit++;
|
|
89
|
+
}
|
|
90
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
91
|
+
}
|
|
92
|
+
const list = (value) => Array.isArray(value) ? value.map(String).join(", ") : "";
|
|
93
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
94
|
+
/** What an audit entry changed, in a few words: `env.update` → `set API_URL, TOKEN`. */
|
|
95
|
+
function auditSummary(entry) {
|
|
96
|
+
const d = entry.details ?? {};
|
|
97
|
+
switch (entry.action) {
|
|
98
|
+
case "deploy":
|
|
99
|
+
case "deploy.failed": {
|
|
100
|
+
const migrations = list(d.migrations);
|
|
101
|
+
return [
|
|
102
|
+
text(d.release),
|
|
103
|
+
migrations && `migrated ${migrations}`,
|
|
104
|
+
text(d.checkError) && `checks failed: ${text(d.checkError)}`,
|
|
105
|
+
entry.action === "deploy.failed" && text(d.error).split("\n")[0]
|
|
106
|
+
].filter(Boolean).join(" ");
|
|
107
|
+
}
|
|
108
|
+
case "rollback":
|
|
109
|
+
case "activate": return `${text(d.previous) || "none"} → ${text(d.release)}`;
|
|
110
|
+
case "env.update": return [list(d.set) && `set ${list(d.set)}`, list(d.unset) && `unset ${list(d.unset)}`].filter(Boolean).join("; ");
|
|
111
|
+
case "access.update": return [
|
|
112
|
+
text(d.visibility),
|
|
113
|
+
list(d.allow) && `allow ${list(d.allow)}`,
|
|
114
|
+
list(d.add) && `add ${list(d.add)}`,
|
|
115
|
+
list(d.remove) && `remove ${list(d.remove)}`
|
|
116
|
+
].filter(Boolean).join("; ");
|
|
117
|
+
case "owners.update": return [
|
|
118
|
+
text(d.owner) && `owner ${text(d.owner)}`,
|
|
119
|
+
list(d.added) && `added ${list(d.added)}`,
|
|
120
|
+
list(d.removed) && `removed ${list(d.removed)}`
|
|
121
|
+
].filter(Boolean).join("; ");
|
|
122
|
+
case "token.create":
|
|
123
|
+
case "token.revoke": return text(d.name) || text(d.id);
|
|
124
|
+
case "client.create":
|
|
125
|
+
case "client.link":
|
|
126
|
+
case "client.delete": return text(d.name) || text(d.clientId);
|
|
127
|
+
case "app.delete": return `database ${text(d.database)}`;
|
|
128
|
+
default: return entry.account ?? "";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// src/source.ts
|
|
79
132
|
async function git(dir, ...args) {
|
|
80
133
|
try {
|
|
81
134
|
const result = await x("git", args, {
|
|
@@ -120,8 +173,7 @@ async function deploySource(dir) {
|
|
|
120
173
|
actor: await git(dir, "config", "user.name")
|
|
121
174
|
};
|
|
122
175
|
}
|
|
123
|
-
|
|
124
|
-
//#region src/index.ts
|
|
176
|
+
// src/index.ts
|
|
125
177
|
const interactive = process.stdout.isTTY === true && !process.env.CI;
|
|
126
178
|
function progress() {
|
|
127
179
|
if (interactive) return p.spinner();
|
|
@@ -294,6 +346,10 @@ const deploy = defineCommand({
|
|
|
294
346
|
process.exitCode = 1;
|
|
295
347
|
continue;
|
|
296
348
|
}
|
|
349
|
+
if (event.step === "quota-warning") {
|
|
350
|
+
p.log.warn(event.message);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
297
353
|
if (event.step === "storage-failed") {
|
|
298
354
|
p.log.warn(`Storage isn't set up: ${event.message}. The app runs; files won't work yet.`);
|
|
299
355
|
continue;
|
|
@@ -333,6 +389,7 @@ const rollback = defineCommand({
|
|
|
333
389
|
console.log(`${args.app} is now on release ${result.release}`);
|
|
334
390
|
}
|
|
335
391
|
});
|
|
392
|
+
const firstLine = (text) => text.split("\n")[0];
|
|
336
393
|
const releases = defineCommand({
|
|
337
394
|
meta: {
|
|
338
395
|
name: "releases",
|
|
@@ -349,16 +406,75 @@ const releases = defineCommand({
|
|
|
349
406
|
async run({ args }) {
|
|
350
407
|
const info = await (await api(`/v1/apps/${args.app}`, args)).json();
|
|
351
408
|
const history = new Map(info.history?.map((entry) => [entry.release, entry]));
|
|
352
|
-
|
|
353
|
-
|
|
409
|
+
const failed = new Map(info.failedDeploys?.map((entry) => [entry.release, entry]));
|
|
410
|
+
const ids = [...info.releases, ...failed.keys()].sort((a, b) => b.localeCompare(a));
|
|
411
|
+
for (const release of ids) {
|
|
412
|
+
const failure = failed.get(release);
|
|
413
|
+
const kept = history.get(release);
|
|
414
|
+
const source = failure?.source ?? kept?.source;
|
|
415
|
+
const commit = source?.commit ? `${source.commit.slice(0, 7)}${source.dirty ? "+" : ""}` : void 0;
|
|
416
|
+
const applied = kept?.migrations?.applied.length;
|
|
354
417
|
const details = [
|
|
355
|
-
|
|
418
|
+
commit,
|
|
356
419
|
source?.branch,
|
|
357
|
-
source?.actor ?? deployedBy,
|
|
420
|
+
source?.actor ?? failure?.actor ?? kept?.deployedBy,
|
|
421
|
+
kept?.size && formatBytes(kept.size.bundle),
|
|
422
|
+
applied && `${applied} migration${applied === 1 ? "" : "s"}`,
|
|
358
423
|
source?.message && styleText("dim", source.message)
|
|
359
424
|
].filter(Boolean);
|
|
360
|
-
const
|
|
361
|
-
|
|
425
|
+
const status = release === info.release ? styleText("green", " (active)") : failure ? styleText("red", " (failed)") : kept?.bootError ? styleText("red", " (didn't start)") : "";
|
|
426
|
+
const checks = kept?.checkError ? styleText("yellow", " (checks failed)") : "";
|
|
427
|
+
console.log(`${release}${status}${checks}${details.length ? ` ${details.join(" ")}` : ""}`);
|
|
428
|
+
const error = failure?.error ?? kept?.bootError ?? kept?.checkError;
|
|
429
|
+
if (error) console.log(styleText("dim", ` ${firstLine(error)}`));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
const audit = defineCommand({
|
|
434
|
+
meta: {
|
|
435
|
+
name: "audit",
|
|
436
|
+
description: "Show who deployed, rolled back, deleted or changed access, env and tokens"
|
|
437
|
+
},
|
|
438
|
+
args: {
|
|
439
|
+
app: {
|
|
440
|
+
type: "positional",
|
|
441
|
+
required: false,
|
|
442
|
+
description: "App name (default: all you see)"
|
|
443
|
+
},
|
|
444
|
+
action: {
|
|
445
|
+
type: "string",
|
|
446
|
+
description: "Only these actions, comma-separated; deploy* for all deploys"
|
|
447
|
+
},
|
|
448
|
+
limit: {
|
|
449
|
+
type: "string",
|
|
450
|
+
default: "50",
|
|
451
|
+
description: "Newest entries to show (max 1000)"
|
|
452
|
+
},
|
|
453
|
+
json: {
|
|
454
|
+
type: "boolean",
|
|
455
|
+
description: "One JSON object per line"
|
|
456
|
+
},
|
|
457
|
+
...connection
|
|
458
|
+
},
|
|
459
|
+
async run({ args }) {
|
|
460
|
+
const params = new URLSearchParams({ limit: args.limit });
|
|
461
|
+
if (args.action) params.set("action", args.action);
|
|
462
|
+
const entries = await (await api(`${args.app ? `/v1/apps/${args.app}/audit` : "/v1/audit"}?${params.toString()}`, args)).json();
|
|
463
|
+
if (!entries.length && !args.json) return console.error("No audit entries.");
|
|
464
|
+
for (const entry of entries.reverse()) {
|
|
465
|
+
if (args.json) {
|
|
466
|
+
console.log(JSON.stringify(entry));
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const time = new Date(entry.at).toISOString().slice(0, 16).replace("T", " ");
|
|
470
|
+
const color = entry.action === "deploy.failed" ? "red" : "cyan";
|
|
471
|
+
console.log([
|
|
472
|
+
styleText("dim", time),
|
|
473
|
+
styleText(color, entry.action.padEnd(15)),
|
|
474
|
+
(entry.app ?? "-").padEnd(16),
|
|
475
|
+
entry.actor,
|
|
476
|
+
styleText("dim", auditSummary(entry))
|
|
477
|
+
].join(" "));
|
|
362
478
|
}
|
|
363
479
|
}
|
|
364
480
|
});
|
|
@@ -441,6 +557,10 @@ const access = defineCommand({
|
|
|
441
557
|
type: "boolean",
|
|
442
558
|
description: "Make the app private, and let people with access invite others in the app"
|
|
443
559
|
},
|
|
560
|
+
"invite-by": {
|
|
561
|
+
type: "string",
|
|
562
|
+
description: "Who may invite on an invite-only app: anyone (with access, the default) or owners (owner, members and admins)"
|
|
563
|
+
},
|
|
444
564
|
add: {
|
|
445
565
|
type: "string",
|
|
446
566
|
description: "Allow emails or *@domain (comma separated)"
|
|
@@ -456,6 +576,7 @@ const access = defineCommand({
|
|
|
456
576
|
if (args.public) change.visibility = "public";
|
|
457
577
|
if (args.private) change.visibility = "private";
|
|
458
578
|
if (args.invite) change.visibility = "invite";
|
|
579
|
+
if (args["invite-by"]) change.inviteBy = args["invite-by"];
|
|
459
580
|
if (args.add) change.add = toList(args.add);
|
|
460
581
|
if (args.remove) change.remove = toList(args.remove);
|
|
461
582
|
const result = await (Object.keys(change).length ? await api(`/v1/apps/${args.app}/access`, args, {
|
|
@@ -468,6 +589,7 @@ const access = defineCommand({
|
|
|
468
589
|
if (result.visibility === "public") return;
|
|
469
590
|
console.log(result.allow.length ? `allowed: ${result.allow.join(", ")}` : "allowed: admins only");
|
|
470
591
|
if (result.visibility === "invite") {
|
|
592
|
+
console.log(result.inviteBy === "owners" ? "who invites: the owner, members and admins" : "who invites: everyone with access");
|
|
471
593
|
const invited = result.invited ?? [];
|
|
472
594
|
console.log(invited.length ? "invited:" : "invited: nobody yet");
|
|
473
595
|
for (const invite of invited) console.log(` ${invite.email} (by ${invite.by}, ${invite.at.slice(0, 10)})`);
|
|
@@ -542,7 +664,7 @@ const env = defineCommand({
|
|
|
542
664
|
return [pair.slice(0, i), pair.slice(i + 1)];
|
|
543
665
|
}));
|
|
544
666
|
const unset = toList(args.unset);
|
|
545
|
-
const { keys } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
|
|
667
|
+
const { keys, warnings = [] } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
|
|
546
668
|
method: "PATCH",
|
|
547
669
|
headers: { "content-type": "application/json" },
|
|
548
670
|
body: JSON.stringify({
|
|
@@ -551,13 +673,38 @@ const env = defineCommand({
|
|
|
551
673
|
})
|
|
552
674
|
}) : await api(`/v1/apps/${args.app}/env`, args)).json();
|
|
553
675
|
console.log(keys.length ? keys.join("\n") : "No variables set.");
|
|
676
|
+
for (const warning of warnings) console.error(styleText("yellow", `Warning: ${warning}`));
|
|
554
677
|
if (Object.keys(set).length || unset.length) console.log("Applies from the next request.");
|
|
555
678
|
}
|
|
556
679
|
});
|
|
680
|
+
const SIZE_FLAGS = [
|
|
681
|
+
"database",
|
|
682
|
+
"storage",
|
|
683
|
+
"releases"
|
|
684
|
+
];
|
|
685
|
+
/** Bytes as people read them: `1.5 GB`, `200 MB`; `no limit` for null. */
|
|
686
|
+
function size(bytes) {
|
|
687
|
+
if (bytes === null || bytes === void 0) return "no limit";
|
|
688
|
+
const units = [
|
|
689
|
+
"B",
|
|
690
|
+
"KB",
|
|
691
|
+
"MB",
|
|
692
|
+
"GB",
|
|
693
|
+
"TB"
|
|
694
|
+
];
|
|
695
|
+
let value = bytes;
|
|
696
|
+
let unit = 0;
|
|
697
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
698
|
+
value /= 1024;
|
|
699
|
+
unit++;
|
|
700
|
+
}
|
|
701
|
+
return `${Number(value.toFixed(value < 10 && unit ? 1 : 0))} ${units[unit]}`;
|
|
702
|
+
}
|
|
703
|
+
const describeLimits = (limits) => `${limits.apps ?? "no limit"} apps, database ${size(limits.database)} and files ${size(limits.storage)} per app, releases ${size(limits.releases)}`;
|
|
557
704
|
const accounts = defineCommand({
|
|
558
705
|
meta: {
|
|
559
706
|
name: "accounts",
|
|
560
|
-
description: "Admins: list accounts and their
|
|
707
|
+
description: "Admins: list accounts and their limits, or change them"
|
|
561
708
|
},
|
|
562
709
|
args: {
|
|
563
710
|
email: {
|
|
@@ -569,21 +716,35 @@ const accounts = defineCommand({
|
|
|
569
716
|
type: "string",
|
|
570
717
|
description: "Apps the account may own: a number, \"none\" for no limit, \"default\""
|
|
571
718
|
},
|
|
719
|
+
database: {
|
|
720
|
+
type: "string",
|
|
721
|
+
description: "Database size per app, e.g. 2GB; past it deploys are refused. \"none\", \"default\""
|
|
722
|
+
},
|
|
723
|
+
storage: {
|
|
724
|
+
type: "string",
|
|
725
|
+
description: "Files per app (bucket quota), e.g. 10GB. \"none\", \"default\""
|
|
726
|
+
},
|
|
727
|
+
releases: {
|
|
728
|
+
type: "string",
|
|
729
|
+
description: "Space all releases of their apps may take, e.g. 2GB. \"none\", \"default\""
|
|
730
|
+
},
|
|
572
731
|
...connection
|
|
573
732
|
},
|
|
574
733
|
async run({ args }) {
|
|
575
734
|
if (args.email) {
|
|
576
|
-
|
|
577
|
-
|
|
735
|
+
const change = {};
|
|
736
|
+
if (args.apps) change.apps = args.apps === "none" ? null : args.apps === "default" ? "default" : Number(args.apps);
|
|
737
|
+
for (const flag of SIZE_FLAGS) if (args[flag]) change[flag] = args[flag] === "none" ? null : args[flag];
|
|
738
|
+
if (!Object.keys(change).length) throw new Error("Pass --apps, --database, --storage or --releases (a value, none or default)");
|
|
578
739
|
const result = await (await api(`/v1/accounts/${encodeURIComponent(args.email)}`, args, {
|
|
579
740
|
method: "PATCH",
|
|
580
741
|
headers: { "content-type": "application/json" },
|
|
581
|
-
body: JSON.stringify(
|
|
742
|
+
body: JSON.stringify(change)
|
|
582
743
|
})).json();
|
|
583
|
-
return console.log(`${result.email}: ${result.limits
|
|
744
|
+
return console.log(`${result.email}: ${describeLimits(result.limits)}`);
|
|
584
745
|
}
|
|
585
746
|
const result = await (await api("/v1/accounts", args)).json();
|
|
586
|
-
console.log(`default: ${result.defaults
|
|
747
|
+
console.log(`default: ${describeLimits(result.defaults)}`);
|
|
587
748
|
for (const account of result.accounts) {
|
|
588
749
|
const list = account.apps.length ? ` ${account.apps.join(", ")}` : "";
|
|
589
750
|
if (account.admin) {
|
|
@@ -593,6 +754,7 @@ const accounts = defineCommand({
|
|
|
593
754
|
const limit = account.limits.apps ?? "no limit";
|
|
594
755
|
const own = "apps" in account.custom ? "" : " (default)";
|
|
595
756
|
console.log(`${account.email} ${account.apps.length}/${limit}${own}${list}`);
|
|
757
|
+
if (SIZE_FLAGS.some((flag) => flag in account.custom)) console.log(styleText("dim", ` database ${size(account.limits.database)}, files ${size(account.limits.storage)} per app, releases ${size(account.limits.releases)}`));
|
|
596
758
|
}
|
|
597
759
|
}
|
|
598
760
|
});
|
|
@@ -642,6 +804,236 @@ const tokens = defineCommand({
|
|
|
642
804
|
}
|
|
643
805
|
}
|
|
644
806
|
});
|
|
807
|
+
const appArg = {
|
|
808
|
+
type: "positional",
|
|
809
|
+
required: true,
|
|
810
|
+
description: "App name"
|
|
811
|
+
};
|
|
812
|
+
const clients = defineCommand({
|
|
813
|
+
meta: {
|
|
814
|
+
name: "clients",
|
|
815
|
+
description: "Manage the OAuth clients of an app's native apps (sign-in with wervt)"
|
|
816
|
+
},
|
|
817
|
+
subCommands: {
|
|
818
|
+
list: defineCommand({
|
|
819
|
+
meta: {
|
|
820
|
+
name: "list",
|
|
821
|
+
description: "List the app's native app clients"
|
|
822
|
+
},
|
|
823
|
+
args: {
|
|
824
|
+
app: appArg,
|
|
825
|
+
...connection
|
|
826
|
+
},
|
|
827
|
+
async run({ args }) {
|
|
828
|
+
const list = await (await api(`/v1/apps/${args.app}/clients`, args)).json();
|
|
829
|
+
if (!list.length) return console.log(`No native app clients for ${args.app}.`);
|
|
830
|
+
for (const client of list) {
|
|
831
|
+
const used = client.lastUsedAt ? `used ${client.lastUsedAt.slice(0, 10)}` : "never used";
|
|
832
|
+
console.log(`${client.clientId} ${client.name} created ${client.createdAt.slice(0, 10)} ${used}`);
|
|
833
|
+
for (const uri of client.redirectUris) console.log(styleText("dim", ` ${uri}`));
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}),
|
|
837
|
+
create: defineCommand({
|
|
838
|
+
meta: {
|
|
839
|
+
name: "create",
|
|
840
|
+
description: "Register a native app for sign-in, e.g. --redirect dev.example.app:/auth"
|
|
841
|
+
},
|
|
842
|
+
args: {
|
|
843
|
+
app: appArg,
|
|
844
|
+
name: {
|
|
845
|
+
type: "string",
|
|
846
|
+
required: true,
|
|
847
|
+
description: "Shown on the consent page"
|
|
848
|
+
},
|
|
849
|
+
redirect: {
|
|
850
|
+
type: "string",
|
|
851
|
+
required: true,
|
|
852
|
+
description: "Redirect URI; comma-separated for several"
|
|
853
|
+
},
|
|
854
|
+
...connection
|
|
855
|
+
},
|
|
856
|
+
async run({ args }) {
|
|
857
|
+
const client = await (await api(`/v1/apps/${args.app}/clients`, args, {
|
|
858
|
+
method: "POST",
|
|
859
|
+
headers: { "content-type": "application/json" },
|
|
860
|
+
body: JSON.stringify({
|
|
861
|
+
name: args.name,
|
|
862
|
+
redirectUris: toList(args.redirect)
|
|
863
|
+
})
|
|
864
|
+
})).json();
|
|
865
|
+
console.error(`Client "${client.name}" for ${args.app}:`);
|
|
866
|
+
console.log(client.clientId);
|
|
867
|
+
console.error(styleText("dim", "A public client (PKCE, no secret): put the id in the app config."));
|
|
868
|
+
}
|
|
869
|
+
}),
|
|
870
|
+
delete: defineCommand({
|
|
871
|
+
meta: {
|
|
872
|
+
name: "delete",
|
|
873
|
+
description: "Delete a client; everyone signed in through it is signed out"
|
|
874
|
+
},
|
|
875
|
+
args: {
|
|
876
|
+
app: appArg,
|
|
877
|
+
clientId: {
|
|
878
|
+
type: "positional",
|
|
879
|
+
required: true,
|
|
880
|
+
description: "Client id"
|
|
881
|
+
},
|
|
882
|
+
yes: {
|
|
883
|
+
type: "boolean",
|
|
884
|
+
description: "Skip the confirmation"
|
|
885
|
+
},
|
|
886
|
+
...connection
|
|
887
|
+
},
|
|
888
|
+
async run({ args }) {
|
|
889
|
+
if (!args.yes) {
|
|
890
|
+
const ok = await p.confirm({
|
|
891
|
+
message: `Delete ${args.clientId}? Every install signed in through it is signed out.`,
|
|
892
|
+
initialValue: false
|
|
893
|
+
});
|
|
894
|
+
if (p.isCancel(ok) || !ok) return p.cancel("Cancelled");
|
|
895
|
+
}
|
|
896
|
+
await api(`/v1/apps/${args.app}/clients/${args.clientId}`, args, { method: "DELETE" });
|
|
897
|
+
console.log(`Deleted ${args.clientId}`);
|
|
898
|
+
}
|
|
899
|
+
}),
|
|
900
|
+
link: defineCommand({
|
|
901
|
+
meta: {
|
|
902
|
+
name: "link",
|
|
903
|
+
description: "Admins: assign a client that registered itself to the app"
|
|
904
|
+
},
|
|
905
|
+
args: {
|
|
906
|
+
app: appArg,
|
|
907
|
+
clientId: {
|
|
908
|
+
type: "positional",
|
|
909
|
+
required: true,
|
|
910
|
+
description: "Client id"
|
|
911
|
+
},
|
|
912
|
+
...connection
|
|
913
|
+
},
|
|
914
|
+
async run({ args }) {
|
|
915
|
+
await api(`/v1/apps/${args.app}/clients/${args.clientId}/link`, args, { method: "POST" });
|
|
916
|
+
console.log(`${args.clientId} belongs to ${args.app} now`);
|
|
917
|
+
}
|
|
918
|
+
})
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
const token = defineCommand({
|
|
922
|
+
meta: {
|
|
923
|
+
name: "token",
|
|
924
|
+
description: "Mint a short-lived access token for your account on an app, to test a private app (curl, Playwright)"
|
|
925
|
+
},
|
|
926
|
+
args: {
|
|
927
|
+
app: {
|
|
928
|
+
type: "positional",
|
|
929
|
+
required: true,
|
|
930
|
+
description: "App name"
|
|
931
|
+
},
|
|
932
|
+
ttl: {
|
|
933
|
+
type: "string",
|
|
934
|
+
default: "15m",
|
|
935
|
+
description: "Lifetime, 1m to 1h (default: 15m)"
|
|
936
|
+
},
|
|
937
|
+
cookie: {
|
|
938
|
+
type: "boolean",
|
|
939
|
+
default: false,
|
|
940
|
+
description: "Print a cookie for Playwright's context.addCookies() instead of the token"
|
|
941
|
+
},
|
|
942
|
+
json: {
|
|
943
|
+
type: "boolean",
|
|
944
|
+
default: false,
|
|
945
|
+
description: "Print token, cookie and expiry as JSON"
|
|
946
|
+
},
|
|
947
|
+
...connection
|
|
948
|
+
},
|
|
949
|
+
async run({ args }) {
|
|
950
|
+
const minted = await (await api(`/v1/apps/${args.app}/agent-tokens`, args, {
|
|
951
|
+
method: "POST",
|
|
952
|
+
headers: { "content-type": "application/json" },
|
|
953
|
+
body: JSON.stringify({ ttl: args.ttl })
|
|
954
|
+
})).json();
|
|
955
|
+
if (args.json) return console.log(JSON.stringify(minted, null, 2));
|
|
956
|
+
console.log(args.cookie ? JSON.stringify(minted.cookie) : minted.token);
|
|
957
|
+
console.error(styleText("dim", `${minted.app} as ${minted.user} until ${minted.expiresAt} (token ${minted.id}). ` + (args.cookie ? `Playwright: await context.addCookies([cookie]), then open ${minted.url}` : `curl -H "Authorization: Bearer $TOKEN" ${minted.url}/api/…`)));
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
function printIdentity(status) {
|
|
961
|
+
if (!status.configured) console.log("Workload identity is not configured on this server.");
|
|
962
|
+
console.log(status.enabled ? `${status.app} has a workload identity (since ${status.enabledAt?.slice(0, 10)}, by ${status.enabledBy})` : `${status.app} has no workload identity`);
|
|
963
|
+
console.log(`issuer: ${status.issuer}`);
|
|
964
|
+
console.log(`subject: ${status.subject ?? "(the app has no owner yet)"}`);
|
|
965
|
+
console.log(`audiences: ${status.audiences.join(", ")}`);
|
|
966
|
+
}
|
|
967
|
+
const identityApp = (enabled) => defineCommand({
|
|
968
|
+
meta: {
|
|
969
|
+
name: enabled === void 0 ? "status" : enabled ? "enable" : "disable",
|
|
970
|
+
description: enabled === void 0 ? "Show an app's workload identity and the values a provider rule needs" : enabled ? "Owners: let the app get identity tokens, for provider rules that trust it" : "Owners: stop signing identity tokens for the app"
|
|
971
|
+
},
|
|
972
|
+
args: {
|
|
973
|
+
app: {
|
|
974
|
+
type: "positional",
|
|
975
|
+
required: true,
|
|
976
|
+
description: "App name"
|
|
977
|
+
},
|
|
978
|
+
...connection
|
|
979
|
+
},
|
|
980
|
+
async run({ args }) {
|
|
981
|
+
printIdentity(await (enabled === void 0 ? await api(`/v1/apps/${args.app}/identity`, args) : await api(`/v1/apps/${args.app}/identity`, args, {
|
|
982
|
+
method: "PATCH",
|
|
983
|
+
headers: { "content-type": "application/json" },
|
|
984
|
+
body: JSON.stringify({ enabled })
|
|
985
|
+
})).json());
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
const identity = defineCommand({
|
|
989
|
+
meta: {
|
|
990
|
+
name: "identity",
|
|
991
|
+
description: "Workload identity: apps authenticate to AI providers without API keys"
|
|
992
|
+
},
|
|
993
|
+
subCommands: {
|
|
994
|
+
status: identityApp(),
|
|
995
|
+
enable: identityApp(true),
|
|
996
|
+
disable: identityApp(false),
|
|
997
|
+
jwks: defineCommand({
|
|
998
|
+
meta: {
|
|
999
|
+
name: "jwks",
|
|
1000
|
+
description: "Print the issuer's public keys, for providers that can't fetch them"
|
|
1001
|
+
},
|
|
1002
|
+
args: connection,
|
|
1003
|
+
async run({ args }) {
|
|
1004
|
+
const res = await api("/.well-known/jwks.json", args);
|
|
1005
|
+
console.log(JSON.stringify(await res.json(), null, 2));
|
|
1006
|
+
}
|
|
1007
|
+
}),
|
|
1008
|
+
keygen: defineCommand({
|
|
1009
|
+
meta: {
|
|
1010
|
+
name: "keygen",
|
|
1011
|
+
description: "Print a new private signing key for WERVT_IDENTITY_KEY (runs locally)"
|
|
1012
|
+
},
|
|
1013
|
+
args: { kid: {
|
|
1014
|
+
type: "string",
|
|
1015
|
+
description: "Key id (default: wervt-<date>)"
|
|
1016
|
+
} },
|
|
1017
|
+
async run({ args }) {
|
|
1018
|
+
const pair = await crypto.subtle.generateKey({
|
|
1019
|
+
name: "ECDSA",
|
|
1020
|
+
namedCurve: "P-256"
|
|
1021
|
+
}, true, ["sign", "verify"]);
|
|
1022
|
+
const { kty, crv, x, y, d } = await crypto.subtle.exportKey("jwk", pair.privateKey);
|
|
1023
|
+
const kid = args.kid ?? `wervt-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
|
|
1024
|
+
console.log(JSON.stringify({
|
|
1025
|
+
kty,
|
|
1026
|
+
crv,
|
|
1027
|
+
x,
|
|
1028
|
+
y,
|
|
1029
|
+
d,
|
|
1030
|
+
kid
|
|
1031
|
+
}));
|
|
1032
|
+
console.error(styleText("dim", "Private: set it as WERVT_IDENTITY_KEY on control only, never in an app or a repo."));
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
645
1037
|
const tasks = defineCommand({
|
|
646
1038
|
meta: {
|
|
647
1039
|
name: "tasks",
|
|
@@ -722,7 +1114,7 @@ const remove = defineCommand({
|
|
|
722
1114
|
const dropDb = Boolean(args["drop-database"]);
|
|
723
1115
|
if (!args.yes) {
|
|
724
1116
|
const ok = await p.confirm({
|
|
725
|
-
message: `Delete ${args.app}${dropDb ? " and ALL its data" : " (
|
|
1117
|
+
message: `Delete ${args.app}${dropDb ? " and ALL its data" : " (data is kept, the name stays yours)"}?`,
|
|
726
1118
|
initialValue: false
|
|
727
1119
|
});
|
|
728
1120
|
if (p.isCancel(ok) || !ok) return p.cancel("Cancelled");
|
|
@@ -1030,6 +1422,7 @@ runMain(defineCommand({
|
|
|
1030
1422
|
deploy,
|
|
1031
1423
|
apps,
|
|
1032
1424
|
releases,
|
|
1425
|
+
audit,
|
|
1033
1426
|
logs,
|
|
1034
1427
|
activate,
|
|
1035
1428
|
rollback,
|
|
@@ -1039,6 +1432,9 @@ runMain(defineCommand({
|
|
|
1039
1432
|
accounts,
|
|
1040
1433
|
env,
|
|
1041
1434
|
tokens,
|
|
1435
|
+
token,
|
|
1436
|
+
clients,
|
|
1437
|
+
identity,
|
|
1042
1438
|
tasks,
|
|
1043
1439
|
stats,
|
|
1044
1440
|
delete: remove,
|
|
@@ -1047,5 +1443,4 @@ runMain(defineCommand({
|
|
|
1047
1443
|
skill
|
|
1048
1444
|
}
|
|
1049
1445
|
}));
|
|
1050
|
-
//#endregion
|
|
1051
1446
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wervt/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "Deploy and manage apps on wervt.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^1.8.1",
|
|
32
32
|
"citty": "^0.2.2",
|
|
33
|
-
"modern-tar": "^0.8.
|
|
34
|
-
"nypm": "^0.6.
|
|
33
|
+
"modern-tar": "^0.8.6",
|
|
34
|
+
"nypm": "^0.6.12",
|
|
35
35
|
"tinyexec": "^1.3.1"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
package/skill/SKILL.md
CHANGED
|
@@ -66,19 +66,37 @@ pnpm exec wervt deploy # build, upload, migrate, acti
|
|
|
66
66
|
pnpm exec wervt access <name> --public # new apps are private (owner, members, admins)
|
|
67
67
|
pnpm exec wervt access <name> --add a@x.com,*@company.com # or let specific people in
|
|
68
68
|
pnpm exec wervt access <name> --invite # private, and people with access invite others (useWervtAccess)
|
|
69
|
+
pnpm exec wervt access <name> --invite-by owners # only owner, members and admins may invite
|
|
69
70
|
pnpm exec wervt members <name> --add a@x.com # who may deploy and manage it (the first deploy owns it)
|
|
70
|
-
pnpm exec wervt env <name> --set
|
|
71
|
+
pnpm exec wervt env <name> --set NUXT_API_KEY=… # runtimeConfig.apiKey, applied from the next request
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
- Ask before making an app public if it shows personal or private data. "Ship it" alone doesn't
|
|
74
75
|
decide that. Private, with the user's email allowed, is the safe default.
|
|
76
|
+
- App configuration and secrets go through Nuxt's `runtimeConfig`, never `process.env` in app
|
|
77
|
+
code: declare the key with a default in `nuxt.config.ts` (`runtimeConfig: { apiKey: '' }`), read
|
|
78
|
+
it with `useRuntimeConfig()` and set it as `NUXT_` + the key in SCREAMING_SNAKE_CASE
|
|
79
|
+
(`apiKey` → `NUXT_API_KEY`, `public.mapStyle` → `NUXT_PUBLIC_MAP_STYLE`). Keys missing from
|
|
80
|
+
`runtimeConfig` are ignored.
|
|
75
81
|
- Secrets for `wervt env` (API keys) come from the user. Ask them to run the command themselves
|
|
76
|
-
(`! pnpm exec wervt env <name> --set
|
|
82
|
+
(`! pnpm exec wervt env <name> --set NUXT_API_KEY=…`) instead of pasting the value to you.
|
|
77
83
|
- `wervt deploy` requests `/` once the release started and fails (exit 1, with the first logged
|
|
78
84
|
error) on a 5xx. Add the pages you changed: `--check /,/todos,/api/todos`. The checks are
|
|
79
85
|
anonymous, so pages behind a sign-in only show they don't crash without a user.
|
|
80
86
|
- Then open the URL `wervt deploy` printed and check
|
|
81
87
|
`pnpm exec wervt logs <name> 'level:error' --since 15m`. Fix and redeploy until both are clean.
|
|
88
|
+
- Check what's behind the login yourself, don't ask the user to. `wervt token` mints a token for
|
|
89
|
+
your CLI account on this app (15 minutes, `--ttl 1h` at most), and the app sees that account:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
TOKEN=$(pnpm exec wervt token <name>)
|
|
93
|
+
curl -sH "Authorization: Bearer $TOKEN" <url>/api/… # protected API routes
|
|
94
|
+
pnpm exec wervt token <name> --cookie > /tmp/wervt-cookie.json # for a headless browser
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
In Playwright, `await context.addCookies([JSON.parse(cookieJson)])`, then open the URL to check
|
|
98
|
+
pages, icons and uploads. Works for owners, members and admins signed in with `wervt login`
|
|
99
|
+
(not with a deploy or read token). Never paste the token into files or commits.
|
|
82
100
|
|
|
83
101
|
## 5. Deploy on push (when the user wants a repository)
|
|
84
102
|
|
|
@@ -101,7 +119,8 @@ release lists the commit.
|
|
|
101
119
|
|
|
102
120
|
```bash
|
|
103
121
|
pnpm exec wervt apps # every app, active release, URL
|
|
104
|
-
pnpm exec wervt releases <name> # newest first, with commit
|
|
122
|
+
pnpm exec wervt releases <name> # newest first, with commit, author, failed deploys
|
|
123
|
+
pnpm exec wervt audit <name> # who deployed, rolled back, changed env or access
|
|
105
124
|
pnpm exec wervt logs <name> 'status:>=500' --since 1d --json
|
|
106
125
|
pnpm exec wervt tasks <name> # tasks, schedules, next runs
|
|
107
126
|
pnpm exec wervt tasks <name> --run <task> # run one now
|