@wervt/cli 0.0.0 → 0.1.0

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/bin/wervt.js CHANGED
@@ -1,2 +1,6 @@
1
1
  #!/usr/bin/env node
2
- console.log('wervt is not released yet. Follow along at https://www.npmjs.com/org/wervt')
2
+ // Published builds run dist/; inside the wervt repo the TypeScript source runs directly.
3
+ import { existsSync } from 'node:fs'
4
+
5
+ const dist = new URL('../dist/index.mjs', import.meta.url)
6
+ await import(existsSync(dist) ? dist.href : new URL('../src/index.ts', import.meta.url).href)
package/dist/index.mjs ADDED
@@ -0,0 +1,458 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import * as p from "@clack/prompts";
5
+ import { defineCommand, runMain } from "citty";
6
+ import { detectPackageManager } from "nypm";
7
+ import { x } from "tinyexec";
8
+ import { packTar } from "modern-tar";
9
+ import { homedir } from "node:os";
10
+ //#region src/bundle.ts
11
+ /** Files under `dir`. Follows symlinks: Nitro can leave traced dependencies in
12
+ * server/node_modules as links into the local pnpm store, which won't exist on the server. */
13
+ async function walk(dir) {
14
+ const entries = await readdir(dir, { withFileTypes: true });
15
+ return (await Promise.all(entries.map(async (e) => {
16
+ const path = join(dir, e.name);
17
+ return (e.isSymbolicLink() ? (await stat(path)).isDirectory() : e.isDirectory()) ? walk(path) : [path];
18
+ }))).flat();
19
+ }
20
+ async function gzip(data) {
21
+ const stream = new Blob([data]).stream().pipeThrough(new CompressionStream("gzip"));
22
+ return new Uint8Array(await new Response(stream).arrayBuffer());
23
+ }
24
+ const exists = (path) => stat(path).then(() => true, () => false);
25
+ /** Packs `.output/server`, `.output/public` and `drizzle/` into the bundle the control plane expects. */
26
+ async function createBundle(appDir) {
27
+ const roots = [
28
+ [join(appDir, ".output/server"), "server"],
29
+ [join(appDir, ".output/public"), "public"],
30
+ [join(appDir, "drizzle"), "drizzle"]
31
+ ];
32
+ if (!await exists(roots[0][0])) throw new Error("No .output/server. Build the app first.");
33
+ const files = [];
34
+ for (const [dir, prefix] of roots) {
35
+ if (!await exists(dir)) continue;
36
+ for (const path of await walk(dir)) {
37
+ if (path.endsWith(".map")) continue;
38
+ const data = await readFile(path);
39
+ files.push({
40
+ header: {
41
+ name: `${prefix}/${relative(dir, path)}`,
42
+ size: data.byteLength
43
+ },
44
+ body: data
45
+ });
46
+ }
47
+ }
48
+ return {
49
+ data: await gzip(await packTar(files)),
50
+ files: files.length
51
+ };
52
+ }
53
+ //#endregion
54
+ //#region src/config.ts
55
+ const file = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "wervt", "config.json");
56
+ /** Flags > WERVT_URL / WERVT_TOKEN > ~/.config/wervt/config.json. */
57
+ async function resolveConfig(flags) {
58
+ const saved = JSON.parse(await readFile(file, "utf8").catch(() => "{}"));
59
+ const url = flags.url ?? process.env.WERVT_URL ?? saved.url;
60
+ const token = flags.token ?? process.env.WERVT_TOKEN ?? saved.token;
61
+ if (!url || !token) throw new Error("Not logged in. Run `wervt login --url <control url> --token <token>`.");
62
+ return {
63
+ url: url.replace(/\/$/, ""),
64
+ token
65
+ };
66
+ }
67
+ async function saveConfig(config) {
68
+ await mkdir(dirname(file), { recursive: true });
69
+ await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
70
+ return file;
71
+ }
72
+ //#endregion
73
+ //#region src/index.ts
74
+ const connection = {
75
+ url: {
76
+ type: "string",
77
+ description: "Control plane URL (or WERVT_URL)"
78
+ },
79
+ token: {
80
+ type: "string",
81
+ description: "Deploy token (or WERVT_TOKEN)"
82
+ }
83
+ };
84
+ async function api(path, flags, init = {}) {
85
+ const { url, token } = await resolveConfig(flags);
86
+ const headers = new Headers(init.headers);
87
+ headers.set("authorization", `Bearer ${token}`);
88
+ const res = await fetch(`${url}${path}`, {
89
+ ...init,
90
+ headers
91
+ });
92
+ if (!res.ok) {
93
+ const body = await res.text();
94
+ throw new Error(`${res.status} ${res.statusText}: ${body}`);
95
+ }
96
+ return res;
97
+ }
98
+ /** App directory from the argument: a path, or an app name inside the wervt repo's apps/. */
99
+ function resolveAppDir(arg = ".") {
100
+ if (existsSync(join(arg, "package.json"))) return resolve(arg);
101
+ if (existsSync(join("apps", arg, "package.json"))) return resolve("apps", arg);
102
+ throw new Error(`No app found at ${arg}`);
103
+ }
104
+ async function appName(dir, name) {
105
+ if (!name) {
106
+ const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
107
+ name = String(pkg.name ?? "").replace(/^@[^/]+\//, "");
108
+ }
109
+ const normalized = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
110
+ if (!normalized) throw new Error("Could not determine the app name, pass --name");
111
+ return normalized;
112
+ }
113
+ const deploy = defineCommand({
114
+ meta: {
115
+ name: "deploy",
116
+ description: "Build an app and deploy it"
117
+ },
118
+ args: {
119
+ dir: {
120
+ type: "positional",
121
+ required: false,
122
+ description: "App directory (default: .)"
123
+ },
124
+ name: {
125
+ type: "string",
126
+ description: "App name (default: package.json name)"
127
+ },
128
+ build: {
129
+ type: "boolean",
130
+ default: true,
131
+ description: "Build before deploying"
132
+ },
133
+ ...connection
134
+ },
135
+ async run({ args }) {
136
+ const dir = resolveAppDir(args.dir);
137
+ const name = await appName(dir, args.name);
138
+ p.intro(`wervt deploy ${name}`);
139
+ if (args.build) {
140
+ const pm = (await detectPackageManager(dir))?.name ?? "npm";
141
+ p.log.step(`${pm} run build`);
142
+ if ((await x(pm, ["run", "build"], {
143
+ nodeOptions: {
144
+ cwd: dir,
145
+ stdio: "inherit"
146
+ },
147
+ throwOnError: false
148
+ })).exitCode !== 0) throw new Error("Build failed");
149
+ }
150
+ const spinner = p.spinner();
151
+ spinner.start("Packing");
152
+ const bundle = await createBundle(dir);
153
+ spinner.stop(`Packed ${bundle.files} files (${(bundle.data.byteLength / 1024).toFixed(0)} kB)`);
154
+ spinner.start("Uploading");
155
+ const res = await api(`/v1/apps/${name}/deploys`, args, {
156
+ method: "POST",
157
+ headers: { "content-type": "application/gzip" },
158
+ body: new Uint8Array(bundle.data)
159
+ });
160
+ const labels = {
161
+ extract: "Extracting",
162
+ migrate: "Migrating database",
163
+ activate: "Activating"
164
+ };
165
+ const decoder = new TextDecoder();
166
+ let buffer = "";
167
+ for await (const chunk of res.body) {
168
+ buffer += decoder.decode(chunk, { stream: true });
169
+ let newline;
170
+ while ((newline = buffer.indexOf("\n")) >= 0) {
171
+ const event = JSON.parse(buffer.slice(0, newline));
172
+ buffer = buffer.slice(newline + 1);
173
+ if (event.error) {
174
+ spinner.error(event.error);
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ if (event.done) {
179
+ spinner.stop(`Release ${event.release}`);
180
+ p.outro(`Deployed → ${event.url}`);
181
+ return;
182
+ }
183
+ spinner.message(labels[event.step] ?? event.step);
184
+ }
185
+ }
186
+ }
187
+ });
188
+ const apps = defineCommand({
189
+ meta: {
190
+ name: "apps",
191
+ description: "List deployed apps"
192
+ },
193
+ args: connection,
194
+ async run({ args }) {
195
+ const list = await (await api("/v1/apps", args)).json();
196
+ if (!list.length) return console.log("No apps deployed.");
197
+ for (const app of list) console.log(`${app.app.padEnd(20)} ${(app.release ?? "-").padEnd(22)} ${app.database ? "db " : " "} ${app.url}`);
198
+ }
199
+ });
200
+ const rollback = defineCommand({
201
+ meta: {
202
+ name: "rollback",
203
+ description: "Activate the previous release (migrations stay applied)"
204
+ },
205
+ args: {
206
+ app: {
207
+ type: "positional",
208
+ required: true,
209
+ description: "App name"
210
+ },
211
+ ...connection
212
+ },
213
+ async run({ args }) {
214
+ const result = await (await api(`/v1/apps/${args.app}/rollback`, args, { method: "POST" })).json();
215
+ console.log(`${args.app} is now on release ${result.release}`);
216
+ }
217
+ });
218
+ const releases = defineCommand({
219
+ meta: {
220
+ name: "releases",
221
+ description: "List an app's releases, newest first"
222
+ },
223
+ args: {
224
+ app: {
225
+ type: "positional",
226
+ required: true,
227
+ description: "App name"
228
+ },
229
+ ...connection
230
+ },
231
+ async run({ args }) {
232
+ const info = await (await api(`/v1/apps/${args.app}`, args)).json();
233
+ for (const release of [...info.releases].reverse()) console.log(`${release}${release === info.release ? " (active)" : ""}`);
234
+ }
235
+ });
236
+ const activate = defineCommand({
237
+ meta: {
238
+ name: "activate",
239
+ description: "Activate a release of an app (migrations stay applied)"
240
+ },
241
+ args: {
242
+ app: {
243
+ type: "positional",
244
+ required: true,
245
+ description: "App name"
246
+ },
247
+ release: {
248
+ type: "positional",
249
+ required: true,
250
+ description: "Release id (see `releases`)"
251
+ },
252
+ ...connection
253
+ },
254
+ async run({ args }) {
255
+ const info = await (await api(`/v1/apps/${args.app}`, args)).json();
256
+ const result = await (await api(`/v1/apps/${args.app}/activate`, args, {
257
+ method: "POST",
258
+ headers: { "content-type": "application/json" },
259
+ body: JSON.stringify({
260
+ release: args.release,
261
+ ifActive: info.release ?? null
262
+ })
263
+ })).json();
264
+ console.log(`${args.app} is now on release ${result.release}`);
265
+ }
266
+ });
267
+ const reprovision = defineCommand({
268
+ meta: {
269
+ name: "reprovision",
270
+ description: "Recreate an app's database role, grants and ownership and run its migrations (after a restore)"
271
+ },
272
+ args: {
273
+ app: {
274
+ type: "positional",
275
+ required: false,
276
+ description: "App name"
277
+ },
278
+ all: {
279
+ type: "boolean",
280
+ description: "Every app"
281
+ },
282
+ ...connection
283
+ },
284
+ async run({ args }) {
285
+ if (!args.app === !args.all) throw new Error("Pass an app name or --all");
286
+ const results = [await (await api(args.all ? "/v1/reprovision" : `/v1/apps/${args.app}/reprovision`, args, { method: "POST" })).json()].flat();
287
+ for (const r of results) console.log(`${r.app.padEnd(20)} ${r.error ?? (r.database ? "ok" : "no database")}`);
288
+ if (results.some((r) => r.error)) process.exitCode = 1;
289
+ }
290
+ });
291
+ const toList = (value) => (Array.isArray(value) ? value : value === void 0 ? [] : [value]).flatMap((v) => String(v).split(","));
292
+ const access = defineCommand({
293
+ meta: {
294
+ name: "access",
295
+ description: "Show or change who can open an app (private apps need a login)"
296
+ },
297
+ args: {
298
+ app: {
299
+ type: "positional",
300
+ required: true,
301
+ description: "App name"
302
+ },
303
+ public: {
304
+ type: "boolean",
305
+ description: "Make the app public"
306
+ },
307
+ private: {
308
+ type: "boolean",
309
+ description: "Make the app private"
310
+ },
311
+ add: {
312
+ type: "string",
313
+ description: "Allow emails or *@domain (comma separated)"
314
+ },
315
+ remove: {
316
+ type: "string",
317
+ description: "Remove emails or *@domain (comma separated)"
318
+ },
319
+ ...connection
320
+ },
321
+ async run({ args }) {
322
+ const change = {};
323
+ if (args.public) change.visibility = "public";
324
+ if (args.private) change.visibility = "private";
325
+ if (args.add) change.add = toList(args.add);
326
+ if (args.remove) change.remove = toList(args.remove);
327
+ const result = await (Object.keys(change).length ? await api(`/v1/apps/${args.app}/access`, args, {
328
+ method: "PATCH",
329
+ headers: { "content-type": "application/json" },
330
+ body: JSON.stringify(change)
331
+ }) : await api(`/v1/apps/${args.app}/access`, args)).json();
332
+ if (args.app === "auth") return console.log("auth is the login app and always public");
333
+ console.log(`${args.app} is ${result.visibility}`);
334
+ if (result.visibility === "private") console.log(result.allow.length ? `allowed: ${result.allow.join(", ")}` : "allowed: admins only");
335
+ }
336
+ });
337
+ const env = defineCommand({
338
+ meta: {
339
+ name: "env",
340
+ description: "Set or unset app environment variables (values are write-only)"
341
+ },
342
+ args: {
343
+ app: {
344
+ type: "positional",
345
+ required: true,
346
+ description: "App name"
347
+ },
348
+ set: {
349
+ type: "string",
350
+ description: "KEY=value (repeatable)"
351
+ },
352
+ unset: {
353
+ type: "string",
354
+ description: "KEY (comma separated)"
355
+ },
356
+ ...connection
357
+ },
358
+ async run({ args, rawArgs }) {
359
+ const pairs = rawArgs.flatMap((arg, i) => arg === "--set" ? [rawArgs[i + 1] ?? ""] : arg.startsWith("--set=") ? [arg.slice(6)] : []);
360
+ const set = Object.fromEntries(pairs.map((pair) => {
361
+ const i = pair.indexOf("=");
362
+ if (i < 1) throw new Error(`Expected KEY=value, got "${pair}"`);
363
+ return [pair.slice(0, i), pair.slice(i + 1)];
364
+ }));
365
+ const unset = toList(args.unset);
366
+ const { keys } = await (Object.keys(set).length || unset.length ? await api(`/v1/apps/${args.app}/env`, args, {
367
+ method: "PATCH",
368
+ headers: { "content-type": "application/json" },
369
+ body: JSON.stringify({
370
+ set,
371
+ unset
372
+ })
373
+ }) : await api(`/v1/apps/${args.app}/env`, args)).json();
374
+ console.log(keys.length ? keys.join("\n") : "No variables set.");
375
+ if (Object.keys(set).length || unset.length) console.log("Applies to newly started isolates (next deploy or after idle timeout).");
376
+ }
377
+ });
378
+ const remove = defineCommand({
379
+ meta: {
380
+ name: "delete",
381
+ description: "Delete an app (releases, access, env)"
382
+ },
383
+ args: {
384
+ app: {
385
+ type: "positional",
386
+ required: true,
387
+ description: "App name"
388
+ },
389
+ "drop-database": {
390
+ type: "boolean",
391
+ description: "Also drop the app's schema and all its data"
392
+ },
393
+ yes: {
394
+ type: "boolean",
395
+ description: "Skip the confirmation"
396
+ },
397
+ ...connection
398
+ },
399
+ async run({ args }) {
400
+ const dropDb = Boolean(args["drop-database"]);
401
+ if (!args.yes) {
402
+ const ok = await p.confirm({
403
+ message: `Delete ${args.app}${dropDb ? " and ALL its data" : " (database is kept)"}?`,
404
+ initialValue: false
405
+ });
406
+ if (p.isCancel(ok) || !ok) return p.cancel("Cancelled");
407
+ }
408
+ await api(`/v1/apps/${args.app}?dropDatabase=${dropDb}`, args, { method: "DELETE" });
409
+ console.log(`Deleted ${args.app}${dropDb ? " and its database" : ""}`);
410
+ }
411
+ });
412
+ const login = defineCommand({
413
+ meta: {
414
+ name: "login",
415
+ description: "Save the control plane URL and token"
416
+ },
417
+ args: {
418
+ url: {
419
+ type: "string",
420
+ required: true,
421
+ description: "Control plane URL"
422
+ },
423
+ token: {
424
+ type: "string",
425
+ required: true,
426
+ description: "Deploy token"
427
+ }
428
+ },
429
+ async run({ args }) {
430
+ const res = await fetch(`${args.url.replace(/\/$/, "")}/v1/apps`, { headers: { authorization: `Bearer ${args.token}` } });
431
+ if (!res.ok) throw new Error(`Login failed: ${res.status} ${res.statusText}`);
432
+ const file = await saveConfig({
433
+ url: args.url,
434
+ token: args.token
435
+ });
436
+ console.log(`Saved to ${file}`);
437
+ }
438
+ });
439
+ runMain(defineCommand({
440
+ meta: {
441
+ name: "wervt",
442
+ description: "Deploy and manage wervt apps"
443
+ },
444
+ subCommands: {
445
+ deploy,
446
+ apps,
447
+ releases,
448
+ activate,
449
+ rollback,
450
+ reprovision,
451
+ access,
452
+ env,
453
+ delete: remove,
454
+ login
455
+ }
456
+ }));
457
+ //#endregion
458
+ export {};
package/package.json CHANGED
@@ -1,11 +1,39 @@
1
1
  {
2
2
  "name": "@wervt/cli",
3
- "version": "0.0.0",
4
- "description": "Build and host small Nuxt apps written by agents. Coming soon.",
3
+ "version": "0.1.0",
4
+ "description": "Deploy and manage apps on wervt.",
5
+ "keywords": [
6
+ "agents",
7
+ "cli",
8
+ "hosting",
9
+ "nitro",
10
+ "nuxt"
11
+ ],
12
+ "homepage": "https://wervt.app",
5
13
  "license": "MIT",
6
14
  "author": "Niki2k1",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/wervt-app/wervt.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "bin": {
21
+ "wervt": "bin/wervt.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "dist",
26
+ "README.md"
27
+ ],
7
28
  "type": "module",
8
- "keywords": ["agents", "nuxt", "nitro", "hosting", "cli"],
9
- "bin": { "wervt": "bin/wervt.js" },
10
- "files": ["bin", "README.md"]
11
- }
29
+ "dependencies": {
30
+ "@clack/prompts": "^1.8.1",
31
+ "citty": "^0.2.2",
32
+ "modern-tar": "^0.8.5",
33
+ "nypm": "^0.6.10",
34
+ "tinyexec": "^1.3.1"
35
+ },
36
+ "scripts": {
37
+ "build": "vp pack"
38
+ }
39
+ }