vellum-cli 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +29 -5
  2. package/dist/index.js +275 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,17 +17,41 @@ vellum push --help
17
17
  Requires Node 18+. The package is self-contained (`@vellum/core` is bundled in),
18
18
  so it has no runtime dependencies.
19
19
 
20
+ ## Authentication
21
+
22
+ The normal path is `vellum login`, which signs you in through your browser and
23
+ stores a per-user token under `~/.config/vellum/config.json`
24
+ (`$XDG_CONFIG_HOME` is honored). After that, commands authenticate as you with
25
+ no further setup.
26
+
27
+ ```bash
28
+ vellum login # opens the browser, approve, done
29
+ vellum whoami # who am I logged in as?
30
+ vellum logout # revoke + forget this machine's token
31
+ ```
32
+
20
33
  ## Configuration
21
34
 
22
- Connection settings come from flags, falling back to env vars:
35
+ `--url` defaults to the hosted instance (`https://vellumai.app`); override it
36
+ for self-hosted servers. Connection settings come from flags, falling back to
37
+ env vars:
38
+
39
+ | Flag | Env var | Description |
40
+ | ------------ | ---------------- | -------------------------------------------- |
41
+ | `--url` | `VELLUM_URL` | Server base URL (default `https://vellumai.app`) |
42
+ | `--api-key` | `VELLUM_API_KEY` | Shared owner/agent key (`X-Api-Key`) |
23
43
 
24
- | Flag | Env var | Description |
25
- | ------------ | ---------------- | ---------------------------- |
26
- | `--url` | `VELLUM_URL` | Server base URL |
27
- | `--api-key` | `VELLUM_API_KEY` | Shared API key (`X-Api-Key`) |
44
+ Credential precedence for a write: an explicit `--api-key`/`VELLUM_API_KEY`
45
+ (the shared owner key, e.g. for agents/CI) wins; otherwise the stored
46
+ `vellum login` token is used.
28
47
 
29
48
  ## Commands
30
49
 
50
+ ### `vellum login`
51
+
52
+ Authenticate this machine. Opens the browser to approve, polls until you do,
53
+ then stores the token. `--no-browser` just prints the URL to open manually.
54
+
31
55
  ### `vellum push [file]`
32
56
 
33
57
  Create an artifact from HTML. Reads from `<file>` if given, otherwise from
package/dist/index.js CHANGED
@@ -1,22 +1,67 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/config.ts
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ var DEFAULT_URL = "https://vellumai.app";
8
+
4
9
  class ConfigError extends Error {
5
10
  }
6
- function resolveConfig(flags) {
7
- const baseUrl = flags.url ?? process.env.VELLUM_URL;
11
+ function normalizeUrl(url) {
12
+ return url.replace(/\/+$/, "");
13
+ }
14
+ function resolveBaseUrl(flags) {
15
+ return normalizeUrl(flags.url ?? process.env.VELLUM_URL ?? DEFAULT_URL);
16
+ }
17
+ async function resolveCredential(flags, baseUrl) {
8
18
  const apiKey = flags.apiKey ?? process.env.VELLUM_API_KEY;
9
- if (!baseUrl) {
10
- throw new ConfigError("Missing server URL. Pass --url or set VELLUM_URL.");
11
- }
12
- if (!apiKey) {
13
- throw new ConfigError("Missing API key. Pass --api-key or set VELLUM_API_KEY.");
19
+ if (apiKey)
20
+ return { apiKey };
21
+ const token = await loadToken(baseUrl);
22
+ if (token)
23
+ return { token };
24
+ throw new ConfigError(`Not logged in to ${baseUrl}. Run \`vellum login\` (or set VELLUM_API_KEY / pass --api-key).`);
25
+ }
26
+ function storePath() {
27
+ const xdg = process.env.XDG_CONFIG_HOME;
28
+ const base = xdg && xdg.trim().length > 0 ? xdg : join(homedir(), ".config");
29
+ return join(base, "vellum", "config.json");
30
+ }
31
+ async function readStore() {
32
+ try {
33
+ const raw = await readFile(storePath(), "utf8");
34
+ const parsed = JSON.parse(raw);
35
+ return { servers: parsed.servers ?? {} };
36
+ } catch {
37
+ return { servers: {} };
14
38
  }
15
- return { baseUrl, apiKey };
39
+ }
40
+ async function writeStore(store) {
41
+ const path = storePath();
42
+ await mkdir(dirname(path), { recursive: true });
43
+ await writeFile(path, `${JSON.stringify(store, null, 2)}
44
+ `, { mode: 384 });
45
+ }
46
+ async function loadToken(baseUrl) {
47
+ const store = await readStore();
48
+ return store.servers[normalizeUrl(baseUrl)]?.token;
49
+ }
50
+ async function saveToken(baseUrl, token, email) {
51
+ const store = await readStore();
52
+ store.servers[normalizeUrl(baseUrl)] = { token, email };
53
+ await writeStore(store);
54
+ }
55
+ async function clearToken(baseUrl) {
56
+ const store = await readStore();
57
+ const key = normalizeUrl(baseUrl);
58
+ const existing = store.servers[key]?.token;
59
+ delete store.servers[key];
60
+ await writeStore(store);
61
+ return existing;
16
62
  }
17
63
 
18
- // src/commands/push.ts
19
- import { readFile } from "node:fs/promises";
64
+ // src/commands/login.ts
20
65
  import { parseArgs } from "node:util";
21
66
  // ../core/src/client.ts
22
67
  class VellumApiError extends Error {
@@ -33,12 +78,25 @@ class VellumApiError extends Error {
33
78
  class VellumClient {
34
79
  baseUrl;
35
80
  apiKey;
81
+ token;
36
82
  fetchImpl;
37
83
  constructor(opts) {
38
84
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
39
85
  this.apiKey = opts.apiKey;
86
+ this.token = opts.token;
40
87
  this.fetchImpl = opts.fetch ?? globalThis.fetch;
41
88
  }
89
+ authHeaders() {
90
+ if (this.token)
91
+ return { Authorization: `Bearer ${this.token}` };
92
+ if (this.apiKey)
93
+ return { "X-Api-Key": this.apiKey };
94
+ return {};
95
+ }
96
+ async fail(res) {
97
+ const code = await res.clone().json().then((b) => b.error).catch(() => res.statusText);
98
+ throw new VellumApiError(res.status, code);
99
+ }
42
100
  async createPage(html, opts = {}) {
43
101
  const url = new URL(`${this.baseUrl}/v1/pages`);
44
102
  if (opts.pageId)
@@ -47,19 +105,168 @@ class VellumClient {
47
105
  method: "POST",
48
106
  headers: {
49
107
  "Content-Type": "text/html; charset=utf-8",
50
- "X-Api-Key": this.apiKey
108
+ ...this.authHeaders()
51
109
  },
52
110
  body: html
53
111
  });
54
- if (!res.ok) {
55
- const code = await res.json().then((b) => b.error).catch(() => res.statusText);
56
- throw new VellumApiError(res.status, code);
57
- }
112
+ if (!res.ok)
113
+ await this.fail(res);
58
114
  return await res.json();
59
115
  }
116
+ async startCliAuth() {
117
+ const res = await this.fetchImpl(`${this.baseUrl}/v1/cli/auth/start`, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: "{}"
121
+ });
122
+ if (!res.ok)
123
+ await this.fail(res);
124
+ return await res.json();
125
+ }
126
+ async pollCliAuth(deviceCode) {
127
+ const res = await this.fetchImpl(`${this.baseUrl}/v1/cli/auth/poll`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({ device_code: deviceCode })
131
+ });
132
+ if (!res.ok)
133
+ await this.fail(res);
134
+ return await res.json();
135
+ }
136
+ async whoami() {
137
+ const res = await this.fetchImpl(`${this.baseUrl}/v1/cli/whoami`, {
138
+ headers: this.authHeaders()
139
+ });
140
+ if (!res.ok)
141
+ await this.fail(res);
142
+ return await res.json();
143
+ }
144
+ async logout() {
145
+ const res = await this.fetchImpl(`${this.baseUrl}/v1/cli/auth/logout`, {
146
+ method: "POST",
147
+ headers: this.authHeaders()
148
+ });
149
+ if (!res.ok)
150
+ await this.fail(res);
151
+ }
152
+ }
153
+ // src/util/open.ts
154
+ import { spawn } from "node:child_process";
155
+ function openBrowser(url) {
156
+ const platform = process.platform;
157
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
158
+ try {
159
+ const child = spawn(cmd, args, {
160
+ stdio: "ignore",
161
+ detached: true
162
+ });
163
+ child.on("error", () => {});
164
+ child.unref();
165
+ } catch {}
166
+ }
167
+
168
+ // src/commands/login.ts
169
+ var HELP = `vellum login — authenticate this machine to a Vellum server
170
+
171
+ Opens your browser to approve a CLI login, then stores a token under
172
+ ~/.config/vellum/config.json so future commands authenticate as you.
173
+
174
+ Usage:
175
+ vellum login [options]
176
+
177
+ Options:
178
+ --url <url> Server base URL (env: VELLUM_URL)
179
+ --no-browser Don't auto-open the browser; just print the URL
180
+ -h, --help Show this help`;
181
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
182
+ async function loginCommand(argv) {
183
+ const { values } = parseArgs({
184
+ args: argv,
185
+ options: {
186
+ url: { type: "string" },
187
+ "no-browser": { type: "boolean", default: false },
188
+ help: { type: "boolean", short: "h", default: false }
189
+ }
190
+ });
191
+ if (values.help) {
192
+ console.log(HELP);
193
+ return 0;
194
+ }
195
+ const baseUrl = resolveBaseUrl({ url: values.url });
196
+ const client2 = new VellumClient({ baseUrl });
197
+ const start = await client2.startCliAuth();
198
+ console.log(`
199
+ To authorize this CLI, visit:
200
+ `);
201
+ console.log(` ${start.verification_uri_complete}
202
+ `);
203
+ console.log(`and confirm the code: ${start.user_code}
204
+ `);
205
+ if (!values["no-browser"]) {
206
+ openBrowser(start.verification_uri_complete);
207
+ console.log(`(Opening your browser…)
208
+ `);
209
+ }
210
+ process.stdout.write("Waiting for approval");
211
+ const deadline = Date.now() + start.expires_in * 1000;
212
+ const intervalMs = Math.max(1, start.interval) * 1000;
213
+ while (Date.now() < deadline) {
214
+ await sleep(intervalMs);
215
+ process.stdout.write(".");
216
+ const res = await client2.pollCliAuth(start.device_code);
217
+ if (res.status === "complete") {
218
+ await saveToken(baseUrl, res.token, res.email);
219
+ console.log(`
220
+
221
+ ✓ Logged in to ${baseUrl}${res.email ? ` as ${res.email}` : ""}.`);
222
+ return 0;
223
+ }
224
+ if (res.status === "expired")
225
+ break;
226
+ }
227
+ console.error("\n\nError: login request expired. Run `vellum login` again.");
228
+ return 1;
229
+ }
230
+
231
+ // src/commands/logout.ts
232
+ import { parseArgs as parseArgs2 } from "node:util";
233
+ var HELP2 = `vellum logout — remove this machine's stored CLI token
234
+
235
+ Usage:
236
+ vellum logout [options]
237
+
238
+ Options:
239
+ --url <url> Server base URL (env: VELLUM_URL)
240
+ -h, --help Show this help`;
241
+ async function logoutCommand(argv) {
242
+ const { values } = parseArgs2({
243
+ args: argv,
244
+ options: {
245
+ url: { type: "string" },
246
+ help: { type: "boolean", short: "h", default: false }
247
+ }
248
+ });
249
+ if (values.help) {
250
+ console.log(HELP2);
251
+ return 0;
252
+ }
253
+ const baseUrl = resolveBaseUrl({ url: values.url });
254
+ const token = await clearToken(baseUrl);
255
+ if (!token) {
256
+ console.log(`Not logged in to ${baseUrl}.`);
257
+ return 0;
258
+ }
259
+ try {
260
+ await new VellumClient({ baseUrl, token }).logout();
261
+ } catch {}
262
+ console.log(`✓ Logged out of ${baseUrl}.`);
263
+ return 0;
60
264
  }
265
+
61
266
  // src/commands/push.ts
62
- var HELP = `vellum push create a Vellum artifact from HTML
267
+ import { readFile as readFile2 } from "node:fs/promises";
268
+ import { parseArgs as parseArgs3 } from "node:util";
269
+ var HELP3 = `vellum push — create a Vellum artifact from HTML
63
270
 
64
271
  Usage:
65
272
  vellum push <file.html> [options]
@@ -78,7 +285,7 @@ async function readStdin() {
78
285
  return Buffer.concat(chunks).toString("utf8");
79
286
  }
80
287
  async function pushCommand(argv) {
81
- const { values, positionals } = parseArgs({
288
+ const { values, positionals } = parseArgs3({
82
289
  args: argv,
83
290
  allowPositionals: true,
84
291
  options: {
@@ -90,20 +297,19 @@ async function pushCommand(argv) {
90
297
  }
91
298
  });
92
299
  if (values.help) {
93
- console.log(HELP);
300
+ console.log(HELP3);
94
301
  return 0;
95
302
  }
96
303
  const file = positionals[0];
97
- const html = file ? await readFile(file, "utf8") : await readStdin();
304
+ const html = file ? await readFile2(file, "utf8") : await readStdin();
98
305
  if (!html.trim()) {
99
306
  console.error("Error: no HTML provided (empty file or stdin).");
100
307
  return 1;
101
308
  }
102
- const { baseUrl, apiKey } = resolveConfig({
103
- url: values.url,
104
- apiKey: values["api-key"]
105
- });
106
- const client2 = new VellumClient({ baseUrl, apiKey });
309
+ const flags = { url: values.url, apiKey: values["api-key"] };
310
+ const baseUrl = resolveBaseUrl(flags);
311
+ const credential = await resolveCredential(flags, baseUrl);
312
+ const client2 = new VellumClient({ baseUrl, ...credential });
107
313
  const res = await client2.createPage(html, { pageId: values["page-id"] });
108
314
  if (values.json) {
109
315
  console.log(JSON.stringify(res, null, 2));
@@ -115,30 +321,72 @@ async function pushCommand(argv) {
115
321
  return 0;
116
322
  }
117
323
 
324
+ // src/commands/whoami.ts
325
+ import { parseArgs as parseArgs4 } from "node:util";
326
+ var HELP4 = `vellum whoami — show the identity the CLI is authenticated as
327
+
328
+ Usage:
329
+ vellum whoami [options]
330
+
331
+ Options:
332
+ --url <url> Server base URL (env: VELLUM_URL)
333
+ --api-key <key> Shared API key (env: VELLUM_API_KEY)
334
+ -h, --help Show this help`;
335
+ async function whoamiCommand(argv) {
336
+ const { values } = parseArgs4({
337
+ args: argv,
338
+ options: {
339
+ url: { type: "string" },
340
+ "api-key": { type: "string" },
341
+ help: { type: "boolean", short: "h", default: false }
342
+ }
343
+ });
344
+ if (values.help) {
345
+ console.log(HELP4);
346
+ return 0;
347
+ }
348
+ const baseUrl = resolveBaseUrl({ url: values.url });
349
+ const credential = await resolveCredential({ url: values.url, apiKey: values["api-key"] }, baseUrl);
350
+ if ("apiKey" in credential) {
351
+ console.log(`Authenticated to ${baseUrl} with a shared API key (owner).`);
352
+ return 0;
353
+ }
354
+ const client2 = new VellumClient({ baseUrl, token: credential.token });
355
+ const { email } = await client2.whoami();
356
+ console.log(`${email ?? "(no email on record)"} — ${baseUrl}`);
357
+ return 0;
358
+ }
359
+
118
360
  // src/index.ts
119
- var HELP2 = `vellum — CLI for the Vellum artifact store
361
+ var HELP5 = `vellum — CLI for the Vellum artifact store
120
362
 
121
363
  Usage:
122
364
  vellum <command> [options]
123
365
 
124
366
  Commands:
367
+ login Authenticate this machine (opens your browser)
368
+ logout Remove this machine's stored CLI token
369
+ whoami Show the identity the CLI is authenticated as
125
370
  push Create an artifact (page) from HTML (file or stdin)
126
371
 
127
372
  Run 'vellum <command> --help' for command-specific options.`;
128
373
  var commands = {
374
+ login: loginCommand,
375
+ logout: logoutCommand,
376
+ whoami: whoamiCommand,
129
377
  push: pushCommand
130
378
  };
131
379
  async function main() {
132
380
  const [, , cmd, ...rest] = process.argv;
133
381
  if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
134
- console.log(HELP2);
382
+ console.log(HELP5);
135
383
  return cmd ? 0 : 1;
136
384
  }
137
385
  const handler = commands[cmd];
138
386
  if (!handler) {
139
387
  console.error(`Unknown command: ${cmd}
140
388
  `);
141
- console.error(HELP2);
389
+ console.error(HELP5);
142
390
  return 1;
143
391
  }
144
392
  return handler(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vellum-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The vellum CLI — publish agent-authored HTML artifacts to a Vellum server.",
5
5
  "type": "module",
6
6
  "license": "MIT",