drawer-so 0.9.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 Joshua Munsch
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 OF OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # drawer-so
2
+
3
+ Publish files and directories to [Drawer](https://drawer.so) from the terminal.
4
+
5
+ The CLI targets `https://drawer.so` by default; override with `DRAWER_BASE_URL`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g drawer-so
11
+ # verify
12
+ drawer --help
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ # one-time: paste your drawer API key (drw_…)
19
+ drawer auth login
20
+
21
+ # publish a single file
22
+ drawer publish ./report.html --title "Q3 report"
23
+
24
+ # publish a directory (zipped + uploaded as a multi-file artifact)
25
+ drawer publish ./site --title "demo site"
26
+
27
+ # update an existing artifact in place
28
+ drawer publish ./report.html --update art_abc1234567
29
+
30
+ # patch — surgical find/replace
31
+ drawer patch art_abc1234567 --find "Q3" --replace "Q4"
32
+
33
+ # list / open
34
+ drawer list
35
+ drawer open art_abc1234567
36
+ ```
37
+
38
+ ## Config
39
+
40
+ API key + base URL are stored in `~/.config/drawer/cli-config.json` (chmod 600).
41
+
42
+ Override per-invocation with env vars:
43
+
44
+ - `DRAWER_API_KEY` — override the saved key
45
+ - `DRAWER_BASE_URL` — override the base URL (default `https://drawer.so`)
46
+
47
+ ## Commands
48
+
49
+ ```
50
+ drawer auth login Paste an API key and save it
51
+ drawer auth logout Delete saved key
52
+ drawer auth whoami Show stored key prefix + base URL
53
+
54
+ drawer publish <file|dir> Publish a file or directory
55
+ --title <text> Title shown in the browser tab
56
+ --visibility <tier> public | unlisted (default) | password
57
+ --password <text> Required when --visibility=password
58
+ --update <artifact_id> Update an existing artifact instead
59
+
60
+ drawer list List your artifacts
61
+ drawer get <artifact_id> Print artifact metadata as JSON
62
+ drawer open <artifact_id> Open the artifact URL in your default browser
63
+ drawer delete <artifact_id> [--yes] Permanently delete (asks first)
64
+
65
+ drawer patch <artifact_id> Find/replace surgical edit
66
+ --file <path> Defaults to the artifact's entry file
67
+ --find <text> Required
68
+ --replace <text> Required
69
+ --replace-all Replace every occurrence
70
+
71
+ drawer revert <artifact_id> [--to <ver>] Promote a prior version back to current
72
+ drawer diff <artifact_id> <from> [to] Unified diff between versions
73
+ ```
74
+
75
+ ## Notes
76
+
77
+ - Uploads use the unified `/api/artifacts/upload` endpoint, which dispatches by `Content-Type` (single file vs. zip archive).
78
+ - Directories are bundled into a zip in-memory and uploaded; `node_modules` and `.git` are skipped by default.
79
+ - Uploads cap at 100 MB raw / 200 MB extracted.
80
+ - All commands honor per-API-key rate limits (60 mutating requests/min, 1000/hour).
81
+
82
+ ## For maintainers — publishing
83
+
84
+ One-liner to publish a release from a clean checkout:
85
+
86
+ ```bash
87
+ git pull && cd cli && npm install && npm run build && npm publish --access public
88
+ ```
89
+
90
+ - `npm login` is a one-time setup on the publishing machine.
91
+ - Bumping versions: `npm version patch && git push --follow-tags && npm publish --access public` (use `minor` or `major` as appropriate). The `prepublishOnly` script re-runs the build.
92
+ - See [PUBLISHING.md](./PUBLISHING.md) for the full pre-flight checklist.
package/dist/drawer.js ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/drawer.ts
4
+ import { cac } from "cac";
5
+ import { promises as fs } from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+ import readline from "readline/promises";
9
+ import { stdin, stdout } from "process";
10
+ import { zipSync } from "fflate";
11
+ import { exec } from "child_process";
12
+ var DEFAULT_BASE = "https://drawer.so";
13
+ var CONFIG_DIR = path.join(os.homedir(), ".config", "drawer");
14
+ var CONFIG_FILE = path.join(CONFIG_DIR, "cli-config.json");
15
+ async function loadConfig() {
16
+ try {
17
+ const raw = await fs.readFile(CONFIG_FILE, "utf-8");
18
+ return JSON.parse(raw);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+ async function saveConfig(cfg) {
24
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
25
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2));
26
+ await fs.chmod(CONFIG_FILE, 384);
27
+ }
28
+ function baseUrl(cfg) {
29
+ return process.env.DRAWER_BASE_URL || cfg.base_url || DEFAULT_BASE;
30
+ }
31
+ async function requireKey(cfg) {
32
+ const key = process.env.DRAWER_API_KEY || cfg.api_key;
33
+ if (!key) {
34
+ die(
35
+ "no API key found.\n Run `drawer auth login` to save one, or set DRAWER_API_KEY.\n Get a key by signing in at " + DEFAULT_BASE + " (one is minted on first GitHub sign-in)."
36
+ );
37
+ }
38
+ return key;
39
+ }
40
+ function die(msg, code = 1) {
41
+ console.error("drawer: " + msg);
42
+ process.exit(code);
43
+ }
44
+ async function apiRequest(cfg, pathname, init = {}) {
45
+ const key = await requireKey(cfg);
46
+ const url = baseUrl(cfg).replace(/\/+$/, "") + pathname;
47
+ const headers = new Headers(init.headers ?? {});
48
+ headers.set("Authorization", `Bearer ${key}`);
49
+ let body = init.body;
50
+ if (init.rawBody) {
51
+ body = init.rawBody;
52
+ } else if (init.jsonBody !== void 0) {
53
+ headers.set("Content-Type", "application/json");
54
+ body = JSON.stringify(init.jsonBody);
55
+ }
56
+ const res = await fetch(url, { ...init, body, headers });
57
+ return res;
58
+ }
59
+ async function readBodySafely(res) {
60
+ const ct = res.headers.get("content-type") ?? "";
61
+ if (ct.includes("application/json")) {
62
+ try {
63
+ return await res.json();
64
+ } catch {
65
+ return await res.text().catch(() => "");
66
+ }
67
+ }
68
+ return res.text().catch(() => "");
69
+ }
70
+ function failOnNon2xx(res, body, hint) {
71
+ if (res.ok) return;
72
+ let detail;
73
+ if (typeof body === "string") {
74
+ detail = body;
75
+ } else if (body && typeof body === "object") {
76
+ const rec = body;
77
+ if (typeof rec.message === "string") {
78
+ detail = rec.message;
79
+ } else if (typeof rec.error === "string") {
80
+ detail = rec.error;
81
+ } else if (rec.error && typeof rec.error === "object" && typeof rec.error.message === "string") {
82
+ detail = String(rec.error.message);
83
+ } else {
84
+ detail = JSON.stringify(body);
85
+ }
86
+ } else {
87
+ detail = String(body ?? "");
88
+ }
89
+ const statusLabel = res.statusText || statusName(res.status);
90
+ die(
91
+ `request failed (${res.status}${statusLabel ? ` ${statusLabel}` : ""})${hint ? ` \u2014 ${hint}` : ""}
92
+ ${detail || "(empty body)"}`,
93
+ 1
94
+ );
95
+ }
96
+ function statusName(status) {
97
+ switch (status) {
98
+ case 400:
99
+ return "Bad Request";
100
+ case 401:
101
+ return "Unauthorized";
102
+ case 402:
103
+ return "Payment Required";
104
+ case 403:
105
+ return "Forbidden";
106
+ case 404:
107
+ return "Not Found";
108
+ case 409:
109
+ return "Conflict";
110
+ case 413:
111
+ return "Payload Too Large";
112
+ case 429:
113
+ return "Too Many Requests";
114
+ case 500:
115
+ return "Internal Server Error";
116
+ case 502:
117
+ return "Bad Gateway";
118
+ case 503:
119
+ return "Service Unavailable";
120
+ default:
121
+ return "";
122
+ }
123
+ }
124
+ var cli = cac("drawer");
125
+ async function doAuthLogin() {
126
+ const rl = readline.createInterface({ input: stdin, output: stdout });
127
+ const key = (await rl.question("Paste your drawer API key (drw_...): ")).trim();
128
+ rl.close();
129
+ if (!key.startsWith("drw_")) {
130
+ die("expected key to start with 'drw_'");
131
+ }
132
+ const cfg = await loadConfig();
133
+ cfg.api_key = key;
134
+ if (!cfg.base_url) cfg.base_url = DEFAULT_BASE;
135
+ await saveConfig(cfg);
136
+ console.log(`saved to ${CONFIG_FILE} (key prefix: ${key.slice(0, 12)}\u2026, base: ${cfg.base_url})`);
137
+ }
138
+ async function doAuthLogout() {
139
+ try {
140
+ await fs.unlink(CONFIG_FILE);
141
+ console.log("removed", CONFIG_FILE);
142
+ } catch {
143
+ console.log("no config to remove");
144
+ }
145
+ }
146
+ async function doAuthWhoami() {
147
+ const cfg = await loadConfig();
148
+ const key = process.env.DRAWER_API_KEY || cfg.api_key;
149
+ console.log("base:", baseUrl(cfg));
150
+ console.log("key: ", key ? `${key.slice(0, 12)}\u2026 (source: ${process.env.DRAWER_API_KEY ? "env" : "config"})` : "(none)");
151
+ }
152
+ cli.command("auth <action>", "Manage credentials: login | logout | whoami").action(async (action) => {
153
+ switch (action) {
154
+ case "login":
155
+ return doAuthLogin();
156
+ case "logout":
157
+ return doAuthLogout();
158
+ case "whoami":
159
+ return doAuthWhoami();
160
+ default:
161
+ die(`unknown auth action '${action}' (expected login | logout | whoami)`);
162
+ }
163
+ });
164
+ cli.command("publish <path>", "Publish a file or directory and return the URL").option("--title <title>", "Title shown in the browser tab").option("--visibility <visibility>", "public | unlisted | password (default unlisted)").option("--password <password>", "Required if visibility=password").option("--update <artifact_id>", "Update an existing artifact instead of creating a new one").action(async (target, flags) => {
165
+ const cfg = await loadConfig();
166
+ const abs = path.resolve(target);
167
+ let stat;
168
+ try {
169
+ stat = await fs.stat(abs);
170
+ } catch (e) {
171
+ die(`cannot read '${target}': ${e.message}`);
172
+ }
173
+ const visibility = (flags.visibility ?? "unlisted").toLowerCase();
174
+ if (!["public", "unlisted", "password"].includes(visibility)) {
175
+ die(`invalid visibility '${visibility}' (expected public | unlisted | password)`);
176
+ }
177
+ if (visibility === "password" && !flags.password) {
178
+ die("--password is required when --visibility=password");
179
+ }
180
+ const headers = new Headers();
181
+ if (flags.title) headers.set("X-Drawer-Title", encodeURIComponent(flags.title));
182
+ headers.set("X-Drawer-Visibility", visibility);
183
+ if (flags.password) headers.set("X-Drawer-Password", flags.password);
184
+ let body;
185
+ let contentType;
186
+ if (stat.isDirectory()) {
187
+ const zipped = await zipDirectory(abs);
188
+ body = Buffer.from(zipped);
189
+ contentType = "application/zip";
190
+ } else {
191
+ body = await fs.readFile(abs);
192
+ contentType = mimeForExt(path.extname(abs).slice(1));
193
+ }
194
+ headers.set("Content-Type", contentType);
195
+ const endpoint = flags.update ? `/api/artifacts/${encodeURIComponent(flags.update)}/upload` : "/api/artifacts/upload";
196
+ const res = await apiRequest(cfg, endpoint, {
197
+ method: "POST",
198
+ rawBody: body,
199
+ headers
200
+ });
201
+ const responseBody = await readBodySafely(res);
202
+ failOnNon2xx(res, responseBody, "publish failed");
203
+ const r = responseBody;
204
+ const url = String(r.url ?? "");
205
+ const id = String(r.artifact_id ?? flags.update ?? "");
206
+ if (url) console.log(url);
207
+ if (id) console.log("artifact_id:", id);
208
+ if (r.version_id) console.log("version_id:", r.version_id);
209
+ });
210
+ async function zipDirectory(dir) {
211
+ const zippable = {};
212
+ const stack = [dir];
213
+ while (stack.length > 0) {
214
+ const cur = stack.pop();
215
+ const entries = await fs.readdir(cur, { withFileTypes: true });
216
+ for (const ent of entries) {
217
+ const full = path.join(cur, ent.name);
218
+ if (ent.isDirectory()) {
219
+ if (ent.name === "node_modules" || ent.name.startsWith(".git")) continue;
220
+ stack.push(full);
221
+ } else if (ent.isFile()) {
222
+ const rel = path.relative(dir, full).split(path.sep).join("/");
223
+ zippable[rel] = new Uint8Array(await fs.readFile(full));
224
+ }
225
+ }
226
+ }
227
+ if (Object.keys(zippable).length === 0) {
228
+ die(`directory '${dir}' is empty`);
229
+ }
230
+ return zipSync(zippable);
231
+ }
232
+ function mimeForExt(ext) {
233
+ const map = {
234
+ html: "text/html",
235
+ htm: "text/html",
236
+ md: "text/markdown",
237
+ markdown: "text/markdown",
238
+ txt: "text/plain",
239
+ json: "application/json",
240
+ css: "text/css",
241
+ js: "text/javascript",
242
+ svg: "image/svg+xml"
243
+ };
244
+ return map[ext.toLowerCase()] || "text/html";
245
+ }
246
+ cli.command("list", "List your artifacts").action(async () => {
247
+ const cfg = await loadConfig();
248
+ const res = await apiRequest(cfg, "/api/artifacts", { method: "GET" });
249
+ const body = await readBodySafely(res);
250
+ failOnNon2xx(res, body, "list failed");
251
+ const artifacts = body.artifacts ?? [];
252
+ if (artifacts.length === 0) {
253
+ console.log("(no artifacts yet \u2014 `drawer publish <file>` to create one)");
254
+ return;
255
+ }
256
+ for (const a of artifacts) {
257
+ console.log(`${a.artifact_id} ${a.visibility.padEnd(11)} ${a.title ?? "(untitled)"}`);
258
+ console.log(` ${a.url}`);
259
+ }
260
+ });
261
+ cli.command("get <artifact_id>", "Show artifact metadata").action(async (id) => {
262
+ const cfg = await loadConfig();
263
+ const res = await apiRequest(cfg, `/api/artifacts/${encodeURIComponent(id)}`, { method: "GET" });
264
+ const body = await readBodySafely(res);
265
+ failOnNon2xx(res, body, "get failed");
266
+ console.log(JSON.stringify(body, null, 2));
267
+ });
268
+ cli.command("open <artifact_id>", "Open the artifact URL in your default browser").action(async (id) => {
269
+ const cfg = await loadConfig();
270
+ const res = await apiRequest(cfg, `/api/artifacts/${encodeURIComponent(id)}`, { method: "GET" });
271
+ const body = await readBodySafely(res);
272
+ failOnNon2xx(res, body, "lookup failed");
273
+ if (!body.url) die("no url in response");
274
+ const url = body.url;
275
+ console.log(url);
276
+ const opener = process.platform === "darwin" ? `open ${shellQuote(url)}` : process.platform === "win32" ? `start "" ${shellQuote(url)}` : `xdg-open ${shellQuote(url)}`;
277
+ exec(opener, () => {
278
+ });
279
+ });
280
+ function shellQuote(s) {
281
+ return `'${s.replace(/'/g, "'\\''")}'`;
282
+ }
283
+ cli.command("delete <artifact_id>", "Permanently delete an artifact (asks first)").option("-y, --yes", "Skip the confirmation prompt").action(async (id, flags) => {
284
+ const cfg = await loadConfig();
285
+ if (!flags.yes) {
286
+ const rl = readline.createInterface({ input: stdin, output: stdout });
287
+ const ans = (await rl.question(`Delete artifact ${id}? This is permanent. [y/N] `)).trim().toLowerCase();
288
+ rl.close();
289
+ if (ans !== "y" && ans !== "yes") {
290
+ console.log("aborted");
291
+ return;
292
+ }
293
+ }
294
+ const res = await apiRequest(cfg, `/api/artifacts/${encodeURIComponent(id)}`, { method: "DELETE" });
295
+ const body = await readBodySafely(res);
296
+ failOnNon2xx(res, body, "delete failed");
297
+ console.log("deleted", id);
298
+ });
299
+ cli.command("patch <artifact_id>", "Surgical find/replace on an existing artifact").option("--file <file>", "File within the artifact (default: entry file, usually index.html)").option("--find <text>", "Text to find (must be unique unless --replace-all)").option("--replace <text>", "Replacement text").option("--replace-all", "Replace every occurrence of --find").action(async (id, flags) => {
300
+ if (flags.find === void 0 || flags.replace === void 0) {
301
+ die("--find and --replace are both required");
302
+ }
303
+ const cfg = await loadConfig();
304
+ const res = await apiRequest(cfg, `/api/artifacts/${encodeURIComponent(id)}/patch`, {
305
+ method: "POST",
306
+ jsonBody: {
307
+ edits: [
308
+ {
309
+ file: flags.file,
310
+ old: flags.find,
311
+ new: flags.replace,
312
+ replace_all: Boolean(flags.replaceAll)
313
+ }
314
+ ]
315
+ }
316
+ });
317
+ const body = await readBodySafely(res);
318
+ failOnNon2xx(res, body, "patch failed");
319
+ console.log(JSON.stringify(body, null, 2));
320
+ });
321
+ cli.command("revert <artifact_id>", "Promote a previous version back to current").option("--to <version_id>", "Specific version to revert to (default: previous)").action(async (id, flags) => {
322
+ const cfg = await loadConfig();
323
+ const res = await apiRequest(cfg, `/api/artifacts/${encodeURIComponent(id)}/revert`, {
324
+ method: "POST",
325
+ jsonBody: flags.to ? { to_version_id: flags.to } : {}
326
+ });
327
+ const body = await readBodySafely(res);
328
+ failOnNon2xx(res, body, "revert failed");
329
+ console.log(JSON.stringify(body, null, 2));
330
+ });
331
+ cli.command("diff <artifact_id> <from_version_id> [to_version_id]", "Unified diff between two versions").action(async (id, from, to) => {
332
+ const cfg = await loadConfig();
333
+ const qs = new URLSearchParams();
334
+ qs.set("from", from);
335
+ if (to) qs.set("to", to);
336
+ const res = await apiRequest(cfg, `/api/artifacts/${encodeURIComponent(id)}/diff?${qs}`, {
337
+ method: "GET"
338
+ });
339
+ const body = await readBodySafely(res);
340
+ failOnNon2xx(res, body, "diff failed");
341
+ const r = body;
342
+ if (!r.files) {
343
+ console.log(JSON.stringify(body, null, 2));
344
+ return;
345
+ }
346
+ console.log(`${r.files_changed ?? 0} files changed`);
347
+ for (const [p, info] of Object.entries(r.files)) {
348
+ console.log(`
349
+ --- ${p} (${info.status}) ---`);
350
+ if (info.diff) console.log(info.diff);
351
+ }
352
+ });
353
+ cli.help();
354
+ cli.version("0.9.0");
355
+ cli.parse();
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "drawer-so",
3
+ "version": "0.9.0",
4
+ "description": "Drawer CLI — publish files and directories to drawer.so from the terminal.",
5
+ "keywords": [
6
+ "drawer",
7
+ "cli",
8
+ "publish",
9
+ "artifacts",
10
+ "agent",
11
+ "mcp"
12
+ ],
13
+ "homepage": "https://drawer.so",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/junofaux/drawer.git",
17
+ "directory": "cli"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/junofaux/drawer/issues"
21
+ },
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "bin": {
25
+ "drawer": "./dist/drawer.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsup src/drawer.ts --format esm --target node18 --clean --shims",
33
+ "dev": "tsx src/drawer.ts",
34
+ "prepublishOnly": "npm run build",
35
+ "typecheck": "tsc --noEmit"
36
+ },
37
+ "dependencies": {
38
+ "cac": "^6.7.14",
39
+ "fflate": "^0.8.2"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.5",
43
+ "tsup": "^8.3.5",
44
+ "tsx": "^4.19.2",
45
+ "typescript": "^5.7.2"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ }
50
+ }