miinideck 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.
Files changed (3) hide show
  1. package/README.md +60 -0
  2. package/index.js +337 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # miinideck CLI
2
+
3
+ Deploy an HTML file, a folder, or a ZIP to a **private, unguessable link** —
4
+ straight from your terminal or your AI coding tool.
5
+
6
+ ```bash
7
+ npx miinideck deploy ./index.html
8
+ npx miinideck deploy ./dist # a build folder → zipped + deployed
9
+ npx miinideck deploy ./site.zip --keep --password hunter2
10
+ ```
11
+
12
+ You get back one private link (e.g. `https://view.miinideck.com/…`). It's
13
+ no-indexed and unguessable by default, so only the people you send it to can
14
+ open it.
15
+
16
+ ## Setup
17
+
18
+ 1. Create a free account at [miinideck.com](https://www.miinideck.com).
19
+ 2. Go to **Settings → API tokens** and create a token.
20
+ 3. Make it available to the CLI:
21
+
22
+ ```bash
23
+ export MIINIDECK_TOKEN="mdk_…"
24
+ # or pass --token on each command
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```
30
+ miinideck deploy <path> [options]
31
+
32
+ <path> an .html file, a .zip bundle, or a folder (zipped for you)
33
+
34
+ --token <t> API token (or set MIINIDECK_TOKEN)
35
+ --name <title> title for the link (default: the file / folder name)
36
+ --password <p> require a password to open the link
37
+ --keep keep the link permanently (default: 7-day self-destruct on
38
+ the free tier)
39
+ --api <url> API base (default https://www.miinideck.com)
40
+ --help show help
41
+ ```
42
+
43
+ The link is printed to **stdout** (so a script or AI tool can capture it);
44
+ progress and hints go to **stderr**.
45
+
46
+ ## Use it from an AI coding tool
47
+
48
+ Codex, Claude Code, and any terminal agent can run shell commands — so they can
49
+ ship what they just built for you:
50
+
51
+ > After you build the site, run `npx miinideck deploy ./dist` and give me the link.
52
+
53
+ ## What can it host?
54
+
55
+ Anything that runs in the browser: a self-contained HTML file, or a built
56
+ static site / app bundled as a folder or ZIP (its CSS, JS, images and assets
57
+ intact). It serves static files — not a backend server.
58
+
59
+ Per-file size and how long links live depend on your plan; see
60
+ [pricing](https://www.miinideck.com/pricing).
package/index.js ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // miinideck CLI (session-31) — deploy an HTML file, a folder, or a ZIP to a
5
+ // private, unguessable link from the terminal or an AI coding tool.
6
+ //
7
+ // miinideck login browser auth (vercel/gh style), no token paste
8
+ // miinideck deploy <path> publish → private link (same path re-deploys
9
+ // update the SAME link)
10
+ // miinideck list your deployments, grouped by project
11
+ // miinideck logout clear the local token
12
+ //
13
+ // Auth resolves in order: --token > MIINIDECK_TOKEN env > ~/.miinideck/config.json
14
+ // (written by `login`). stdout carries the link / data; stderr carries progress.
15
+
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const path = require("path");
19
+ const { spawn } = require("child_process");
20
+
21
+ const VERSION = require("./package.json").version;
22
+ const DEFAULT_API = (process.env.MIINIDECK_API || "https://www.miinideck.com").replace(/\/+$/, "");
23
+ const CONFIG_DIR = path.join(os.homedir(), ".miinideck");
24
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
25
+
26
+ const HELP = `miinideck ${VERSION} — deploy to a private link from your terminal
27
+
28
+ Usage:
29
+ miinideck login sign in (opens your browser — no token to paste)
30
+ miinideck deploy <path> [options] publish a file/folder/zip → a private link
31
+ miinideck list your deployments, grouped by project
32
+ miinideck logout clear the saved sign-in on this machine
33
+
34
+ Deploy options:
35
+ --name <title> title for the link (default: the file / folder name)
36
+ --password <p> require a password to open the link
37
+ --keep keep the link permanently (free tier: uses your one
38
+ always-on slot; default is a 7-day self-destruct)
39
+ --new force a brand-new link instead of updating this folder's
40
+ --token <t> use a specific API token (else MIINIDECK_TOKEN / saved login)
41
+ --api <url> API base (default ${DEFAULT_API})
42
+
43
+ Re-deploying the SAME path updates the SAME link (the URL doesn't change).
44
+
45
+ Examples:
46
+ miinideck login
47
+ miinideck deploy ./index.html
48
+ miinideck deploy ./dist # a build folder → zipped + deployed
49
+ miinideck deploy ./dist # again → updates the same link
50
+ miinideck deploy ./site.zip --keep --password hunter2 --new
51
+ `;
52
+
53
+ function die(msg) {
54
+ process.stderr.write(`✗ ${msg}\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const out = { _: [], flags: {} };
60
+ const booleans = new Set(["keep", "new", "help", "version"]);
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const a = argv[i];
63
+ if (a.startsWith("--")) {
64
+ const key = a.slice(2);
65
+ out.flags[key] = booleans.has(key) ? true : argv[++i];
66
+ } else {
67
+ out._.push(a);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // ── config (token + per-path deploy memory) ──────────────────────────────
74
+ function readConfig() {
75
+ try {
76
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) || {};
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+ function writeConfig(cfg) {
82
+ try {
83
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
84
+ } catch {
85
+ /* ignore */
86
+ }
87
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
88
+ }
89
+ function resolveToken(flags) {
90
+ return flags.token || process.env.MIINIDECK_TOKEN || readConfig().token || null;
91
+ }
92
+ function apiBase(flags) {
93
+ return (flags.api || DEFAULT_API).replace(/\/+$/, "");
94
+ }
95
+ function viewerBaseFrom(api) {
96
+ if (process.env.MIINIDECK_VIEWER) return process.env.MIINIDECK_VIEWER.replace(/\/+$/, "");
97
+ return api.replace("://www.", "://view.");
98
+ }
99
+
100
+ // ── small net helpers ────────────────────────────────────────────────────
101
+ function sleep(ms) {
102
+ return new Promise((r) => setTimeout(r, ms));
103
+ }
104
+ async function postJson(url, body, token) {
105
+ const headers = { "Content-Type": "application/json" };
106
+ if (token) headers.Authorization = `Bearer ${token}`;
107
+ let res;
108
+ try {
109
+ res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
110
+ } catch (e) {
111
+ die(`Network error reaching ${url} — ${e && e.message ? e.message : e}`);
112
+ }
113
+ let json = null;
114
+ try {
115
+ json = await res.json();
116
+ } catch {
117
+ /* non-JSON */
118
+ }
119
+ return { ok: res.ok, status: res.status, json };
120
+ }
121
+ function openBrowser(url) {
122
+ try {
123
+ const isWin = process.platform === "win32";
124
+ const cmd = process.platform === "darwin" ? "open" : isWin ? "cmd" : "xdg-open";
125
+ const args = isWin ? ["/c", "start", "", url] : [url];
126
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // ── login (browser authorization) ────────────────────────────────────────
134
+ async function login(args) {
135
+ const api = apiBase(args.flags);
136
+ const label = `${os.userInfo().username}@${os.hostname()}`.slice(0, 80);
137
+
138
+ const start = await postJson(`${api}/api/v1/cli/auth/start`, { label });
139
+ if (!start.ok || !start.json || !start.json.device_code) {
140
+ die((start.json && start.json.error) || "Could not start login.");
141
+ }
142
+ const { device_code, verify_url, interval_sec, expires_in_sec } = start.json;
143
+
144
+ process.stderr.write(`\nOpening your browser to authorize:\n ${verify_url}\n\n`);
145
+ openBrowser(verify_url);
146
+ process.stderr.write("Waiting for you to click Authorize");
147
+
148
+ const deadline = Date.now() + (expires_in_sec || 600) * 1000;
149
+ const interval = (interval_sec || 3) * 1000;
150
+ while (Date.now() < deadline) {
151
+ await sleep(interval);
152
+ const poll = await postJson(`${api}/api/v1/cli/auth/poll`, { device_code });
153
+ const st = poll.json && poll.json.status;
154
+ if (st === "authorized" && poll.json.token) {
155
+ const cfg = readConfig();
156
+ cfg.token = poll.json.token;
157
+ writeConfig(cfg);
158
+ process.stderr.write("\n✓ Signed in. Saved to ~/.miinideck/config.json\n");
159
+ return;
160
+ }
161
+ if (st === "denied") die("\nAuthorization was denied.");
162
+ if (st === "expired") die("\nLogin expired. Run `miinideck login` again.");
163
+ process.stderr.write(".");
164
+ }
165
+ die("\nLogin timed out. Run `miinideck login` again.");
166
+ }
167
+
168
+ function logout() {
169
+ const cfg = readConfig();
170
+ delete cfg.token;
171
+ writeConfig(cfg);
172
+ process.stderr.write("✓ Signed out on this machine.\n");
173
+ }
174
+
175
+ // ── list ─────────────────────────────────────────────────────────────────
176
+ async function list(args) {
177
+ const api = apiBase(args.flags);
178
+ const token = resolveToken(args.flags);
179
+ if (!token) die("Not signed in. Run `miinideck login` first.");
180
+
181
+ let res;
182
+ try {
183
+ res = await fetch(`${api}/api/v1/docs`, { headers: { Authorization: `Bearer ${token}` } });
184
+ } catch (e) {
185
+ die(`Network error — ${e && e.message ? e.message : e}`);
186
+ }
187
+ if (!res.ok) {
188
+ const j = await res.json().catch(() => ({}));
189
+ die(j.error || `Could not list (HTTP ${res.status}).`);
190
+ }
191
+ const data = await res.json();
192
+ if (!data.projects || data.projects.length === 0) {
193
+ process.stdout.write("No deployments yet. Try: miinideck deploy ./dist\n");
194
+ return;
195
+ }
196
+ for (const p of data.projects) {
197
+ process.stdout.write(`\n${p.name}\n`);
198
+ for (const d of p.docs) {
199
+ process.stdout.write(` • ${d.title || "(untitled)"}\n ${d.url}\n`);
200
+ }
201
+ }
202
+ process.stdout.write("\n");
203
+ }
204
+
205
+ // ── deploy ─────────────────────────────────────────────────────────────────
206
+ async function zipDirectory(dir) {
207
+ const JSZip = require("jszip");
208
+ const zip = new JSZip();
209
+ const root = path.resolve(dir);
210
+ const SKIP = new Set(["node_modules", ".git", ".DS_Store", ".next", ".vercel"]);
211
+ const walk = (d) => {
212
+ for (const name of fs.readdirSync(d)) {
213
+ if (SKIP.has(name)) continue;
214
+ const full = path.join(d, name);
215
+ const stat = fs.statSync(full);
216
+ if (stat.isDirectory()) walk(full);
217
+ else zip.file(path.relative(root, full).split(path.sep).join("/"), fs.readFileSync(full));
218
+ }
219
+ };
220
+ walk(root);
221
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
222
+ }
223
+
224
+ async function readTarget(target) {
225
+ let stat;
226
+ try {
227
+ stat = fs.statSync(target);
228
+ } catch {
229
+ die(`Path not found: ${target}`);
230
+ }
231
+ const base = path.basename(target.replace(/[/\\]+$/, "")) || "upload";
232
+ if (stat.isDirectory()) {
233
+ return { bytes: await zipDirectory(target), fileKind: "zip", contentType: "application/zip", sourceFilename: `${base}.zip` };
234
+ }
235
+ const lower = target.toLowerCase();
236
+ if (lower.endsWith(".zip")) {
237
+ return { bytes: fs.readFileSync(target), fileKind: "zip", contentType: "application/zip", sourceFilename: base };
238
+ }
239
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) {
240
+ return { bytes: fs.readFileSync(target), fileKind: "html", contentType: "text/html", sourceFilename: base };
241
+ }
242
+ die("Unsupported file. Give an .html file, a .zip, or a folder.");
243
+ }
244
+
245
+ async function deploy(args) {
246
+ const target = args._[1];
247
+ if (!target) die("Give a path: miinideck deploy <path>");
248
+
249
+ const token = resolveToken(args.flags);
250
+ if (!token) die("Not signed in. Run `miinideck login` first (or pass --token).");
251
+
252
+ const api = apiBase(args.flags);
253
+ const viewer = viewerBaseFrom(api);
254
+ const targetKey = path.resolve(target);
255
+
256
+ const cfg = readConfig();
257
+ cfg.deploys = cfg.deploys || {};
258
+ const knownDocId = !args.flags.new ? cfg.deploys[targetKey] || null : null;
259
+
260
+ const { bytes, fileKind, contentType, sourceFilename } = await readTarget(target);
261
+ const size = bytes.length;
262
+ process.stderr.write(
263
+ `→ ${knownDocId ? "Updating" : "Uploading"} ${sourceFilename} (${(size / 1048576).toFixed(2)} MB)…\n`,
264
+ );
265
+
266
+ // 1. signed URL — try as an update first if we've deployed this path before.
267
+ let step1 = await postJson(
268
+ `${api}/api/v1/upload-url`,
269
+ knownDocId ? { size, fileKind, replace_doc_id: knownDocId } : { size, fileKind },
270
+ token,
271
+ );
272
+ // The remembered doc may have been deleted on the server — fall back to new.
273
+ if (!step1.ok && knownDocId) {
274
+ step1 = await postJson(`${api}/api/v1/upload-url`, { size, fileKind }, token);
275
+ }
276
+ if (!step1.ok) die((step1.json && step1.json.error) || `Could not start upload (HTTP ${step1.status}).`);
277
+ const { signedUrl, path: storagePath, slug } = step1.json || {};
278
+ if (!signedUrl || !storagePath || !slug) die("Malformed server response (upload-url).");
279
+
280
+ // 2. PUT raw bytes straight to storage.
281
+ let put;
282
+ try {
283
+ put = await fetch(signedUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: bytes });
284
+ } catch (e) {
285
+ die(`Storage upload failed — ${e && e.message ? e.message : e}`);
286
+ }
287
+ if (!put.ok) die(`Storage upload failed (HTTP ${put.status}).`);
288
+
289
+ // 3. finalize.
290
+ const finalizeBody = { path: storagePath, slug, fileKind, source_filename: sourceFilename };
291
+ if (knownDocId && step1.json.slug === slug) finalizeBody.replace_doc_id = knownDocId;
292
+ if (args.flags.name) finalizeBody.title = String(args.flags.name);
293
+ if (args.flags.password) finalizeBody.password = String(args.flags.password);
294
+ if (args.flags.keep) finalizeBody.expiry_preset = "never";
295
+
296
+ const step3 = await postJson(`${api}/api/v1/finalize`, finalizeBody, token);
297
+ if (!step3.ok) die((step3.json && step3.json.error) || `Could not finalize (HTTP ${step3.status}).`);
298
+
299
+ // Remember this path → doc for next time.
300
+ if (step3.json.id) {
301
+ cfg.deploys[targetKey] = step3.json.id;
302
+ writeConfig(cfg);
303
+ }
304
+
305
+ const link = `${viewer}/${step3.json.slug}`;
306
+ if (step3.json.replaced) {
307
+ process.stderr.write("✓ Updated — same link:\n");
308
+ } else {
309
+ if (args.flags.keep && step3.json.always_on === false) {
310
+ process.stderr.write(" note: always-on slot full — self-destructs in 7 days.\n");
311
+ } else if (!args.flags.keep) {
312
+ process.stderr.write(" (self-destructs in 7 days — pass --keep to make it permanent)\n");
313
+ }
314
+ process.stderr.write("✓ Private link ready:\n");
315
+ }
316
+ process.stdout.write(`${link}\n`);
317
+ }
318
+
319
+ async function main() {
320
+ const args = parseArgs(process.argv.slice(2));
321
+ if (args.flags.version) {
322
+ process.stdout.write(`${VERSION}\n`);
323
+ return;
324
+ }
325
+ if (args.flags.help || args._.length === 0) {
326
+ process.stdout.write(HELP);
327
+ return;
328
+ }
329
+ const cmd = args._[0];
330
+ if (cmd === "login") return login(args);
331
+ if (cmd === "logout") return logout();
332
+ if (cmd === "list" || cmd === "ls") return list(args);
333
+ if (cmd === "deploy") return deploy(args);
334
+ die(`Unknown command: ${cmd}\nTry: miinideck --help`);
335
+ }
336
+
337
+ main().catch((e) => die(e && e.message ? e.message : String(e)));
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "miinideck",
3
+ "version": "0.1.0",
4
+ "description": "Deploy an HTML file, folder, or ZIP to a private, unguessable miinideck link — straight from your terminal or AI coding tool.",
5
+ "keywords": [
6
+ "miinideck",
7
+ "deploy",
8
+ "html",
9
+ "static",
10
+ "private",
11
+ "share",
12
+ "cli"
13
+ ],
14
+ "homepage": "https://www.miinideck.com",
15
+ "license": "MIT",
16
+ "type": "commonjs",
17
+ "bin": {
18
+ "miinideck": "index.js"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "README.md"
26
+ ],
27
+ "dependencies": {
28
+ "jszip": "^3.10.1"
29
+ }
30
+ }