seedyn 0.1.0 → 0.2.1

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 CHANGED
@@ -5,13 +5,17 @@ no runtime dependencies and requires Node.js 22.12 or newer.
5
5
 
6
6
  ## Setup
7
7
 
8
- Create an API key in Seedyn with the scopes needed for the files you plan to
9
- upload, then save it using a hidden terminal prompt:
8
+ Open Seedyn in your browser, approve the requested upload scopes, and let the
9
+ CLI save the new key:
10
10
 
11
11
  ```sh
12
- npx seedyn auth set
12
+ npx seedyn auth login
13
13
  ```
14
14
 
15
+ The browser encrypts the new credential to the waiting CLI process. The server
16
+ cannot recover it from the login request. To store an existing key instead, run
17
+ `npx seedyn auth set` and paste it into the hidden prompt.
18
+
15
19
  Credentials are written to `$XDG_CONFIG_HOME/seedyn/config.json`, or
16
20
  `~/.config/seedyn/config.json` when `XDG_CONFIG_HOME` is unset. The directory is
17
21
  mode `0700` and the file is mode `0600`. `SEEDYN_CONFIG_PATH` can select a
@@ -29,6 +33,19 @@ owner-only config file. API URL precedence is `--api-url`, then
29
33
  Passing a secret on the command line can expose it to shell history and process
30
34
  inspection; prefer the environment or `seedyn auth set`.
31
35
 
36
+ ## Self-hosted Seedyn
37
+
38
+ Save the application origin once, then use the normal login flow:
39
+
40
+ ```sh
41
+ npx seedyn config set api-url https://seedyn.example.com
42
+ npx seedyn config get api-url
43
+ npx seedyn auth login
44
+ ```
45
+
46
+ The origin must use HTTPS. HTTP is accepted for `localhost` during development.
47
+ For a one-off command, pass `--api-url`. In CI, set `SEEDYN_API_URL`.
48
+
32
49
  ## Upload
33
50
 
34
51
  The shortest form treats the first argument as a file:
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "seedyn",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Upload any file to Seedyn and receive its durable URL.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/DavidIlie/seedyn.git",
10
+ "directory": "packages/cli"
11
+ },
7
12
  "files": [
8
13
  "bin/",
9
14
  "src/",
@@ -12,7 +17,7 @@
12
17
  "THIRD_PARTY_NOTICES.md"
13
18
  ],
14
19
  "bin": {
15
- "seedyn": "./bin/seedyn.mjs"
20
+ "seedyn": "bin/seedyn.mjs"
16
21
  },
17
22
  "engines": {
18
23
  "node": ">=22.12.0"
@@ -0,0 +1,105 @@
1
+ import { generateKeyPairSync, privateDecrypt } from "node:crypto";
2
+
3
+ import { CliError } from "./errors.mjs";
4
+
5
+ function delay(milliseconds) {
6
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
7
+ }
8
+
9
+ async function json(response) {
10
+ try {
11
+ return await response.json();
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function responseError(response, body) {
18
+ const message = body?.error?.message;
19
+ return new CliError(
20
+ typeof message === "string"
21
+ ? message
22
+ : `Seedyn authentication failed with HTTP ${response.status}.`,
23
+ );
24
+ }
25
+
26
+ export async function browserLogin({ apiUrl, version, openBrowser }) {
27
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
28
+ modulusLength: 2_048,
29
+ });
30
+ const publicKeyPem = publicKey
31
+ .export({ type: "spki", format: "pem" })
32
+ .toString();
33
+
34
+ let startResponse;
35
+ try {
36
+ startResponse = await fetch(`${apiUrl}/api/cli-auth/start`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ "User-Agent": `seedyn/${version}`,
41
+ },
42
+ body: JSON.stringify({ publicKey: publicKeyPem }),
43
+ });
44
+ } catch (error) {
45
+ throw new CliError(`Could not reach ${apiUrl}.`, { cause: error });
46
+ }
47
+ const started = await json(startResponse);
48
+ if (!startResponse.ok) throw responseError(startResponse, started);
49
+ if (
50
+ typeof started?.requestId !== "string" ||
51
+ typeof started?.pollSecret !== "string" ||
52
+ typeof started?.verificationUrl !== "string" ||
53
+ typeof started?.expiresAt !== "string"
54
+ ) {
55
+ throw new CliError("Seedyn returned an invalid authentication response.");
56
+ }
57
+
58
+ console.log(`Open this page to connect the CLI:\n${started.verificationUrl}`);
59
+ if (openBrowser(started.verificationUrl)) {
60
+ console.log("Opened the page in your browser.");
61
+ }
62
+ console.log("Waiting for approval...");
63
+
64
+ const expiresAt = Date.parse(started.expiresAt);
65
+ const intervalMs = Math.max(
66
+ 1_000,
67
+ Math.min(10_000, Number(started.intervalSeconds || 2) * 1_000),
68
+ );
69
+ while (Date.now() < expiresAt) {
70
+ await delay(intervalMs);
71
+ let response;
72
+ try {
73
+ response = await fetch(
74
+ `${apiUrl}/api/cli-auth/poll/${encodeURIComponent(started.requestId)}`,
75
+ {
76
+ headers: {
77
+ Authorization: `Bearer ${started.pollSecret}`,
78
+ "User-Agent": `seedyn/${version}`,
79
+ },
80
+ },
81
+ );
82
+ } catch {
83
+ continue;
84
+ }
85
+ const body = await json(response);
86
+ if (response.status === 202) continue;
87
+ if (!response.ok) throw responseError(response, body);
88
+ if (typeof body?.encryptedApiKey !== "string") {
89
+ throw new CliError("Seedyn returned an invalid encrypted credential.");
90
+ }
91
+ try {
92
+ return privateDecrypt(
93
+ { key: privateKey, oaepHash: "sha256" },
94
+ Buffer.from(body.encryptedApiKey, "base64"),
95
+ ).toString("utf8");
96
+ } catch (error) {
97
+ throw new CliError("The CLI could not decrypt the new credential.", {
98
+ cause: error,
99
+ });
100
+ }
101
+ }
102
+ throw new CliError(
103
+ "The login request expired. Run `seedyn auth login` again.",
104
+ );
105
+ }
package/src/cli.mjs CHANGED
@@ -7,11 +7,13 @@ import {
7
7
  readConfig,
8
8
  removeAuthentication,
9
9
  resolveAuthentication,
10
+ saveApiUrl,
10
11
  saveAuthentication,
11
12
  validateApiKey,
12
13
  validateApiUrl,
13
14
  } from "./config.mjs";
14
15
  import { CliError } from "./errors.mjs";
16
+ import { browserLogin } from "./browser-auth.mjs";
15
17
  import { copyUrl, openUrl, uploadFile } from "./upload.mjs";
16
18
 
17
19
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
@@ -31,6 +33,10 @@ export async function run(arguments_) {
31
33
  await authCommand(args.slice(1));
32
34
  return;
33
35
  }
36
+ if (first === "config") {
37
+ await configCommand(args.slice(1));
38
+ return;
39
+ }
34
40
  await uploadCommand(first === "upload" ? args.slice(1) : args);
35
41
  }
36
42
 
@@ -73,6 +79,42 @@ async function authCommand(args) {
73
79
  return;
74
80
  }
75
81
 
82
+ if (action === "login") {
83
+ const { values, positionals } = parseArgs({
84
+ args: args.slice(1),
85
+ allowPositionals: true,
86
+ strict: true,
87
+ options: {
88
+ "api-url": { type: "string" },
89
+ "no-open": { type: "boolean" },
90
+ help: { type: "boolean", short: "h" },
91
+ },
92
+ });
93
+ if (values.help) {
94
+ console.log(AUTH_LOGIN_HELP);
95
+ return;
96
+ }
97
+ if (positionals.length > 0) {
98
+ throw new CliError("auth login takes no arguments.");
99
+ }
100
+ const apiUrl = validateApiUrl(
101
+ values["api-url"] ||
102
+ process.env.SEEDYN_API_URL ||
103
+ "https://seedyn.dave.tips",
104
+ );
105
+ const key = validateApiKey(
106
+ await browserLogin({
107
+ apiUrl,
108
+ version: VERSION,
109
+ openBrowser: values["no-open"] ? () => false : openUrl,
110
+ }),
111
+ );
112
+ const result = await saveAuthentication({ apiKey: key, apiUrl });
113
+ console.log(`Seedyn credentials saved to ${result.file}`);
114
+ console.log(`API key: ${displayApiKey(key)}`);
115
+ return;
116
+ }
117
+
76
118
  if (action === "remove") {
77
119
  if (args.length > 1) throw new CliError("auth remove takes no arguments.");
78
120
  console.log(
@@ -98,6 +140,35 @@ async function authCommand(args) {
98
140
  throw new CliError(`Unknown auth command: ${action}`);
99
141
  }
100
142
 
143
+ async function configCommand(args) {
144
+ const action = args[0] || "help";
145
+ if (action === "help" || action === "--help" || action === "-h") {
146
+ console.log(CONFIG_HELP);
147
+ return;
148
+ }
149
+
150
+ if (action === "set" && args[1] === "api-url") {
151
+ if (args.length !== 3) {
152
+ throw new CliError("Usage: seedyn config set api-url <origin>");
153
+ }
154
+ const result = await saveApiUrl(args[2]);
155
+ console.log(`API URL saved to ${result.file}`);
156
+ console.log(`API URL: ${result.value.apiUrl}`);
157
+ return;
158
+ }
159
+
160
+ if (action === "get" && args[1] === "api-url") {
161
+ if (args.length !== 2) {
162
+ throw new CliError("Usage: seedyn config get api-url");
163
+ }
164
+ const config = await readConfig();
165
+ console.log(config.apiUrl || "https://seedyn.dave.tips");
166
+ return;
167
+ }
168
+
169
+ throw new CliError(`Unknown config command: ${args.join(" ")}`);
170
+ }
171
+
101
172
  async function uploadCommand(args) {
102
173
  const { values, positionals } = parseArgs({
103
174
  args,
@@ -249,7 +320,8 @@ Upload any file and receive its durable URL.
249
320
  Usage:
250
321
  seedyn <file> [options]
251
322
  seedyn upload <file> [options]
252
- seedyn auth <set|status|remove>
323
+ seedyn auth <login|set|status|remove>
324
+ seedyn config <set|get> api-url
253
325
 
254
326
  Examples:
255
327
  seedyn ./image.png --copy
@@ -279,11 +351,25 @@ Options:
279
351
  const AUTH_HELP = `Usage: seedyn auth <command>
280
352
 
281
353
  Commands:
354
+ login Create a key through the Seedyn website
282
355
  set [api-key] Store a key with owner-only permissions
283
356
  status Show config and redacted credential status
284
357
  remove Remove the stored API key
285
358
 
286
- Prefer bare "seedyn auth set" for a hidden prompt.`;
359
+ Run "seedyn auth login" for the guided browser flow.`;
360
+
361
+ const CONFIG_HELP = `Usage: seedyn config <command>
362
+
363
+ Commands:
364
+ set api-url <origin> Save a self-hosted Seedyn application origin
365
+ get api-url Print the configured application origin`;
366
+
367
+ const AUTH_LOGIN_HELP = `Usage: seedyn auth login [options]
368
+
369
+ Options:
370
+ --api-url <origin> Use and save a custom Seedyn application origin
371
+ --no-open Print the approval URL without opening a browser
372
+ -h, --help Show this help`;
287
373
 
288
374
  const AUTH_SET_HELP = `Usage: seedyn auth set [api-key] [options]
289
375
 
package/src/config.mjs CHANGED
@@ -112,7 +112,7 @@ export async function resolveAuthentication(
112
112
  options.apiKey || environment.SEEDYN_API_KEY || config.apiKey;
113
113
  if (!candidate) {
114
114
  throw new CliError(
115
- "Missing Seedyn API key. Run `seedyn auth set` or set SEEDYN_API_KEY.",
115
+ "Missing Seedyn API key. Run `seedyn auth login`, `seedyn auth set`, or set SEEDYN_API_KEY.",
116
116
  );
117
117
  }
118
118
  return { apiKey: validateApiKey(candidate), apiUrl };
@@ -133,6 +133,17 @@ export async function saveAuthentication(
133
133
  return { file: configPath(environment), value: next };
134
134
  }
135
135
 
136
+ export async function saveApiUrl(apiUrl, environment = process.env) {
137
+ const existing = await readConfig(environment);
138
+ const next = {
139
+ ...existing,
140
+ apiUrl: validateApiUrl(apiUrl),
141
+ updatedAt: new Date().toISOString(),
142
+ };
143
+ await writeConfig(next, environment);
144
+ return { file: configPath(environment), value: next };
145
+ }
146
+
136
147
  export async function removeAuthentication(environment = process.env) {
137
148
  const existing = await readConfig(environment);
138
149
  const { apiKey: _removed, ...next } = existing;