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 +21 -0
- package/README.md +92 -0
- package/dist/drawer.js +355 -0
- package/package.json +50 -0
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
|
+
}
|