seedyn 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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seedyn",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Upload any file to Seedyn and receive its durable URL.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -12,6 +12,7 @@ import {
12
12
  validateApiUrl,
13
13
  } from "./config.mjs";
14
14
  import { CliError } from "./errors.mjs";
15
+ import { browserLogin } from "./browser-auth.mjs";
15
16
  import { copyUrl, openUrl, uploadFile } from "./upload.mjs";
16
17
 
17
18
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
@@ -73,6 +74,42 @@ async function authCommand(args) {
73
74
  return;
74
75
  }
75
76
 
77
+ if (action === "login") {
78
+ const { values, positionals } = parseArgs({
79
+ args: args.slice(1),
80
+ allowPositionals: true,
81
+ strict: true,
82
+ options: {
83
+ "api-url": { type: "string" },
84
+ "no-open": { type: "boolean" },
85
+ help: { type: "boolean", short: "h" },
86
+ },
87
+ });
88
+ if (values.help) {
89
+ console.log(AUTH_LOGIN_HELP);
90
+ return;
91
+ }
92
+ if (positionals.length > 0) {
93
+ throw new CliError("auth login takes no arguments.");
94
+ }
95
+ const apiUrl = validateApiUrl(
96
+ values["api-url"] ||
97
+ process.env.SEEDYN_API_URL ||
98
+ "https://seedyn.dave.tips",
99
+ );
100
+ const key = validateApiKey(
101
+ await browserLogin({
102
+ apiUrl,
103
+ version: VERSION,
104
+ openBrowser: values["no-open"] ? () => false : openUrl,
105
+ }),
106
+ );
107
+ const result = await saveAuthentication({ apiKey: key, apiUrl });
108
+ console.log(`Seedyn credentials saved to ${result.file}`);
109
+ console.log(`API key: ${displayApiKey(key)}`);
110
+ return;
111
+ }
112
+
76
113
  if (action === "remove") {
77
114
  if (args.length > 1) throw new CliError("auth remove takes no arguments.");
78
115
  console.log(
@@ -249,7 +286,7 @@ Upload any file and receive its durable URL.
249
286
  Usage:
250
287
  seedyn <file> [options]
251
288
  seedyn upload <file> [options]
252
- seedyn auth <set|status|remove>
289
+ seedyn auth <login|set|status|remove>
253
290
 
254
291
  Examples:
255
292
  seedyn ./image.png --copy
@@ -279,11 +316,19 @@ Options:
279
316
  const AUTH_HELP = `Usage: seedyn auth <command>
280
317
 
281
318
  Commands:
319
+ login Create a key through the Seedyn website
282
320
  set [api-key] Store a key with owner-only permissions
283
321
  status Show config and redacted credential status
284
322
  remove Remove the stored API key
285
323
 
286
- Prefer bare "seedyn auth set" for a hidden prompt.`;
324
+ Run "seedyn auth login" for the guided browser flow.`;
325
+
326
+ const AUTH_LOGIN_HELP = `Usage: seedyn auth login [options]
327
+
328
+ Options:
329
+ --api-url <origin> Use and save a custom Seedyn application origin
330
+ --no-open Print the approval URL without opening a browser
331
+ -h, --help Show this help`;
287
332
 
288
333
  const AUTH_SET_HELP = `Usage: seedyn auth set [api-key] [options]
289
334
 
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 };