seedyn 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/LICENSE +21 -0
- package/README.md +81 -0
- package/THIRD_PARTY_NOTICES.md +32 -0
- package/bin/seedyn.mjs +20 -0
- package/package.json +23 -0
- package/src/cli.mjs +293 -0
- package/src/config.mjs +175 -0
- package/src/errors.mjs +6 -0
- package/src/upload.mjs +234 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Ilie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Seedyn CLI
|
|
2
|
+
|
|
3
|
+
Upload any file to Seedyn and print its permanent public URL. The package has
|
|
4
|
+
no runtime dependencies and requires Node.js 22.12 or newer.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
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:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx seedyn auth set
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Credentials are written to `$XDG_CONFIG_HOME/seedyn/config.json`, or
|
|
16
|
+
`~/.config/seedyn/config.json` when `XDG_CONFIG_HOME` is unset. The directory is
|
|
17
|
+
mode `0700` and the file is mode `0600`. `SEEDYN_CONFIG_PATH` can select a
|
|
18
|
+
different absolute file.
|
|
19
|
+
|
|
20
|
+
For CI, keep credentials outside the repository:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
export SEEDYN_API_KEY='sdn_live_…'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Authentication precedence is `--api-key`, then `SEEDYN_API_KEY`, then the
|
|
27
|
+
owner-only config file. API URL precedence is `--api-url`, then
|
|
28
|
+
`SEEDYN_API_URL`, then the config file, then `https://seedyn.dave.tips`.
|
|
29
|
+
Passing a secret on the command line can expose it to shell history and process
|
|
30
|
+
inspection; prefer the environment or `seedyn auth set`.
|
|
31
|
+
|
|
32
|
+
## Upload
|
|
33
|
+
|
|
34
|
+
The shortest form treats the first argument as a file:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx seedyn ./report.pdf
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The explicit form exposes all upload controls:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx seedyn upload ./report.pdf --copy
|
|
44
|
+
npx seedyn upload ./page.html --render-html --open
|
|
45
|
+
npx seedyn upload ./image.png --slug launch-shot --domain gurt
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--render-html` is an explicit opt-in. Seedyn verifies a non-empty UTF-8 HTML
|
|
49
|
+
document, stores it as `text/html`, and serves it inline only on a configured
|
|
50
|
+
public media domain. Uploaded scripts, forms, frames, embedded objects, and
|
|
51
|
+
network requests remain disabled by the response sandbox. Without the flag,
|
|
52
|
+
HTML is an ordinary attachment.
|
|
53
|
+
|
|
54
|
+
Agents can upload stdin by naming the resulting file:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
generate-report | npx seedyn upload - --filename report.html --render-html
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Useful options:
|
|
61
|
+
|
|
62
|
+
- `--copy` copies the URL using the operating system clipboard command.
|
|
63
|
+
- `--open` opens the URL using the operating system URL handler.
|
|
64
|
+
- `--json` prints a machine-readable response.
|
|
65
|
+
- `--quiet` prints only the URL.
|
|
66
|
+
- `--slug <slug>` requests a readable public slug.
|
|
67
|
+
- `--domain <id>` chooses a configured media-domain identifier.
|
|
68
|
+
- `--kind <auto|image|file|text>` supplies the server classification hint.
|
|
69
|
+
- `--language <language>` supplies the text-language hint.
|
|
70
|
+
- `--api-url <origin>` targets a local or custom Seedyn instance.
|
|
71
|
+
|
|
72
|
+
The server always classifies the uploaded bytes and enforces the API key scope.
|
|
73
|
+
Files are limited to 64 MiB; rendered HTML, images, and text are limited to
|
|
74
|
+
16 MiB.
|
|
75
|
+
|
|
76
|
+
## Attribution
|
|
77
|
+
|
|
78
|
+
The command workflow is inspired by the MIT-licensed
|
|
79
|
+
[`postplan@0.0.4`](https://www.jsdelivr.com/package/npm/postplan). See
|
|
80
|
+
[`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md) for attribution and the
|
|
81
|
+
retained license.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
The Seedyn CLI workflow was adapted from
|
|
4
|
+
[`postplan@0.0.4`](https://www.jsdelivr.com/package/npm/postplan), particularly
|
|
5
|
+
its low-step upload command, explicit API URL override, owner-only local
|
|
6
|
+
credential storage, and optional Git/CI-friendly operation. Postplan is
|
|
7
|
+
copyright 2026 t3dotgg and distributed under the following MIT license:
|
|
8
|
+
|
|
9
|
+
> MIT License
|
|
10
|
+
>
|
|
11
|
+
> Copyright (c) 2026 t3dotgg
|
|
12
|
+
>
|
|
13
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
> of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
> in the Software without restriction, including without limitation the rights
|
|
16
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
> copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
> furnished to do so, subject to the following conditions:
|
|
19
|
+
>
|
|
20
|
+
> The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
> copies or substantial portions of the Software.
|
|
22
|
+
>
|
|
23
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
> SOFTWARE.
|
|
30
|
+
|
|
31
|
+
Seedyn uses its own API contract and implementation. It does not include or run
|
|
32
|
+
the Postplan service.
|
package/bin/seedyn.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { run } from "../src/cli.mjs";
|
|
4
|
+
import { CliError } from "../src/errors.mjs";
|
|
5
|
+
|
|
6
|
+
process.stdout.on("error", (error) => {
|
|
7
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
await run(process.argv.slice(2));
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof CliError) {
|
|
15
|
+
console.error(error.message);
|
|
16
|
+
} else {
|
|
17
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
18
|
+
}
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seedyn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upload any file to Seedyn and receive its durable URL.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"bin/",
|
|
9
|
+
"src/",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"THIRD_PARTY_NOTICES.md"
|
|
13
|
+
],
|
|
14
|
+
"bin": {
|
|
15
|
+
"seedyn": "./bin/seedyn.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
configPath,
|
|
6
|
+
displayApiKey,
|
|
7
|
+
readConfig,
|
|
8
|
+
removeAuthentication,
|
|
9
|
+
resolveAuthentication,
|
|
10
|
+
saveAuthentication,
|
|
11
|
+
validateApiKey,
|
|
12
|
+
validateApiUrl,
|
|
13
|
+
} from "./config.mjs";
|
|
14
|
+
import { CliError } from "./errors.mjs";
|
|
15
|
+
import { copyUrl, openUrl, uploadFile } from "./upload.mjs";
|
|
16
|
+
|
|
17
|
+
const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
18
|
+
|
|
19
|
+
export async function run(arguments_) {
|
|
20
|
+
const args = [...arguments_];
|
|
21
|
+
const first = args[0];
|
|
22
|
+
if (!first || first === "help" || first === "--help" || first === "-h") {
|
|
23
|
+
console.log(HELP);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (first === "version" || first === "--version" || first === "-V") {
|
|
27
|
+
console.log(VERSION);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (first === "auth") {
|
|
31
|
+
await authCommand(args.slice(1));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await uploadCommand(first === "upload" ? args.slice(1) : args);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function authCommand(args) {
|
|
38
|
+
const action = args[0] || "help";
|
|
39
|
+
if (action === "help" || action === "--help" || action === "-h") {
|
|
40
|
+
console.log(AUTH_HELP);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (action === "set") {
|
|
45
|
+
const { values, positionals } = parseArgs({
|
|
46
|
+
args: args.slice(1),
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
strict: true,
|
|
49
|
+
options: {
|
|
50
|
+
"api-key": { type: "string" },
|
|
51
|
+
"api-url": { type: "string" },
|
|
52
|
+
help: { type: "boolean", short: "h" },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (values.help) {
|
|
56
|
+
console.log(AUTH_SET_HELP);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (positionals.length > 1)
|
|
60
|
+
throw new CliError("auth set accepts at most one API key.");
|
|
61
|
+
const key = validateApiKey(
|
|
62
|
+
values["api-key"] || positionals[0] || (await readSecret()),
|
|
63
|
+
);
|
|
64
|
+
const apiUrl = values["api-url"]
|
|
65
|
+
? validateApiUrl(values["api-url"])
|
|
66
|
+
: undefined;
|
|
67
|
+
const result = await saveAuthentication({ apiKey: key, apiUrl });
|
|
68
|
+
console.log(`Seedyn credentials saved to ${result.file}`);
|
|
69
|
+
console.log(`API key: ${displayApiKey(key)}`);
|
|
70
|
+
console.log(
|
|
71
|
+
`API URL: ${result.value.apiUrl || "https://seedyn.dave.tips"}`,
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (action === "remove") {
|
|
77
|
+
if (args.length > 1) throw new CliError("auth remove takes no arguments.");
|
|
78
|
+
console.log(
|
|
79
|
+
`Removed the stored API key from ${await removeAuthentication()}`,
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (action === "status") {
|
|
85
|
+
if (args.length > 1) throw new CliError("auth status takes no arguments.");
|
|
86
|
+
const config = await readConfig();
|
|
87
|
+
console.log(`Config: ${configPath()}`);
|
|
88
|
+
console.log(`API URL: ${config.apiUrl || "https://seedyn.dave.tips"}`);
|
|
89
|
+
console.log(
|
|
90
|
+
`Stored API key: ${config.apiKey ? displayApiKey(config.apiKey) : "not configured"}`,
|
|
91
|
+
);
|
|
92
|
+
console.log(
|
|
93
|
+
`Environment API key: ${process.env.SEEDYN_API_KEY ? "configured" : "not configured"}`,
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new CliError(`Unknown auth command: ${action}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function uploadCommand(args) {
|
|
102
|
+
const { values, positionals } = parseArgs({
|
|
103
|
+
args,
|
|
104
|
+
allowPositionals: true,
|
|
105
|
+
strict: true,
|
|
106
|
+
options: {
|
|
107
|
+
"api-key": { type: "string" },
|
|
108
|
+
"api-url": { type: "string" },
|
|
109
|
+
copy: { type: "boolean", short: "c" },
|
|
110
|
+
domain: { type: "string", short: "d" },
|
|
111
|
+
filename: { type: "string" },
|
|
112
|
+
help: { type: "boolean", short: "h" },
|
|
113
|
+
json: { type: "boolean" },
|
|
114
|
+
kind: { type: "string", default: "auto" },
|
|
115
|
+
language: { type: "string" },
|
|
116
|
+
open: { type: "boolean", short: "o" },
|
|
117
|
+
quiet: { type: "boolean", short: "q" },
|
|
118
|
+
render: { type: "boolean" },
|
|
119
|
+
"render-html": { type: "boolean" },
|
|
120
|
+
slug: { type: "string", short: "s" },
|
|
121
|
+
timeout: { type: "string", default: "120" },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (values.help) {
|
|
125
|
+
console.log(UPLOAD_HELP);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (positionals.length !== 1) {
|
|
129
|
+
throw new CliError("Choose exactly one file, or use - for stdin.");
|
|
130
|
+
}
|
|
131
|
+
const kind = values.kind;
|
|
132
|
+
if (!new Set(["auto", "image", "file", "text"]).has(kind)) {
|
|
133
|
+
throw new CliError("--kind must be auto, image, file, or text.");
|
|
134
|
+
}
|
|
135
|
+
const timeoutSeconds = Number(values.timeout);
|
|
136
|
+
if (
|
|
137
|
+
!Number.isInteger(timeoutSeconds) ||
|
|
138
|
+
timeoutSeconds < 1 ||
|
|
139
|
+
timeoutSeconds > 600
|
|
140
|
+
) {
|
|
141
|
+
throw new CliError(
|
|
142
|
+
"--timeout must be a whole number from 1 to 600 seconds.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (values.json && values.quiet) {
|
|
146
|
+
throw new CliError("Choose either --json or --quiet, not both.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const auth = await resolveAuthentication({
|
|
150
|
+
apiKey: values["api-key"],
|
|
151
|
+
apiUrl: values["api-url"],
|
|
152
|
+
});
|
|
153
|
+
const result = await uploadFile({
|
|
154
|
+
...auth,
|
|
155
|
+
version: VERSION,
|
|
156
|
+
file: positionals[0],
|
|
157
|
+
filename: values.filename,
|
|
158
|
+
renderHtml: Boolean(values["render-html"] || values.render),
|
|
159
|
+
slug: values.slug,
|
|
160
|
+
domain: values.domain,
|
|
161
|
+
kind,
|
|
162
|
+
language: values.language,
|
|
163
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const notices = [];
|
|
167
|
+
if (values.copy)
|
|
168
|
+
notices.push(
|
|
169
|
+
copyUrl(result.url)
|
|
170
|
+
? "Copied URL."
|
|
171
|
+
: "Could not access a clipboard command.",
|
|
172
|
+
);
|
|
173
|
+
if (values.open)
|
|
174
|
+
notices.push(
|
|
175
|
+
openUrl(result.url) ? "Opened URL." : "Could not open the URL handler.",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (values.json) {
|
|
179
|
+
console.log(JSON.stringify(result, null, 2));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (values.quiet) {
|
|
183
|
+
console.log(result.url);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
console.log(`Uploaded ${result.filename}`);
|
|
187
|
+
console.log(`URL: ${result.url}`);
|
|
188
|
+
if (result.renderedHtml) console.log("Mode: sandboxed HTML page");
|
|
189
|
+
for (const notice of notices) console.log(notice);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function readSecret() {
|
|
193
|
+
if (!process.stdin.isTTY) {
|
|
194
|
+
const chunks = [];
|
|
195
|
+
let bytes = 0;
|
|
196
|
+
for await (const chunk of process.stdin) {
|
|
197
|
+
const value = Buffer.from(chunk);
|
|
198
|
+
bytes += value.byteLength;
|
|
199
|
+
if (bytes > 512) throw new CliError("The piped API key is too long.");
|
|
200
|
+
chunks.push(value);
|
|
201
|
+
}
|
|
202
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
let value = "";
|
|
207
|
+
const wasRaw = process.stdin.isRaw;
|
|
208
|
+
const cleanup = () => {
|
|
209
|
+
process.stdin.removeListener("data", onData);
|
|
210
|
+
process.stdin.setRawMode(wasRaw);
|
|
211
|
+
process.stdin.pause();
|
|
212
|
+
process.stderr.write("\n");
|
|
213
|
+
};
|
|
214
|
+
const onData = (chunk) => {
|
|
215
|
+
for (const byte of Buffer.from(chunk)) {
|
|
216
|
+
if (byte === 3) {
|
|
217
|
+
cleanup();
|
|
218
|
+
reject(new CliError("Authentication setup cancelled."));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (byte === 4 && value.length === 0) {
|
|
222
|
+
cleanup();
|
|
223
|
+
reject(new CliError("No API key entered."));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (byte === 10 || byte === 13) {
|
|
227
|
+
cleanup();
|
|
228
|
+
resolve(value);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (byte === 8 || byte === 127) {
|
|
232
|
+
value = value.slice(0, -1);
|
|
233
|
+
} else if (byte >= 0x20 && byte <= 0x7e && value.length < 256) {
|
|
234
|
+
value += String.fromCharCode(byte);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
process.stderr.write("Seedyn API key: ");
|
|
239
|
+
process.stdin.setRawMode(true);
|
|
240
|
+
process.stdin.resume();
|
|
241
|
+
process.stdin.on("data", onData);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const HELP = `Seedyn ${VERSION}
|
|
246
|
+
|
|
247
|
+
Upload any file and receive its durable URL.
|
|
248
|
+
|
|
249
|
+
Usage:
|
|
250
|
+
seedyn <file> [options]
|
|
251
|
+
seedyn upload <file> [options]
|
|
252
|
+
seedyn auth <set|status|remove>
|
|
253
|
+
|
|
254
|
+
Examples:
|
|
255
|
+
seedyn ./image.png --copy
|
|
256
|
+
seedyn ./page.html --render-html --open
|
|
257
|
+
seedyn upload - --filename report.html --render-html
|
|
258
|
+
|
|
259
|
+
Run seedyn upload --help for every upload option.`;
|
|
260
|
+
|
|
261
|
+
const UPLOAD_HELP = `Usage: seedyn upload <file|-> [options]
|
|
262
|
+
|
|
263
|
+
Options:
|
|
264
|
+
-c, --copy Copy the resulting URL
|
|
265
|
+
-o, --open Open the resulting URL
|
|
266
|
+
--render-html, --render Serve verified UTF-8 HTML as a sandboxed page
|
|
267
|
+
-s, --slug <slug> Request a custom public slug
|
|
268
|
+
-d, --domain <id> Choose a configured media-domain identifier
|
|
269
|
+
--kind <kind> auto, image, file, or text (default: auto)
|
|
270
|
+
--language <language> Text-language hint
|
|
271
|
+
--filename <name> Override the uploaded filename; required for stdin
|
|
272
|
+
--json Print machine-readable JSON
|
|
273
|
+
-q, --quiet Print only the URL
|
|
274
|
+
--timeout <seconds> Upload timeout from 1 to 600 (default: 120)
|
|
275
|
+
--api-url <origin> Override the Seedyn application origin
|
|
276
|
+
--api-key <key> Override auth (prefer env/config; argv is visible)
|
|
277
|
+
-h, --help Show this help`;
|
|
278
|
+
|
|
279
|
+
const AUTH_HELP = `Usage: seedyn auth <command>
|
|
280
|
+
|
|
281
|
+
Commands:
|
|
282
|
+
set [api-key] Store a key with owner-only permissions
|
|
283
|
+
status Show config and redacted credential status
|
|
284
|
+
remove Remove the stored API key
|
|
285
|
+
|
|
286
|
+
Prefer bare "seedyn auth set" for a hidden prompt.`;
|
|
287
|
+
|
|
288
|
+
const AUTH_SET_HELP = `Usage: seedyn auth set [api-key] [options]
|
|
289
|
+
|
|
290
|
+
Options:
|
|
291
|
+
--api-url <origin> Save a custom Seedyn application origin
|
|
292
|
+
--api-key <key> Supply the key (prefer the hidden prompt)
|
|
293
|
+
-h, --help Show this help`;
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
unlink,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { CliError } from "./errors.mjs";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_API_URL = "https://seedyn.dave.tips";
|
|
17
|
+
const API_KEY_PATTERN = /^sdn_live_[A-Za-z0-9_-]{8}_[A-Za-z0-9_-]{43}$/u;
|
|
18
|
+
|
|
19
|
+
export function validateApiKey(value) {
|
|
20
|
+
const key = typeof value === "string" ? value.trim() : "";
|
|
21
|
+
if (!API_KEY_PATTERN.test(key)) {
|
|
22
|
+
throw new CliError("That is not a valid Seedyn API key.");
|
|
23
|
+
}
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function displayApiKey(value) {
|
|
28
|
+
const key = validateApiKey(value);
|
|
29
|
+
return `${key.slice(0, "sdn_live_".length + 8)}_…`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateApiUrl(value) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(String(value).trim());
|
|
36
|
+
} catch {
|
|
37
|
+
throw new CliError("The Seedyn API URL must be a valid origin.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const local =
|
|
41
|
+
url.hostname === "localhost" ||
|
|
42
|
+
url.hostname.endsWith(".localhost") ||
|
|
43
|
+
url.hostname === "127.0.0.1" ||
|
|
44
|
+
url.hostname === "[::1]";
|
|
45
|
+
if (
|
|
46
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && local)) ||
|
|
47
|
+
url.username ||
|
|
48
|
+
url.password ||
|
|
49
|
+
url.pathname !== "/" ||
|
|
50
|
+
url.search ||
|
|
51
|
+
url.hash
|
|
52
|
+
) {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
"The Seedyn API URL must be an HTTPS origin (HTTP is allowed only on localhost).",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return url.origin;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function configPath(environment = process.env) {
|
|
61
|
+
const explicit = environment.SEEDYN_CONFIG_PATH?.trim();
|
|
62
|
+
if (explicit) {
|
|
63
|
+
if (!path.isAbsolute(explicit)) {
|
|
64
|
+
throw new CliError("SEEDYN_CONFIG_PATH must be an absolute path.");
|
|
65
|
+
}
|
|
66
|
+
return explicit;
|
|
67
|
+
}
|
|
68
|
+
const xdg = environment.XDG_CONFIG_HOME?.trim();
|
|
69
|
+
const root =
|
|
70
|
+
xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".config");
|
|
71
|
+
return path.join(root, "seedyn", "config.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function readConfig(environment = process.env) {
|
|
75
|
+
const file = configPath(environment);
|
|
76
|
+
try {
|
|
77
|
+
const status = await lstat(file);
|
|
78
|
+
if (!status.isFile() || status.isSymbolicLink()) {
|
|
79
|
+
throw new CliError(`Seedyn config is not a regular file: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
if (process.platform !== "win32" && (status.mode & 0o077) !== 0) {
|
|
82
|
+
throw new CliError(
|
|
83
|
+
`Seedyn config permissions are too open: ${file}. Restrict it to mode 0600.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
87
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
88
|
+
throw new Error("not an object");
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === "ENOENT") return {};
|
|
93
|
+
if (error instanceof CliError) throw error;
|
|
94
|
+
throw new CliError(`Seedyn config is unreadable: ${file}`, {
|
|
95
|
+
cause: error,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function resolveAuthentication(
|
|
101
|
+
options = {},
|
|
102
|
+
environment = process.env,
|
|
103
|
+
) {
|
|
104
|
+
const config = await readConfig(environment);
|
|
105
|
+
const apiUrl = validateApiUrl(
|
|
106
|
+
options.apiUrl ||
|
|
107
|
+
environment.SEEDYN_API_URL ||
|
|
108
|
+
config.apiUrl ||
|
|
109
|
+
DEFAULT_API_URL,
|
|
110
|
+
);
|
|
111
|
+
const candidate =
|
|
112
|
+
options.apiKey || environment.SEEDYN_API_KEY || config.apiKey;
|
|
113
|
+
if (!candidate) {
|
|
114
|
+
throw new CliError(
|
|
115
|
+
"Missing Seedyn API key. Run `seedyn auth set` or set SEEDYN_API_KEY.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { apiKey: validateApiKey(candidate), apiUrl };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function saveAuthentication(
|
|
122
|
+
{ apiKey, apiUrl },
|
|
123
|
+
environment = process.env,
|
|
124
|
+
) {
|
|
125
|
+
const existing = await readConfig(environment);
|
|
126
|
+
const next = {
|
|
127
|
+
...existing,
|
|
128
|
+
apiKey: validateApiKey(apiKey),
|
|
129
|
+
...(apiUrl ? { apiUrl: validateApiUrl(apiUrl) } : {}),
|
|
130
|
+
updatedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
await writeConfig(next, environment);
|
|
133
|
+
return { file: configPath(environment), value: next };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function removeAuthentication(environment = process.env) {
|
|
137
|
+
const existing = await readConfig(environment);
|
|
138
|
+
const { apiKey: _removed, ...next } = existing;
|
|
139
|
+
await writeConfig(
|
|
140
|
+
{ ...next, updatedAt: new Date().toISOString() },
|
|
141
|
+
environment,
|
|
142
|
+
);
|
|
143
|
+
return configPath(environment);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function writeConfig(value, environment) {
|
|
147
|
+
const file = configPath(environment);
|
|
148
|
+
const directory = path.dirname(file);
|
|
149
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
150
|
+
const directoryStatus = await lstat(directory);
|
|
151
|
+
if (!directoryStatus.isDirectory() || directoryStatus.isSymbolicLink()) {
|
|
152
|
+
throw new CliError(`Seedyn config directory is unsafe: ${directory}`);
|
|
153
|
+
}
|
|
154
|
+
await chmod(directory, 0o700);
|
|
155
|
+
|
|
156
|
+
const temporary = path.join(
|
|
157
|
+
directory,
|
|
158
|
+
`.config.${process.pid}.${randomUUID()}.tmp`,
|
|
159
|
+
);
|
|
160
|
+
try {
|
|
161
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
162
|
+
encoding: "utf8",
|
|
163
|
+
flag: "wx",
|
|
164
|
+
mode: 0o600,
|
|
165
|
+
});
|
|
166
|
+
await chmod(temporary, 0o600);
|
|
167
|
+
await rename(temporary, file);
|
|
168
|
+
await chmod(file, 0o600);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
await unlink(temporary).catch(() => undefined);
|
|
171
|
+
throw new CliError(`Could not write Seedyn config: ${file}`, {
|
|
172
|
+
cause: error,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/errors.mjs
ADDED
package/src/upload.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { openAsBlob } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
import { CliError } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export async function uploadFile(input) {
|
|
12
|
+
const source = await uploadSource(input.file, input.filename);
|
|
13
|
+
const form = new FormData();
|
|
14
|
+
form.append("file", source.blob, source.filename);
|
|
15
|
+
append(form, "kind", input.kind === "auto" ? undefined : input.kind);
|
|
16
|
+
append(form, "textLanguage", input.language);
|
|
17
|
+
append(form, "slug", input.slug);
|
|
18
|
+
append(form, "mediaDomain", input.domain);
|
|
19
|
+
if (input.renderHtml) form.append("renderHtml", "true");
|
|
20
|
+
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timeout = setTimeout(
|
|
23
|
+
() => controller.abort("timeout"),
|
|
24
|
+
input.timeoutMs,
|
|
25
|
+
);
|
|
26
|
+
timeout.unref();
|
|
27
|
+
const interrupt = () => controller.abort("interrupt");
|
|
28
|
+
process.once("SIGINT", interrupt);
|
|
29
|
+
|
|
30
|
+
let response;
|
|
31
|
+
let body;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(new URL("/api/upload", input.apiUrl), {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
Accept: "application/json",
|
|
37
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
38
|
+
"User-Agent": `seedyn-cli/${input.version}`,
|
|
39
|
+
},
|
|
40
|
+
body: form,
|
|
41
|
+
signal: controller.signal,
|
|
42
|
+
});
|
|
43
|
+
body = await readJsonResponse(response);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (controller.signal.reason === "timeout") {
|
|
46
|
+
throw new CliError(
|
|
47
|
+
`Upload timed out after ${input.timeoutMs / 1000} seconds.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (controller.signal.reason === "interrupt") {
|
|
51
|
+
throw new CliError("Upload cancelled.");
|
|
52
|
+
}
|
|
53
|
+
throw new CliError("Seedyn could not be reached.", { cause: error });
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
process.removeListener("SIGINT", interrupt);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const message =
|
|
61
|
+
typeof body?.error?.message === "string"
|
|
62
|
+
? body.error.message
|
|
63
|
+
: `Seedyn rejected the upload (HTTP ${response.status}).`;
|
|
64
|
+
const requestId =
|
|
65
|
+
typeof body?.error?.requestId === "string"
|
|
66
|
+
? ` Request: ${body.error.requestId}`
|
|
67
|
+
: "";
|
|
68
|
+
throw new CliError(`${message}${requestId}`);
|
|
69
|
+
}
|
|
70
|
+
if (!body || typeof body.url !== "string") {
|
|
71
|
+
throw new CliError("Seedyn returned an unreadable upload response.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let url;
|
|
75
|
+
try {
|
|
76
|
+
url = new URL(body.url);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new CliError("Seedyn returned an invalid public URL.");
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
(url.protocol !== "https:" && url.protocol !== "http:") ||
|
|
82
|
+
(url.protocol === "http:" && !isLocalHostname(url.hostname)) ||
|
|
83
|
+
url.username ||
|
|
84
|
+
url.password
|
|
85
|
+
) {
|
|
86
|
+
throw new CliError("Seedyn returned an unsafe public URL.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
id: typeof body.id === "string" ? body.id : null,
|
|
91
|
+
kind: typeof body.kind === "string" ? body.kind : null,
|
|
92
|
+
contentType: typeof body.contentType === "string" ? body.contentType : null,
|
|
93
|
+
disposition: typeof body.disposition === "string" ? body.disposition : null,
|
|
94
|
+
renderedHtml: body.renderedHtml === true,
|
|
95
|
+
url: url.toString(),
|
|
96
|
+
filename: source.filename,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isLocalHostname(hostname) {
|
|
101
|
+
return (
|
|
102
|
+
hostname === "localhost" ||
|
|
103
|
+
hostname.endsWith(".localhost") ||
|
|
104
|
+
hostname === "127.0.0.1" ||
|
|
105
|
+
hostname === "[::1]"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function copyUrl(value) {
|
|
110
|
+
const attempts =
|
|
111
|
+
process.platform === "darwin"
|
|
112
|
+
? [["pbcopy", []]]
|
|
113
|
+
: process.platform === "win32"
|
|
114
|
+
? [["clip.exe", []]]
|
|
115
|
+
: [
|
|
116
|
+
["wl-copy", []],
|
|
117
|
+
["xclip", ["-selection", "clipboard"]],
|
|
118
|
+
["xsel", ["--clipboard", "--input"]],
|
|
119
|
+
];
|
|
120
|
+
for (const [command, args] of attempts) {
|
|
121
|
+
const result = spawnSync(command, args, {
|
|
122
|
+
input: value,
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
125
|
+
timeout: 5_000,
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
if (!result.error && result.status === 0) return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function openUrl(value) {
|
|
134
|
+
const target = new URL(value);
|
|
135
|
+
let command;
|
|
136
|
+
let args;
|
|
137
|
+
if (process.platform === "darwin") {
|
|
138
|
+
command = "open";
|
|
139
|
+
args = [target.toString()];
|
|
140
|
+
} else if (process.platform === "win32") {
|
|
141
|
+
command = "rundll32.exe";
|
|
142
|
+
args = ["url.dll,FileProtocolHandler", target.toString()];
|
|
143
|
+
} else {
|
|
144
|
+
command = "xdg-open";
|
|
145
|
+
args = [target.toString()];
|
|
146
|
+
}
|
|
147
|
+
const result = spawnSync(command, args, {
|
|
148
|
+
stdio: "ignore",
|
|
149
|
+
timeout: 5_000,
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
});
|
|
152
|
+
return !result.error && result.status === 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function uploadSource(file, explicitFilename) {
|
|
156
|
+
if (file === "-") {
|
|
157
|
+
if (!explicitFilename) {
|
|
158
|
+
throw new CliError("Uploading stdin requires --filename <name>.");
|
|
159
|
+
}
|
|
160
|
+
const bytes = await readStdin();
|
|
161
|
+
return { blob: new Blob(bytes), filename: safeFilename(explicitFilename) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const resolved = path.resolve(file);
|
|
165
|
+
let status;
|
|
166
|
+
try {
|
|
167
|
+
status = await stat(resolved);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw new CliError(`File does not exist: ${resolved}`, { cause: error });
|
|
170
|
+
}
|
|
171
|
+
if (!status.isFile()) throw new CliError(`Not a regular file: ${resolved}`);
|
|
172
|
+
if (status.size > MAX_UPLOAD_BYTES) {
|
|
173
|
+
throw new CliError("That file exceeds Seedyn's 64 MiB upload limit.");
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
blob: await openAsBlob(resolved),
|
|
177
|
+
filename: safeFilename(explicitFilename || path.basename(resolved)),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readStdin() {
|
|
182
|
+
if (process.stdin.isTTY) {
|
|
183
|
+
throw new CliError("No stdin data was provided.");
|
|
184
|
+
}
|
|
185
|
+
const chunks = [];
|
|
186
|
+
let received = 0;
|
|
187
|
+
for await (const chunk of process.stdin) {
|
|
188
|
+
const bytes = Buffer.from(chunk);
|
|
189
|
+
received += bytes.byteLength;
|
|
190
|
+
if (received > MAX_UPLOAD_BYTES) {
|
|
191
|
+
throw new CliError("Stdin exceeds Seedyn's 64 MiB upload limit.");
|
|
192
|
+
}
|
|
193
|
+
chunks.push(bytes);
|
|
194
|
+
}
|
|
195
|
+
return chunks;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function safeFilename(value) {
|
|
199
|
+
const filename = String(value).trim();
|
|
200
|
+
if (
|
|
201
|
+
!filename ||
|
|
202
|
+
filename === "." ||
|
|
203
|
+
filename === ".." ||
|
|
204
|
+
/[\\/\0\r\n]/u.test(filename)
|
|
205
|
+
) {
|
|
206
|
+
throw new CliError(
|
|
207
|
+
"--filename must be a plain filename without path separators.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (Buffer.byteLength(filename, "utf8") > 255) {
|
|
211
|
+
throw new CliError("The filename is longer than 255 UTF-8 bytes.");
|
|
212
|
+
}
|
|
213
|
+
return filename;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function append(form, name, value) {
|
|
217
|
+
if (typeof value === "string" && value.length > 0) form.append(name, value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readJsonResponse(response) {
|
|
221
|
+
const declared = Number(response.headers.get("content-length"));
|
|
222
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
223
|
+
throw new CliError("Seedyn returned an unexpectedly large response.");
|
|
224
|
+
}
|
|
225
|
+
const text = await response.text();
|
|
226
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
227
|
+
throw new CliError("Seedyn returned an unexpectedly large response.");
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(text);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|