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 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
@@ -0,0 +1,6 @@
1
+ export class CliError extends Error {
2
+ constructor(message, options) {
3
+ super(message, options);
4
+ this.name = "CliError";
5
+ }
6
+ }
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
+ }