hollware 0.0.2 → 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/README.md CHANGED
@@ -3,20 +3,59 @@
3
3
  The command line of [Hollware](https://github.com/hollware/hollware), an open-source headless
4
4
  CMS for the agent era, native to Cloudflare Workers.
5
5
 
6
- > **Status: pre-alpha, not usable yet.** The binary answers `--help` and `--version`; every
7
- > command prints "not implemented yet" and exits with code 1.
6
+ > **Status: pre-alpha.** `init`, `migrate`, `token` and `import` work, against a Hollware that
7
+ > is still being built. `export` arrives in v0.2. POSIX shells only: it has not been tried on
8
+ > Windows.
8
9
 
9
- ## What it will be
10
+ ## Commands
10
11
 
11
- The `hollware` command, with these planned commands:
12
+ | Command | Purpose |
13
+ | --------- | -------------------------------------------------------------------------------- |
14
+ | `init` | Create a site, its owner and a first personal token in the project's D1 database |
15
+ | `migrate` | Apply D1 migrations; `--check` lists the pending ones, `--remote` backs up first |
16
+ | `token` | Create, list and revoke personal tokens |
17
+ | `import` | Import Markdown files over MCP, as drafts, and publish them with `--publish` |
18
+ | `export` | Export entries as Markdown files (v0.2) |
12
19
 
13
- | Command | Purpose |
14
- | --------- | ------------------------------------- |
15
- | `init` | Set up Hollware in a project |
16
- | `migrate` | Apply D1 migrations |
17
- | `token` | Create, list and revoke access tokens |
18
- | `import` | Import Markdown files over MCP |
19
- | `export` | Export entries as Markdown files |
20
+ Every command answers `--help`. A command that fails prints one message on stderr, with what to
21
+ do, and exits with code 1.
22
+
23
+ ## A first run
24
+
25
+ In a Hollware project (the starter has the D1 binding `DB` in its `wrangler.jsonc`):
26
+
27
+ ```sh
28
+ hollware migrate # create the tables in the local database
29
+ hollware init --site blog # create the site and its owner; prints a token, once
30
+ hollware token create --label ci --scopes content:read,content:write
31
+ hollware token list # never shows a hash
32
+ hollware token revoke tok_01JAB3XQ8ZK4M6N2P5R7S9T0VW
33
+ ```
34
+
35
+ Add `--remote` to work on the database in Cloudflare instead (it needs `wrangler login` or a
36
+ `CLOUDFLARE_API_TOKEN`). `migrate --remote` first exports the database to
37
+ `.wrangler/hollware-backups` and stops if that fails. `--database <name>` picks another D1
38
+ database than the binding `DB`. `--site <key>` picks a site when the database has several.
39
+
40
+ Put the token that `init` printed in a file only you can read, and import a directory of
41
+ Markdown, laid out as `<collection>/<locale>/<slug>.md`, into a running Hollware:
42
+
43
+ ```sh
44
+ hollware import ./content --url https://cms.example.com --token-file ./token.txt --dry-run
45
+ hollware import ./content --url https://cms.example.com --token-file ./token.txt --publish
46
+ ```
47
+
48
+ `import` validates every file first and writes nothing if one would be refused. Running it
49
+ again changes nothing: every entry answers `unchanged`. With `--publish` it publishes the
50
+ entries that have unpublished changes, and it acknowledges no warning: an entry with warnings is
51
+ reported, and the command exits with code 1. The token never appears in its output.
52
+
53
+ ## How it works
54
+
55
+ `init`, `migrate` and `token` run the project's own `wrangler` (the nearest
56
+ `node_modules/.bin/wrangler`) against its D1 database, and read the binding from `wrangler.jsonc`.
57
+ `import` speaks MCP to `<url>/mcp` with the token as a bearer token. The CLI never loads
58
+ `hollware.config.ts`.
20
59
 
21
60
  ## Links
22
61
 
@@ -0,0 +1,20 @@
1
+ import { i as UsageError } from "./format-BpRYEsGB.mjs";
2
+ import { parseArgs } from "node:util";
3
+ //#region src/args.ts
4
+ /**
5
+ * `parseArgs` in strict mode: an unknown option, a missing value or a stray argument is a
6
+ * `UsageError` with one short sentence, instead of Node's error with its advice about `--`.
7
+ */
8
+ function parseFlags(config) {
9
+ try {
10
+ return parseArgs(config);
11
+ } catch (error) {
12
+ if (error instanceof TypeError && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS_")) {
13
+ const [sentence = error.message] = error.message.split(". ");
14
+ throw new UsageError(`${sentence.charAt(0).toLowerCase()}${sentence.slice(1)}.`);
15
+ }
16
+ throw error;
17
+ }
18
+ }
19
+ //#endregion
20
+ export { parseFlags as t };
@@ -0,0 +1,281 @@
1
+ import { i as UsageError, r as CliError, t as printable } from "./format-BpRYEsGB.mjs";
2
+ import { n as failureReason } from "./wrangler-BLIfwbcu.mjs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { SCOPES, SITE_KEY_MAX_LENGTH, isId, isScope, isSiteKey } from "@hollware/core";
6
+ import * as z from "zod";
7
+ //#region src/jsonc.ts
8
+ /**
9
+ * Reads JSON with comments, the format of `wrangler.jsonc`: `//` and `/* *\/` comments and a
10
+ * comma before a closing bracket are dropped, and a comment marker inside a string is left
11
+ * alone. It does nothing else: whatever `JSON.parse` rejects after that is an error.
12
+ */
13
+ function parseJsonc(text) {
14
+ return JSON.parse(stripJsonc(text));
15
+ }
16
+ function stripJsonc(text) {
17
+ const source = text.startsWith("") ? text.slice(1) : text;
18
+ let out = "";
19
+ let mode = "code";
20
+ for (let i = 0; i < source.length; i += 1) {
21
+ const char = source.charAt(i);
22
+ const next = source.charAt(i + 1);
23
+ if (mode === "string") {
24
+ out += char;
25
+ if (char === "\\") {
26
+ out += next;
27
+ i += 1;
28
+ } else if (char === "\"") mode = "code";
29
+ } else if (mode === "line-comment") {
30
+ if (char === "\n") {
31
+ out += char;
32
+ mode = "code";
33
+ }
34
+ } else if (mode === "block-comment") {
35
+ if (char === "*" && next === "/") {
36
+ i += 1;
37
+ mode = "code";
38
+ } else if (char === "\n") out += char;
39
+ } else if (char === "\"") {
40
+ out += char;
41
+ mode = "string";
42
+ } else if (char === "/" && next === "/") {
43
+ i += 1;
44
+ mode = "line-comment";
45
+ } else if (char === "/" && next === "*") {
46
+ i += 1;
47
+ mode = "block-comment";
48
+ } else out += char;
49
+ }
50
+ if (mode === "block-comment") throw new SyntaxError("a /* comment is never closed");
51
+ return dropTrailingCommas(out);
52
+ }
53
+ /** Drops a comma that only whitespace separates from a closing bracket, outside strings. */
54
+ function dropTrailingCommas(code) {
55
+ let out = "";
56
+ let inString = false;
57
+ for (let i = 0; i < code.length; i += 1) {
58
+ const char = code.charAt(i);
59
+ if (inString) {
60
+ out += char;
61
+ if (char === "\\") {
62
+ out += code.charAt(i + 1);
63
+ i += 1;
64
+ } else if (char === "\"") inString = false;
65
+ } else if (char === "\"") {
66
+ out += char;
67
+ inString = true;
68
+ } else if (char === "," && /^\s*[}\]]/.test(code.slice(i + 1))) continue;
69
+ else out += char;
70
+ }
71
+ return out;
72
+ }
73
+ //#endregion
74
+ //#region src/sql.ts
75
+ /**
76
+ * Everything the CLI says to the database. A value a person types is checked first and then
77
+ * enters a statement only as a SQL string literal, `'` doubled, so it cannot become part of the
78
+ * statement; the statements themselves are fixed templates. Nothing else in the package builds
79
+ * SQL.
80
+ */
81
+ /** A SQL string literal: the value in single quotes, with every single quote doubled. */
82
+ function quote(value) {
83
+ if (value.includes("\0")) throw new TypeError("a SQL string cannot hold a NUL character");
84
+ return `'${value.replaceAll("'", "''")}'`;
85
+ }
86
+ /** A value a person typed, quoted and escaped for a message; invisible characters become `?`. */
87
+ function shown(value) {
88
+ return printable(JSON.stringify(value));
89
+ }
90
+ /** A site key: lowercase letters and digits joined by single hyphens, like a slug. */
91
+ function parseSiteKey(value) {
92
+ if (!isSiteKey(value)) throw new UsageError(`invalid --site ${shown(value)}: a site key is lowercase letters and digits joined by single hyphens, up to ${SITE_KEY_MAX_LENGTH} characters, such as "blog" or "my-site".`);
93
+ return value;
94
+ }
95
+ const LABEL_MAX = 64;
96
+ const OWNER_NAME_MAX = 100;
97
+ const FORBIDDEN_IN_TEXT = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
98
+ function parseText(value, flag, max) {
99
+ const text = value.trim();
100
+ const length = Array.from(text).length;
101
+ if (length < 1 || length > max || FORBIDDEN_IN_TEXT.test(text)) throw new UsageError(`invalid ${flag} ${shown(value)}: use 1 to ${max} characters, with no control or invisible formatting characters.`);
102
+ return text;
103
+ }
104
+ /** The label of a token: 1 to 64 characters, trimmed, with nothing that a terminal would act on. */
105
+ function parseLabel(value) {
106
+ return parseText(value, "--label", LABEL_MAX);
107
+ }
108
+ /** The name of the owner: the same rules as a label, up to 100 characters. */
109
+ function parseOwnerName(value) {
110
+ return parseText(value, "--owner-name", OWNER_NAME_MAX);
111
+ }
112
+ /** The scopes of `--scopes`: a comma-separated list of known scopes, without repeats. */
113
+ function parseScopes(value) {
114
+ const names = value.split(",").map((name) => name.trim());
115
+ const scopes = [];
116
+ for (const name of names) {
117
+ if (!isScope(name)) throw new UsageError(`invalid --scopes: ${shown(name)} is not a scope. Choose from ${SCOPES.join(", ")}.`);
118
+ if (!scopes.includes(name)) scopes.push(name);
119
+ }
120
+ return scopes;
121
+ }
122
+ /** The days of `--expires-in-days`: a whole number from 1 to 365. */
123
+ function parseExpiresInDays(value) {
124
+ const days = /^[0-9]{1,4}$/.test(value) ? Number(value) : 0;
125
+ if (days < 1 || days > 365) throw new UsageError(`invalid --expires-in-days ${shown(value)}: use a whole number of days from 1 to 365.`);
126
+ return days;
127
+ }
128
+ /** The id of a token, `tok_` and 26 characters, as `token list` shows it. */
129
+ function parseTokenId(value) {
130
+ if (!isId("tok", value)) throw new UsageError(`invalid token id ${shown(value)}: a token id is tok_ followed by 26 characters. "hollware token list" shows them.`);
131
+ return value;
132
+ }
133
+ const DATABASE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
134
+ /** A D1 database, by name or by binding, as wrangler takes it. It never starts with a hyphen. */
135
+ function parseDatabase(value) {
136
+ if (!DATABASE.test(value)) throw new UsageError(`invalid --database ${shown(value)}: use the name or the binding of a D1 database (letters, digits, "_", "-" and ".", not starting with "-").`);
137
+ return value;
138
+ }
139
+ /** The statement that finds an owner. */
140
+ function ownerQuery() {
141
+ return "SELECT id FROM users WHERE role = 'owner' LIMIT 1";
142
+ }
143
+ /** One statement for the sites of the database and one for its first owner. */
144
+ function sitesAndOwnerQuery() {
145
+ return "SELECT id FROM sites ORDER BY id; SELECT id FROM users WHERE role = 'owner' ORDER BY created_at, id LIMIT 1;";
146
+ }
147
+ /** The sites of the database. */
148
+ function sitesQuery() {
149
+ return "SELECT id FROM sites ORDER BY id;";
150
+ }
151
+ /** The row of a new token. */
152
+ function insertToken(token) {
153
+ return `INSERT INTO tokens (id, site_id, user_id, label, hash, scopes, expires_at, created_at, updated_at) VALUES (${quote(token.id)}, ${quote(token.site)}, ${quote(token.userId)}, ${quote(token.label)}, ${quote(token.hash)}, ${quote(JSON.stringify(token.scopes))}, ${quote(token.expiresAt)}, ${quote(token.now)}, ${quote(token.now)});`;
154
+ }
155
+ /**
156
+ * What `init` writes, in one script: the site (left alone if it is already there), its owner
157
+ * and the owner's first token.
158
+ */
159
+ function initScript({ site, ownerId, ownerName, token }) {
160
+ const name = ownerName === void 0 ? "NULL" : quote(ownerName);
161
+ return [
162
+ `INSERT INTO sites (id, created_at, updated_at) VALUES (${quote(site)}, ${quote(token.now)}, ${quote(token.now)}) ON CONFLICT (id) DO NOTHING;`,
163
+ `INSERT INTO users (id, name, role, created_at, updated_at) VALUES (${quote(ownerId)}, ${name}, 'owner', ${quote(token.now)}, ${quote(token.now)});`,
164
+ insertToken(token),
165
+ ""
166
+ ].join("\n");
167
+ }
168
+ /** The tokens of a site. It never selects the hash. */
169
+ function listTokens(site) {
170
+ return `SELECT id, label, scopes, expires_at, revoked_at, last_used_at, created_at FROM tokens WHERE site_id = ${quote(site)} ORDER BY created_at, id;`;
171
+ }
172
+ /**
173
+ * Revokes a token that is not revoked yet and tells what is stored for the id afterwards: the
174
+ * first statement returns the id when it revoked the token, the second the row, if there is one.
175
+ */
176
+ function revokeToken(id, site, now) {
177
+ const scope = site === void 0 ? "" : ` AND site_id = ${quote(site)}`;
178
+ return `UPDATE tokens SET revoked_at = ${quote(now)}, updated_at = ${quote(now)} WHERE id = ${quote(id)}${scope} AND revoked_at IS NULL RETURNING id; SELECT id, revoked_at FROM tokens WHERE id = ${quote(id)}${scope};`;
179
+ }
180
+ //#endregion
181
+ //#region src/d1.ts
182
+ /** The two flags that choose a target. A command without either works on the local database. */
183
+ function chooseTarget(flags) {
184
+ if (flags.local === true && flags.remote === true) throw new UsageError("--local and --remote cannot be used together: choose one.");
185
+ return flags.remote === true ? "remote" : "local";
186
+ }
187
+ /** Where a command ran, for the messages: `the remote database "DB"`. */
188
+ function describeDatabase(target, database) {
189
+ return `the ${target} database "${database}"`;
190
+ }
191
+ const CONFIG_FILES = ["wrangler.jsonc", "wrangler.json"];
192
+ const WranglerConfig = z.looseObject({ d1_databases: z.array(z.looseObject({ binding: z.string() })).optional() });
193
+ /**
194
+ * The D1 database a command works on, as wrangler takes it (a name or a binding). `--database`
195
+ * says it. Without it, it is the binding `DB` of the project's `wrangler.jsonc`, which the CLI
196
+ * reads, as JSON with comments, and checks; it never loads `hollware.config.ts`.
197
+ */
198
+ function resolveDatabase(cwd, database) {
199
+ if (database !== void 0) return parseDatabase(database);
200
+ const file = CONFIG_FILES.map((name) => join(cwd, name)).find((path) => existsSync(path));
201
+ if (file === void 0) {
202
+ const hint = existsSync(join(cwd, "wrangler.toml")) ? " (wrangler.toml is not read)" : "";
203
+ throw new CliError(`there is no wrangler.jsonc in ${cwd}${hint}. Run hollware from the project directory, or name the D1 database with --database <name>.`);
204
+ }
205
+ const bindings = readBindings(file);
206
+ if (bindings.includes("DB")) return "DB";
207
+ throw new CliError(bindings.length === 0 ? `${file} has no D1 database. Add a D1 binding named "DB" (the starter has one), or name the database with --database <name>.` : `${file} has no D1 binding named "DB" (it has ${bindings.join(", ")}). Rename the binding, or choose one with --database <name or binding>.`);
208
+ }
209
+ function readBindings(file) {
210
+ let text;
211
+ try {
212
+ text = readFileSync(file, "utf8");
213
+ } catch (error) {
214
+ throw new CliError(`could not read ${file}: ${error instanceof Error ? error.message : String(error)}`);
215
+ }
216
+ let json;
217
+ try {
218
+ json = parseJsonc(text);
219
+ } catch (error) {
220
+ throw new CliError(`${file} is not valid JSON with comments: ${error instanceof Error ? error.message : String(error)}`);
221
+ }
222
+ const config = WranglerConfig.safeParse(json);
223
+ if (!config.success) {
224
+ const [issue] = config.error.issues;
225
+ throw new CliError(`${file} has an unexpected shape at "${issue?.path.join(".") ?? ""}": ${issue?.message ?? "invalid"}`);
226
+ }
227
+ return (config.data.d1_databases ?? []).map((entry) => entry.binding);
228
+ }
229
+ const Answer = z.array(z.object({
230
+ results: z.array(z.record(z.string(), z.unknown())),
231
+ success: z.boolean()
232
+ }));
233
+ /**
234
+ * Runs SQL through `wrangler d1 execute --json` and returns the rows of each statement, in
235
+ * order. A database without the Hollware tables is reported with the command that creates them.
236
+ */
237
+ async function query(wrangler, database, target, sql) {
238
+ return readAnswer(await wrangler([
239
+ "d1",
240
+ "execute",
241
+ database,
242
+ `--${target}`,
243
+ "--json",
244
+ "--command",
245
+ sql
246
+ ]), database, target);
247
+ }
248
+ /** Runs the SQL of a file in one `wrangler d1 execute --json`: all of it, or none. */
249
+ async function runFile(wrangler, database, target, file) {
250
+ readAnswer(await wrangler([
251
+ "d1",
252
+ "execute",
253
+ database,
254
+ `--${target}`,
255
+ "--json",
256
+ "--file",
257
+ file
258
+ ]), database, target);
259
+ }
260
+ function readAnswer(result, database, target) {
261
+ if (result.code !== 0) {
262
+ const reason = failureReason(result);
263
+ if (reason.includes("no such table")) {
264
+ const flag = database === "DB" ? "" : ` --database ${database}`;
265
+ throw new CliError(`the database has no Hollware tables yet. Run "hollware migrate --${target}${flag}" first.`);
266
+ }
267
+ throw new CliError(`wrangler could not run the SQL: ${reason}`);
268
+ }
269
+ let json;
270
+ try {
271
+ json = JSON.parse(result.stdout);
272
+ } catch {
273
+ throw new CliError(`wrangler did not answer with JSON: ${failureReason(result)}`);
274
+ }
275
+ const answer = Answer.safeParse(json);
276
+ if (!answer.success) throw new CliError("wrangler answered with JSON that has an unexpected shape.");
277
+ if (answer.data.some((statement) => !statement.success)) throw new CliError("wrangler reported that a statement did not succeed.");
278
+ return answer.data.map((statement) => statement.results);
279
+ }
280
+ //#endregion
281
+ export { sitesAndOwnerQuery as _, runFile as a, listTokens as c, parseLabel as d, parseOwnerName as f, revokeToken as g, parseTokenId as h, resolveDatabase as i, ownerQuery as l, parseSiteKey as m, describeDatabase as n, initScript as o, parseScopes as p, query as r, insertToken as s, chooseTarget as t, parseExpiresInDays as u, sitesQuery as v };
@@ -0,0 +1,17 @@
1
+ import { ID_PREFIXES, generatePersonalToken, ulid } from "@hollware/core";
2
+ //#region src/entropy.ts
3
+ /** A new id of a kind, such as `usr_01JAB…`, from the clock and the random source of `io`. */
4
+ function newId(io, kind) {
5
+ return `${ID_PREFIXES[kind]}_${ulid(io.now().getTime(), io.randomBytes(10))}`;
6
+ }
7
+ /** A new personal token (`hw_pat_` and 52 characters) from the random source of `io`. */
8
+ function newPersonalToken(io) {
9
+ return generatePersonalToken(io.randomBytes(52));
10
+ }
11
+ const DAY_MS = 864e5;
12
+ /** The moment, in ISO 8601 UTC, that is `days` days after `from`. */
13
+ function inDays(from, days) {
14
+ return new Date(from.getTime() + days * DAY_MS).toISOString();
15
+ }
16
+ //#endregion
17
+ export { newId as n, newPersonalToken as r, inDays as t };
@@ -0,0 +1,38 @@
1
+ //#region src/errors.ts
2
+ /**
3
+ * An error the person at the keyboard can act on. `main` prints its message on stderr, behind
4
+ * the name of the command, and exits with code 1. Anything that is not a `CliError` is a bug or
5
+ * a broken install, and escapes with its stack trace.
6
+ */
7
+ var CliError = class extends Error {
8
+ name = "CliError";
9
+ };
10
+ /**
11
+ * A mistake in the arguments: an unknown option, a missing or a malformed value. `main` adds a
12
+ * pointer to the help of the command after the message.
13
+ */
14
+ var UsageError = class extends CliError {
15
+ name = "UsageError";
16
+ };
17
+ //#endregion
18
+ //#region src/format.ts
19
+ /**
20
+ * Text for the terminal: a value that came from the database or from a server is made safe to
21
+ * print, and rows are laid out in aligned columns.
22
+ */
23
+ const UNSAFE_FOR_TERMINAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
24
+ /** `text` with every control or invisible formatting character replaced by `?`. */
25
+ function printable(text) {
26
+ return text.replace(UNSAFE_FOR_TERMINAL, "?");
27
+ }
28
+ /** Rows of cells in columns as wide as their widest cell, separated by two spaces. */
29
+ function table(header, rows) {
30
+ const all = [header, ...rows];
31
+ const widths = header.map((_, column) => Math.max(...all.map((row) => Array.from(row[column] ?? "").length)));
32
+ return all.map((row) => row.map((cell, column) => {
33
+ const padding = " ".repeat((widths[column] ?? 0) - Array.from(cell).length);
34
+ return column === row.length - 1 ? cell : `${cell}${padding}`;
35
+ }).join(" ")).join("\n");
36
+ }
37
+ //#endregion
38
+ export { UsageError as i, table as n, CliError as r, printable as t };