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 +20 -3
- package/package.json +7 -2
- package/src/browser-auth.mjs +105 -0
- package/src/cli.mjs +88 -2
- package/src/config.mjs +12 -1
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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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;
|