draftlink 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 +674 -0
- package/README.md +92 -0
- package/bin/draftlink +393 -0
- package/migrations/0000_baseline.sql +51 -0
- package/package.json +45 -0
- package/skills/draftlink/SKILL.md +54 -0
- package/src/assets.ts +4 -0
- package/src/drafts.ts +209 -0
- package/src/index.ts +572 -0
- package/src/ui.ts +696 -0
- package/src/util.ts +119 -0
- package/tsconfig.json +15 -0
- package/wrangler.jsonc +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# draftlink
|
|
2
|
+
|
|
3
|
+
<p align="center"><img src="https://raw.githubusercontent.com/lm-sousa/draftlink/master/assets/banner.png" alt="DraftLink — from idea to impact" width="720"></p>
|
|
4
|
+
|
|
5
|
+
Publish HTML drafts from agents; searchable, shareable, status-tracked.
|
|
6
|
+
|
|
7
|
+
Draftlink is a personal service for hosting single HTML pages — plans, proposals,
|
|
8
|
+
briefs, architecture notes. Agents publish through the `draftlink` CLI; humans
|
|
9
|
+
browse and share through a web dashboard. Built for [Cloudflare Workers](https://workers.cloudflare.com) + D1.
|
|
10
|
+
|
|
11
|
+
## How it fits together
|
|
12
|
+
|
|
13
|
+
- **Worker** (`src/`) — serves drafts at `/d/<id>`, the dashboard, a Bearer-token
|
|
14
|
+
API at `/api/drafts`, and GitHub OAuth sign-in.
|
|
15
|
+
- **CLI** (`bin/draftlink`) — dependency-free Node script. Agents call the CLI;
|
|
16
|
+
the human logs in once and tokens never touch agent context.
|
|
17
|
+
- **Agent skill** (`skills/draftlink/`) — instructions that teach coding agents
|
|
18
|
+
how to publish and read drafts.
|
|
19
|
+
- **Setup wizard** (`scripts/setup-draftlink.sh`) — walks you through deps, D1,
|
|
20
|
+
a GitHub OAuth app, secrets, and the first deploy.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
**Use an existing deployment** (e.g. your own already-configured worker):
|
|
25
|
+
create an API key at `<your-worker>/keys`, then:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install -g draftlink
|
|
29
|
+
draftlink auth login # paste the key once
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Self-host** (deploy your own worker): the setup wizard lives in the repo
|
|
33
|
+
and expects to run from a checkout — clone first:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
git clone https://github.com/lm-sousa/draftlink.git && cd draftlink
|
|
37
|
+
npm install
|
|
38
|
+
./scripts/setup-draftlink.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The wizard walks you through deps, a D1 database, a GitHub OAuth app,
|
|
42
|
+
secrets, and the first deploy. It is safe to re-run; it remembers what it
|
|
43
|
+
already configured.
|
|
44
|
+
|
|
45
|
+
## CLI
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
draftlink auth login # paste an API key once (create it at <your-worker>/keys)
|
|
49
|
+
draftlink upload plan.html --title "My plan" --project myrepo
|
|
50
|
+
draftlink upload - < plan.html # stdin
|
|
51
|
+
draftlink update <id> --file v2.html # same public URL, no version history
|
|
52
|
+
draftlink update <id> --status done
|
|
53
|
+
draftlink read <id|url> # print draft HTML to stdout
|
|
54
|
+
draftlink list [query] # search title/project/content
|
|
55
|
+
draftlink delete <id>
|
|
56
|
+
draftlink auth status | auth logout
|
|
57
|
+
draftlink upgrade # update the CLI via npm
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Env overrides for CI: `DRAFTLINK_TOKEN`, `DRAFTLINK_URL`. Requires Node 22+.
|
|
61
|
+
|
|
62
|
+
## Security model
|
|
63
|
+
|
|
64
|
+
- Drafts are **private by default** — only the owner and people the owner
|
|
65
|
+
granted by GitHub handle (read/write) can open them.
|
|
66
|
+
- An owner can flip a draft **public**, turning its link into a read-only page
|
|
67
|
+
for anyone.
|
|
68
|
+
- API keys are shown once and stored hashed; revoke at any time from the dashboard.
|
|
69
|
+
- Draft pages render the draft inside a **sandboxed iframe** (opaque origin —
|
|
70
|
+
no cookies, no same-origin fetch, no top-level navigation), so even hostile
|
|
71
|
+
agent HTML stays isolated; public drafts are additionally sandboxed by CSP.
|
|
72
|
+
- Draft bodies are **versioned** (last 25 per draft). Open the history dropdown
|
|
73
|
+
on a draft page to view or restore old versions.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npm run dev # wrangler dev (uses .dev.vars)
|
|
79
|
+
npm test # vitest-pool-workers suite against the real handler + local D1
|
|
80
|
+
npm run typecheck
|
|
81
|
+
npm run deploy
|
|
82
|
+
npm run db:local # apply pending D1 migrations locally
|
|
83
|
+
npm run db:remote # apply pending D1 migrations to production (CI runs this on every merge)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Merges to master auto-deploy via CI (requires a `CLOUDFLARE_API_TOKEN` repo
|
|
87
|
+
secret with Workers Scripts:Edit + D1:Edit scopes). Schema changes are
|
|
88
|
+
numbered files in `migrations/`, applied by `wrangler d1 migrations apply`.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
[GPL-3.0-only](LICENSE)
|
package/bin/draftlink
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
// curl honored HTTP(S)_PROXY implicitly; global fetch needs Node's opt-in flag,
|
|
8
|
+
// which is read at startup — re-exec with it, but only when a proxy is actually
|
|
9
|
+
// configured and this Node supports the flag.
|
|
10
|
+
if (
|
|
11
|
+
!process.env.DRAFTLINK_NO_REEXEC &&
|
|
12
|
+
["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"].some((k) => process.env[k])
|
|
13
|
+
) {
|
|
14
|
+
const probe = spawnSync(process.execPath, ["--use-env-proxy", "-e", ""]);
|
|
15
|
+
if (probe.status === 0) {
|
|
16
|
+
const r = spawnSync(
|
|
17
|
+
process.execPath,
|
|
18
|
+
["--use-env-proxy", process.argv[1], ...process.argv.slice(2)],
|
|
19
|
+
{ stdio: "inherit", env: { ...process.env, DRAFTLINK_NO_REEXEC: "1" } }
|
|
20
|
+
);
|
|
21
|
+
process.exit(r.status ?? 1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "draftlink");
|
|
26
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "credentials");
|
|
27
|
+
|
|
28
|
+
const PKG_VERSION = (() => {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || "";
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
function newerThan(a, b) {
|
|
37
|
+
const pa = a.split(".").map(Number);
|
|
38
|
+
const pb = b.split(".").map(Number);
|
|
39
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Best-effort: once a day, only on a real terminal (never for CI/agent calls),
|
|
44
|
+
// and a failure here must never fail the command.
|
|
45
|
+
async function checkUpdate() {
|
|
46
|
+
if (!process.stdout.isTTY || !PKG_VERSION) return;
|
|
47
|
+
try {
|
|
48
|
+
const stamp = path.join(CONFIG_DIR, "update-check");
|
|
49
|
+
if (Date.now() - (fs.statSync(stamp).mtimeMs ?? 0) < 86_400_000) return;
|
|
50
|
+
const res = await fetch("https://registry.npmjs.org/draftlink/latest", { signal: AbortSignal.timeout(2000) });
|
|
51
|
+
const latest = (await res.json()).version;
|
|
52
|
+
if (newerThan(latest, PKG_VERSION)) {
|
|
53
|
+
process.stderr.write(`note: draftlink ${latest} available — run 'draftlink upgrade'\n`);
|
|
54
|
+
}
|
|
55
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
56
|
+
fs.closeSync(fs.openSync(stamp, "w"));
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const die = (e) => {
|
|
61
|
+
process.stderr.write(`error: ${e?.cause?.message ?? e?.message ?? e}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
};
|
|
64
|
+
const clean = (s) => String(s).replaceAll(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
65
|
+
process.on("uncaughtException", die);
|
|
66
|
+
process.on("unhandledRejection", die);
|
|
67
|
+
|
|
68
|
+
function usage() {
|
|
69
|
+
process.stdout.write(`draftlink CLI
|
|
70
|
+
|
|
71
|
+
draftlink auth login [--url URL] store credentials once (human does this)
|
|
72
|
+
draftlink auth status show saved URL and key prefix
|
|
73
|
+
draftlink auth logout forget stored credentials
|
|
74
|
+
draftlink upgrade update the CLI (installs via npm)
|
|
75
|
+
draftlink upload <file.html> [--title T] [--project P] [--status S]
|
|
76
|
+
draftlink upload - < plan.html read from stdin
|
|
77
|
+
draftlink update <id> [--file F|-] [--title T] [--project P] [--status S] [--public|--private]
|
|
78
|
+
draftlink read <id|url> print draft HTML to stdout (uses your login; public drafts need none)
|
|
79
|
+
draftlink list [query] search your drafts
|
|
80
|
+
draftlink delete <id> delete a draft you own
|
|
81
|
+
|
|
82
|
+
Credentials live in ${CONFIG_FILE} (mode 600).
|
|
83
|
+
Agents and scripts only ever call draftlink; they never handle tokens.
|
|
84
|
+
Env overrides for CI: DRAFTLINK_TOKEN, DRAFTLINK_URL.
|
|
85
|
+
`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadConfig() {
|
|
90
|
+
const cfg = {};
|
|
91
|
+
try {
|
|
92
|
+
for (const line of fs.readFileSync(CONFIG_FILE, "utf8").split("\n")) {
|
|
93
|
+
const i = line.indexOf("=");
|
|
94
|
+
if (i > 0) cfg[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
return {
|
|
98
|
+
url: process.env.DRAFTLINK_URL || cfg.url || "",
|
|
99
|
+
token: process.env.DRAFTLINK_TOKEN || cfg.token || "",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requireAuth() {
|
|
104
|
+
const cfg = loadConfig();
|
|
105
|
+
if (!cfg.token) {
|
|
106
|
+
process.stderr.write("error: not logged in — run 'draftlink auth login' first\n");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
return cfg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function saveConfig(url, token) {
|
|
113
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
114
|
+
fs.writeFileSync(CONFIG_FILE, `url=${url}\ntoken=${token}\n`, { mode: 0o600 });
|
|
115
|
+
fs.chmodSync(CONFIG_DIR, 0o700);
|
|
116
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ttyInput() {
|
|
120
|
+
const readLine = (prompt, hidden) =>
|
|
121
|
+
new Promise((resolve) => {
|
|
122
|
+
process.stdout.write(prompt);
|
|
123
|
+
process.stdin.resume();
|
|
124
|
+
if (hidden) process.stdin.setRawMode(true);
|
|
125
|
+
process.stdin.setEncoding("utf8");
|
|
126
|
+
const chars = [];
|
|
127
|
+
const onKey = (chunk) => {
|
|
128
|
+
for (const c of chunk) {
|
|
129
|
+
if (c === "\r" || c === "\n") {
|
|
130
|
+
done();
|
|
131
|
+
if (!hidden) process.stdout.write("\n");
|
|
132
|
+
resolve(chars.join(""));
|
|
133
|
+
return;
|
|
134
|
+
} else if (c === "\x03") {
|
|
135
|
+
done();
|
|
136
|
+
process.exit(130);
|
|
137
|
+
} else if (c === "\x7f" || c === "\x08") {
|
|
138
|
+
chars.pop();
|
|
139
|
+
} else {
|
|
140
|
+
chars.push(c);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const done = () => {
|
|
145
|
+
if (hidden) process.stdin.setRawMode(false);
|
|
146
|
+
process.stdin.pause();
|
|
147
|
+
process.stdin.removeListener("data", onKey);
|
|
148
|
+
};
|
|
149
|
+
process.stdin.on("data", onKey);
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
line: (prompt) => readLine(prompt, false),
|
|
153
|
+
secret: (prompt) => readLine(prompt, true),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function pipeInput() {
|
|
158
|
+
const lines = fs.readFileSync(0, "utf8").split("\n").map((s) => s.trim());
|
|
159
|
+
let i = 0;
|
|
160
|
+
const next = async (prompt) => {
|
|
161
|
+
process.stdout.write(prompt);
|
|
162
|
+
return lines[i++] ?? "";
|
|
163
|
+
};
|
|
164
|
+
return { line: next, secret: next };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function qsOf(opts) {
|
|
168
|
+
const qs = new URLSearchParams();
|
|
169
|
+
for (const [k, v] of Object.entries(opts)) if (v) qs.set(k, v);
|
|
170
|
+
const s = qs.toString();
|
|
171
|
+
return s ? `?${s}` : "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function api(cfg, method, pathname, { body } = {}) {
|
|
175
|
+
if (!cfg.url) {
|
|
176
|
+
process.stderr.write("error: no draftlink URL configured — run 'draftlink auth login' first\n");
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const headers = {};
|
|
180
|
+
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
|
|
181
|
+
if (body !== undefined) headers["Content-Type"] = "text/html; charset=utf-8";
|
|
182
|
+
const res = await fetch(cfg.url + pathname, { method, headers, body });
|
|
183
|
+
if (!res.ok && method === "DELETE") {
|
|
184
|
+
// server hides existence from non-owners; surface it plainly
|
|
185
|
+
process.stderr.write(`error: HTTP ${res.status}\n`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
return res;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseOpts(args) {
|
|
192
|
+
const o = { file: "", title: "", project: "", status: "", visibility: "" };
|
|
193
|
+
const take = (flag) => {
|
|
194
|
+
const v = args.shift();
|
|
195
|
+
if (v === undefined) {
|
|
196
|
+
process.stderr.write(`error: ${flag} requires a value\n`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
return v;
|
|
200
|
+
};
|
|
201
|
+
while (args.length) {
|
|
202
|
+
const a = args.shift();
|
|
203
|
+
switch (a) {
|
|
204
|
+
case "--title": o.title = take(a); break;
|
|
205
|
+
case "--project": case "-p": o.project = take(a); break;
|
|
206
|
+
case "--status": o.status = take(a); break;
|
|
207
|
+
case "--public": o.visibility = "true"; break;
|
|
208
|
+
case "--private": o.visibility = "false"; break;
|
|
209
|
+
case "--file": o.file = take(a); break;
|
|
210
|
+
case "-": if (o.file) usage(); o.file = "-"; break;
|
|
211
|
+
default:
|
|
212
|
+
if (a.startsWith("-")) usage();
|
|
213
|
+
else if (o.file) usage();
|
|
214
|
+
else o.file = a;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return o;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readBody(o) {
|
|
221
|
+
if (o.file === "-") {
|
|
222
|
+
// Windows can't sync-read a console fd; interactive stdin still works on POSIX.
|
|
223
|
+
if (process.platform === "win32" && process.stdin.isTTY) {
|
|
224
|
+
process.stderr.write(`error: "-" reads from stdin — pipe it (Get-Content plan.html | draftlink upload -) or pass the file directly\n`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
return fs.readFileSync(0, "utf8");
|
|
228
|
+
}
|
|
229
|
+
if (o.file) {
|
|
230
|
+
if (!fs.existsSync(o.file)) {
|
|
231
|
+
process.stderr.write(`error: no such file ${o.file}\n`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
return fs.readFileSync(o.file, "utf8");
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
240
|
+
|
|
241
|
+
if (["upload", "update", "list", "delete", "read"].includes(cmd)) await checkUpdate();
|
|
242
|
+
|
|
243
|
+
switch (cmd) {
|
|
244
|
+
case "auth": {
|
|
245
|
+
const sub = rest[0];
|
|
246
|
+
if (sub === "login") {
|
|
247
|
+
let urlFlag = "";
|
|
248
|
+
const args = rest.slice(1);
|
|
249
|
+
while (args.length) {
|
|
250
|
+
const a = args.shift();
|
|
251
|
+
if (a === "--url" || a === "-u") {
|
|
252
|
+
urlFlag = args.shift();
|
|
253
|
+
if (urlFlag === undefined) {
|
|
254
|
+
process.stderr.write("error: --url requires a value\n");
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
usage();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const current = loadConfig();
|
|
262
|
+
const input = process.stdin.isTTY ? ttyInput() : pipeInput();
|
|
263
|
+
const url = urlFlag || (await input.line(`draftlink URL (your draftlink instance${current.url ? `, enter for ${current.url}` : ""}): `)) || current.url;
|
|
264
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
265
|
+
process.stderr.write("error: draftlink URL required (e.g. https://draftlink.example.com)\n");
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
const token = await input.secret(`API token (create at ${url}/keys): `);
|
|
269
|
+
const probe = await fetch(`${url}/api/drafts`, { headers: { Authorization: `Bearer ${token}` } });
|
|
270
|
+
if (probe.status !== 200) {
|
|
271
|
+
process.stderr.write(`error: token rejected (HTTP ${probe.status})\n`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
saveConfig(url, token);
|
|
275
|
+
process.stdout.write(`logged in to ${url} (credentials: ${CONFIG_FILE})\n`);
|
|
276
|
+
} else if (sub === "status") {
|
|
277
|
+
const { url, token } = loadConfig();
|
|
278
|
+
if (!token) {
|
|
279
|
+
process.stdout.write("not logged in\n");
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
process.stdout.write(`url: ${url}\ntoken: ${token.slice(0, 9)}…\n`);
|
|
283
|
+
} else if (sub === "logout") {
|
|
284
|
+
fs.rmSync(CONFIG_FILE, { force: true });
|
|
285
|
+
process.stdout.write("credentials removed\n");
|
|
286
|
+
} else {
|
|
287
|
+
usage();
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case "upgrade": {
|
|
293
|
+
const r = spawnSync("npm", ["install", "-g", "draftlink@latest"], { stdio: "inherit", shell: true });
|
|
294
|
+
if (r.status !== 0) {
|
|
295
|
+
process.stderr.write("error: npm install failed — run it manually: npm install -g draftlink@latest\n");
|
|
296
|
+
process.exit(r.status ?? 1);
|
|
297
|
+
}
|
|
298
|
+
process.stdout.write("refresh the agent skill too: npx skills update draftlink -g\n");
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case "upload": {
|
|
303
|
+
const cfg = requireAuth();
|
|
304
|
+
const o = parseOpts(rest);
|
|
305
|
+
if (!o.file) usage();
|
|
306
|
+
const body = readBody(o);
|
|
307
|
+
const res = await api(cfg, "POST", `/api/drafts${qsOf({ title: o.title, project: o.project, status: o.status })}`, { body });
|
|
308
|
+
if (!res.ok) {
|
|
309
|
+
process.stderr.write(clean(await res.text()));
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
const { id, url } = await res.json();
|
|
313
|
+
process.stdout.write(`id: ${clean(id)}\nurl: ${clean(url)}\n`);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case "update": {
|
|
318
|
+
const cfg = requireAuth();
|
|
319
|
+
const id = rest.shift();
|
|
320
|
+
if (!id) usage();
|
|
321
|
+
const o = parseOpts(rest);
|
|
322
|
+
const res = await api(cfg, "PUT", `/api/drafts/${id}${qsOf({ title: o.title, project: o.project, status: o.status, public: o.visibility })}`, { body: readBody(o) });
|
|
323
|
+
process.stdout.write(`${clean(await res.text())}\n`);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case "read": {
|
|
328
|
+
const target = rest[0];
|
|
329
|
+
if (!target) usage();
|
|
330
|
+
const cfg = loadConfig();
|
|
331
|
+
if (cfg.token) {
|
|
332
|
+
const id = target.startsWith("http") ? new URL(target).pathname.split("/").pop() : target;
|
|
333
|
+
const res = await api(cfg, "GET", `/api/drafts/${id}/raw`);
|
|
334
|
+
if (!res.ok) {
|
|
335
|
+
process.stderr.write(`error: HTTP ${res.status} — if this is 404, the draft is private and not shared with you\n`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
// Draft bodies are HTML: keep whitespace intact, strip only terminal escape sequences.
|
|
339
|
+
process.stdout.write((await res.text()).replaceAll("\x1b", ""));
|
|
340
|
+
} else if (target.startsWith("http")) {
|
|
341
|
+
let u;
|
|
342
|
+
try {
|
|
343
|
+
u = new URL(target);
|
|
344
|
+
} catch {
|
|
345
|
+
process.stderr.write(`error: not a valid URL: ${clean(target)}\n`);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
// Without a token this branch has no auth to leak — but it must not become
|
|
349
|
+
// a generic fetch tool for agents (SSRF/internal endpoints via prompt injection).
|
|
350
|
+
if (u.protocol !== "https:" || !u.pathname.startsWith("/d/")) {
|
|
351
|
+
process.stderr.write("error: unauthenticated reads are limited to public draft links (https://…/d/<id>)\n");
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
const res = await fetch(u, { signal: AbortSignal.timeout(15_000), redirect: "error" });
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
process.stderr.write(`error: HTTP ${res.status}\n`);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
process.stdout.write((await res.text()).replaceAll("\x1b", ""));
|
|
360
|
+
} else {
|
|
361
|
+
process.stderr.write("error: not logged in — 'draftlink read <id>' needs a login unless you pass a public link\n");
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case "list": {
|
|
368
|
+
const cfg = requireAuth();
|
|
369
|
+
const query = rest[0] ?? "";
|
|
370
|
+
const res = await api(cfg, "GET", `/api/drafts${qsOf({ q: query })}`);
|
|
371
|
+
const d = await res.json();
|
|
372
|
+
const marks = { active: "[ ]", done: "[ ok ]", archived: "[archived]" };
|
|
373
|
+
for (const section of ["mine", "shared"]) {
|
|
374
|
+
for (const r of d[section] ?? []) {
|
|
375
|
+
const owner = section === "shared" && r.owner_login ? ` by @${clean(r.owner_login)}` : "";
|
|
376
|
+
process.stdout.write(`${marks[r.status] ?? "[?????]"} ${r.id} ${clean(r.project).padEnd(12)} ${clean(r.title)}${owner}\n`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
case "delete": {
|
|
383
|
+
const cfg = requireAuth();
|
|
384
|
+
const id = rest[0];
|
|
385
|
+
if (!id) usage();
|
|
386
|
+
await api(cfg, "DELETE", `/api/drafts/${id}`);
|
|
387
|
+
process.stdout.write(`deleted ${id}\n`);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
default:
|
|
392
|
+
usage();
|
|
393
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
github_id INTEGER NOT NULL UNIQUE,
|
|
4
|
+
login TEXT NOT NULL,
|
|
5
|
+
created_at INTEGER NOT NULL,
|
|
6
|
+
status TEXT NOT NULL DEFAULT 'approved' CHECK (status IN ('approved', 'pending', 'banned')),
|
|
7
|
+
is_admin INTEGER NOT NULL DEFAULT 0
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
13
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
14
|
+
prefix TEXT NOT NULL,
|
|
15
|
+
label TEXT NOT NULL DEFAULT 'cli',
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
last_used_at INTEGER
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS drafts (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
owner_user_id INTEGER NOT NULL REFERENCES users(id),
|
|
23
|
+
title TEXT NOT NULL,
|
|
24
|
+
project TEXT NOT NULL DEFAULT 'misc',
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'done', 'archived')),
|
|
26
|
+
is_public INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
body TEXT NOT NULL,
|
|
28
|
+
created_at INTEGER NOT NULL,
|
|
29
|
+
updated_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_drafts_owner ON drafts(owner_user_id, created_at DESC);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS draft_versions (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
draft_id TEXT NOT NULL REFERENCES drafts(id) ON DELETE CASCADE,
|
|
37
|
+
body TEXT NOT NULL,
|
|
38
|
+
created_at INTEGER NOT NULL
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_versions_draft ON draft_versions(draft_id, created_at DESC);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS draft_access (
|
|
44
|
+
draft_id TEXT NOT NULL REFERENCES drafts(id) ON DELETE CASCADE,
|
|
45
|
+
github_id INTEGER NOT NULL,
|
|
46
|
+
login TEXT NOT NULL,
|
|
47
|
+
created_at INTEGER NOT NULL,
|
|
48
|
+
PRIMARY KEY (draft_id, github_id)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_access_github ON draft_access(github_id);
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "draftlink",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Publish HTML drafts from agents; searchable, shareable, status-tracked. Cloudflare Worker + CLI.",
|
|
5
|
+
"license": "GPL-3.0-only",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"draftlink": "bin/draftlink"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/draftlink",
|
|
15
|
+
"src/",
|
|
16
|
+
"migrations/",
|
|
17
|
+
"wrangler.jsonc",
|
|
18
|
+
"tsconfig.json",
|
|
19
|
+
"skills/"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "wrangler dev",
|
|
23
|
+
"deploy": "wrangler deploy",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"prepublishOnly": "node scripts/prepublish-check.js",
|
|
27
|
+
"db:local": "wrangler d1 migrations apply draftlink --local",
|
|
28
|
+
"db:remote": "wrangler d1 migrations apply draftlink --remote"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
32
|
+
"@cloudflare/workers-types": "^5.20260825.1",
|
|
33
|
+
"typescript": "^7.0.0",
|
|
34
|
+
"vitest": "^4.1.11",
|
|
35
|
+
"wrangler": "^4.125.0"
|
|
36
|
+
},
|
|
37
|
+
"allowScripts": {
|
|
38
|
+
"workerd@1.20260815.1": true,
|
|
39
|
+
"workerd@1.20260820.1": true
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/lm-sousa/draftlink.git"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: draftlink
|
|
3
|
+
description: When the user asks for a draftlink or an HTML writeup of work (NOT as part of the codebase). Also use to read or update drafts when given a draftlink link.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Draftlink
|
|
7
|
+
|
|
8
|
+
Draftlink is a personal service for hosting single HTML pages.
|
|
9
|
+
|
|
10
|
+
Create and publish readable HTML plans, proposals, briefs, reports, or architecture notes as searchable, status-tracked drafts.
|
|
11
|
+
|
|
12
|
+
All requests go through the `draftlink` CLI (installed globally). The user is responsible for logging-in. If a command fails with "not logged in", tell the human to run `draftlink auth login` and stop.
|
|
13
|
+
|
|
14
|
+
The service adds a header to every draft (a "draftlink" link back to the dashboard and the dark-mode toggle) and loads Tailwind with `darkMode: 'class'`. Do not add any page chrome — no nav, back link, or theme button.
|
|
15
|
+
|
|
16
|
+
Style theme-dependent content with Tailwind `dark:` variants; never use `@media (prefers-color-scheme: ...)` — the toggle flips the `dark` class on `<html>`, which media queries ignore. Give text and controls deliberate colors in both modes.
|
|
17
|
+
|
|
18
|
+
Do not write your own theme toggle, storage key, or logic. Ensure text and controls have deliberate colors in both modes. Keep the writeup concise and readable. Do not publish secrets, local paths, or private URLs.
|
|
19
|
+
|
|
20
|
+
Publish (prints `id` and `url`); title is a short human summary, project is the working repo's basename:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
draftlink upload file.html --title "Short title" --project myrepo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Return the resulting `url`.
|
|
27
|
+
|
|
28
|
+
Drafts are private by default: only the owner and people the owner granted by GitHub handle (read/write) can open them. An owner may make a draft public, which turns its link into a read-only page for anyone.
|
|
29
|
+
|
|
30
|
+
Read a draftlink link with your stored login:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
draftlink read <url-or-id> # prints the uploaded HTML to stdout
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Reading requires access — if it fails with 404, the draft is private and not shared with you; don't retry unauthenticated. Public drafts are also readable by plain `curl <url>`.
|
|
37
|
+
|
|
38
|
+
Update an existing draft (same public URL, no version history):
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
draftlink update <id> --file v2.html [--title T] [--project P] [--status S]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Omitting `--file` keeps the current HTML and only changes the metadata.
|
|
45
|
+
|
|
46
|
+
`--public` and `--private` change who can read the draft. NEVER pass `--public` unless the user explicitly asked to publish that draft — it exposes the content to the entire internet.
|
|
47
|
+
|
|
48
|
+
Statuses are `active`, `done`, `archived`. Set `done` when the user says the work shipped; `archived` when they say it no longer needs attention. Never delete unless asked explicitly (owner-only anyway).
|
|
49
|
+
|
|
50
|
+
Search the user's drafts when they ask where something went:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
draftlink list [query]
|
|
54
|
+
```
|