hollware 0.2.0 → 0.4.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,8 +3,8 @@
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.** `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
6
+ > **Status: pre-alpha.** `init`, `migrate`, `deploy`, `doctor`, `token`, `invite` and `import`
7
+ > work, against a Hollware that is still being built. POSIX shells only: it has not been tried on
8
8
  > Windows.
9
9
 
10
10
  ## Commands
@@ -13,7 +13,10 @@ CMS for the agent era, native to Cloudflare Workers.
13
13
  | --------- | -------------------------------------------------------------------------------- |
14
14
  | `init` | Create a site, its owner and a first personal token in the project's D1 database |
15
15
  | `migrate` | Apply D1 migrations; `--check` lists the pending ones, `--remote` backs up first |
16
- | `token` | Create, list and revoke personal tokens |
16
+ | `deploy` | Deploy to Cloudflare, creating what the project needs, and migrate the database |
17
+ | `doctor` | Check the project, its bindings, its database and its owner, with what to fix |
18
+ | `token` | Create, list and revoke personal tokens, and read tokens with `--read` |
19
+ | `invite` | Invite a person to the admin, as an editor or an author |
17
20
  | `import` | Import Markdown files over MCP, as drafts, and publish them with `--publish` |
18
21
  | `export` | Export entries as Markdown files (v0.2) |
19
22
 
@@ -32,9 +35,15 @@ hollware token list # never shows a hash
32
35
  hollware token revoke tok_01JAB3XQ8ZK4M6N2P5R7S9T0VW
33
36
  ```
34
37
 
38
+ To put it on Cloudflare, log in with `wrangler login` and run `hollware deploy`: the first deploy
39
+ creates the D1 database, the KV namespace and the queues, writes their ids and the public URL of
40
+ the Worker into `wrangler.jsonc`, and applies the migrations. Then `hollware init --site blog
41
+ --remote` makes the site in it.
42
+
35
43
  Add `--remote` to work on the database in Cloudflare instead (it needs `wrangler login` or a
36
44
  `CLOUDFLARE_API_TOKEN`). `migrate --remote` first exports the database to
37
- `.wrangler/hollware-backups` and stops if that fails. `--database <name>` picks another D1
45
+ `.wrangler/hollware-backups` and stops if that fails; a new database, with none of the
46
+ migrations, has nothing to back up. `--database <name>` picks another D1
38
47
  database than the binding `DB`. `--site <key>` picks a site when the database has several.
39
48
 
40
49
  Put the token that `init` printed in a file only you can read, and import a directory of
@@ -52,7 +61,7 @@ reported, and the command exits with code 1. The token never appears in its outp
52
61
 
53
62
  ## How it works
54
63
 
55
- `init`, `migrate` and `token` run the project's own `wrangler` (the nearest
64
+ `init`, `migrate`, `deploy`, `doctor` and `token` run the project's own `wrangler` (the nearest
56
65
  `node_modules/.bin/wrangler`) against its D1 database, and read the binding from `wrangler.jsonc`.
57
66
  `import` speaks MCP to `<url>/mcp` with the token as a bearer token. The CLI never loads
58
67
  `hollware.config.ts`.
@@ -2,8 +2,8 @@ import { i as UsageError, r as CliError, t as printable } from "./format-BpRYEsG
2
2
  import { n as failureReason } from "./wrangler-BLIfwbcu.mjs";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { SCOPES, SITE_KEY_MAX_LENGTH, isId, isScope, isSiteKey } from "@hollware/core";
6
5
  import * as z from "zod";
6
+ import { SCOPES, SITE_KEY_MAX_LENGTH, isId, isScope, isSiteKey } from "@hollware/core";
7
7
  //#region src/jsonc.ts
8
8
  /**
9
9
  * Reads JSON with comments, the format of `wrangler.jsonc`: `//` and `/* *\/` comments and a
@@ -136,13 +136,17 @@ function parseDatabase(value) {
136
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
137
  return value;
138
138
  }
139
- /** The statement that finds an owner. */
139
+ /** The statement that finds the owners: the members with the role owner, first come first (ADR-026). */
140
140
  function ownerQuery() {
141
- return "SELECT id FROM users WHERE role = 'owner' LIMIT 1";
141
+ return "SELECT user_id AS id, site_id AS site FROM members WHERE role = 'owner' ORDER BY created_at, user_id";
142
142
  }
143
143
  /** One statement for the sites of the database and one for its first owner. */
144
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;";
145
+ return "SELECT id FROM sites ORDER BY id; SELECT user_id AS id FROM members WHERE role = 'owner' ORDER BY created_at, user_id LIMIT 1;";
146
+ }
147
+ /** The owner of a site: the member with the role owner who came first. */
148
+ function ownerOfSiteQuery(site) {
149
+ return `SELECT user_id AS id FROM members WHERE site_id = ${quote(site)} AND role = 'owner' ORDER BY created_at, user_id LIMIT 1;`;
146
150
  }
147
151
  /** The sites of the database. */
148
152
  function sitesQuery() {
@@ -150,21 +154,41 @@ function sitesQuery() {
150
154
  }
151
155
  /** The row of a new token. */
152
156
  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)});`;
157
+ const kind = token.kind === void 0 ? ["", ""] : [", kind", `, ${quote(token.kind)}`];
158
+ return `INSERT INTO tokens (id, site_id, user_id, label, hash, scopes, expires_at, created_at, updated_at${kind[0]}) 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)}${kind[1]});`;
154
159
  }
155
160
  /**
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.
161
+ * What `init` writes, in one script: the site (left alone if it is already there), its owner, a
162
+ * member of the site with the role owner, and the owner's first token. `users.role` gets the
163
+ * owner too, the one name of a role that its CHECK and `members` share.
158
164
  */
159
- function initScript({ site, ownerId, ownerName, token }) {
165
+ function initScript({ site, ownerId, memberId, ownerName, token }) {
160
166
  const name = ownerName === void 0 ? "NULL" : quote(ownerName);
161
167
  return [
162
168
  `INSERT INTO sites (id, created_at, updated_at) VALUES (${quote(site)}, ${quote(token.now)}, ${quote(token.now)}) ON CONFLICT (id) DO NOTHING;`,
163
169
  `INSERT INTO users (id, name, role, created_at, updated_at) VALUES (${quote(ownerId)}, ${name}, 'owner', ${quote(token.now)}, ${quote(token.now)});`,
170
+ `INSERT INTO members (id, site_id, user_id, role, created_at, updated_at) VALUES (${quote(memberId)}, ${quote(site)}, ${quote(ownerId)}, 'owner', ${quote(token.now)}, ${quote(token.now)});`,
171
+ insertToken(token),
172
+ ""
173
+ ].join("\n");
174
+ }
175
+ /**
176
+ * What `init` writes for a site of a deployment that has an owner already: the site, the owner as
177
+ * its owner too (one person across the sites of a deployment), and a token of that site.
178
+ */
179
+ function anotherSiteScript({ site, ownerId, memberId, token }) {
180
+ return [
181
+ `INSERT INTO sites (id, created_at, updated_at) VALUES (${quote(site)}, ${quote(token.now)}, ${quote(token.now)}) ON CONFLICT (id) DO NOTHING;`,
182
+ `INSERT INTO members (id, site_id, user_id, role, created_at, updated_at) VALUES (${quote(memberId)}, ${quote(site)}, ${quote(ownerId)}, 'owner', ${quote(token.now)}, ${quote(token.now)});`,
164
183
  insertToken(token),
165
184
  ""
166
185
  ].join("\n");
167
186
  }
187
+ /** The row of an invitation (ADR-026): the owner's setup link, or an invitation with a role. */
188
+ function insertInvitation(invitation) {
189
+ const user = invitation.userId === null ? "NULL" : quote(invitation.userId);
190
+ return `INSERT INTO invitations (id, site_id, hash, role, user_id, expires_at, used_at, created_at, updated_at) VALUES (${quote(invitation.id)}, ${quote(invitation.site)}, ${quote(invitation.hash)}, ${quote(invitation.role)}, ${user}, ${quote(invitation.expiresAt)}, NULL, ${quote(invitation.now)}, ${quote(invitation.now)});`;
191
+ }
168
192
  /** The tokens of a site. It never selects the hash. */
169
193
  function listTokens(site) {
170
194
  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;`;
@@ -266,16 +290,24 @@ function readAnswer(result, database, target) {
266
290
  }
267
291
  throw new CliError(`wrangler could not run the SQL: ${reason}`);
268
292
  }
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
- }
293
+ const json = jsonOf(result.stdout);
294
+ if (json === void 0) throw new CliError(`wrangler did not answer with JSON: ${failureReason(result)}`);
275
295
  const answer = Answer.safeParse(json);
276
296
  if (!answer.success) throw new CliError("wrangler answered with JSON that has an unexpected shape.");
277
297
  if (answer.data.some((statement) => !statement.success)) throw new CliError("wrangler reported that a statement did not succeed.");
278
298
  return answer.data.map((statement) => statement.results);
279
299
  }
300
+ /**
301
+ * The JSON of an answer of `wrangler d1 execute --json`: all of stdout, or, when wrangler printed a
302
+ * warning before it (a config it finds incomplete, an update that is out), what follows the first
303
+ * line that opens the array. `undefined` when there is no JSON. Reading past a warning matters:
304
+ * `hollware init` prints its token only after the SQL answered, so a failure here loses it.
305
+ */
306
+ function jsonOf(stdout) {
307
+ const attempts = [stdout, ...stdout.split("\n").flatMap((line, index, lines) => line.trimStart().startsWith("[") ? [lines.slice(index).join("\n")] : [])];
308
+ for (const text of attempts) try {
309
+ return JSON.parse(text);
310
+ } catch {}
311
+ }
280
312
  //#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 };
313
+ export { parseJsonc as S, parseSiteKey as _, runFile as a, sitesAndOwnerQuery as b, insertInvitation as c, ownerOfSiteQuery as d, ownerQuery as f, parseScopes as g, parseOwnerName as h, resolveDatabase as i, insertToken as l, parseLabel as m, describeDatabase as n, anotherSiteScript as o, parseExpiresInDays as p, query as r, initScript as s, chooseTarget as t, listTokens as u, parseTokenId as v, sitesQuery as x, revokeToken as y };
@@ -0,0 +1,101 @@
1
+ import { r as CliError } from "./format-BpRYEsGB.mjs";
2
+ import { n as failureReason, r as stripAnsi } from "./wrangler-BLIfwbcu.mjs";
3
+ import { t as parseFlags } from "./args-CljWh-kZ.mjs";
4
+ import { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-D5A6OVDD.mjs";
5
+ import { t as publicUrlOf } from "./public-url-DURHzCaC.mjs";
6
+ import { i as migrate } from "./migrate-D6Sgxnwx.mjs";
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ //#region src/commands/deploy.ts
10
+ const DEPLOY_USAGE = [
11
+ "Usage: hollware deploy [--url <url>]",
12
+ "",
13
+ "Deploys the project to Cloudflare with its wrangler, and applies the migrations to the remote",
14
+ "database. The first deploy creates the D1 database, the KV namespace and the queues that",
15
+ "wrangler.jsonc names without an id, and writes their ids into it: commit them.",
16
+ "",
17
+ "HOLLWARE_PUBLIC_URL must be the public URL of the Worker. While it is a localhost URL, the",
18
+ "workers.dev URL that wrangler prints is written into wrangler.jsonc, and the Worker is deployed",
19
+ "again with it. Run \"wrangler login\" first.",
20
+ "",
21
+ "Options:",
22
+ " --url <url> The public URL of the Worker, such as https://cms.example.com (default: the",
23
+ " HOLLWARE_PUBLIC_URL of wrangler.jsonc, or the workers.dev URL)",
24
+ " --help Show this help"
25
+ ].join("\n");
26
+ /** The workers.dev URL that `wrangler deploy` prints, or `undefined`. */
27
+ function workersDevUrlOf(stdout) {
28
+ const match = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev(?![\w.-])/i.exec(stripAnsi(stdout));
29
+ return match === null ? void 0 : new URL(match[0]).origin;
30
+ }
31
+ /** Whether a URL is this machine's: the one `pnpm dev` serves, which no deploy answers at. */
32
+ function isLoopback(url) {
33
+ const { hostname } = new URL(url);
34
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
35
+ }
36
+ const PUBLIC_URL_VALUE = /("HOLLWARE_PUBLIC_URL"\s*:\s*)"[^"\n]*"/;
37
+ /**
38
+ * `wrangler.jsonc` with `HOLLWARE_PUBLIC_URL` set to `url`, and everything else as it was: its
39
+ * comments, its order and its spacing. It changes the one string and nothing around it.
40
+ */
41
+ function withPublicUrl(text, url) {
42
+ if (!PUBLIC_URL_VALUE.test(text)) throw new CliError("wrangler.jsonc has no \"HOLLWARE_PUBLIC_URL\" in \"vars\". Add it, with the public URL of the Worker.");
43
+ return text.replace(PUBLIC_URL_VALUE, (_match, key) => `${key}${JSON.stringify(url)}`);
44
+ }
45
+ async function wranglerDeploy(io) {
46
+ const deployed = await io.wrangler(["deploy"]);
47
+ if (deployed.code !== 0) throw new CliError(`wrangler could not deploy: ${failureReason(deployed)}. Is wrangler logged in ("wrangler login")?`);
48
+ return deployed.stdout;
49
+ }
50
+ async function deploy(argv, io) {
51
+ const { values } = parseFlags({
52
+ args: [...argv],
53
+ allowPositionals: false,
54
+ options: {
55
+ url: { type: "string" },
56
+ help: { type: "boolean" }
57
+ }
58
+ });
59
+ if (values.help === true) {
60
+ io.out(DEPLOY_USAGE);
61
+ return 0;
62
+ }
63
+ const file = join(io.cwd, "wrangler.jsonc");
64
+ if (!existsSync(file)) throw new CliError(`${file} is missing: run hollware deploy in the directory of the project.`);
65
+ const configured = publicUrlOf(io.cwd, void 0);
66
+ const asked = values.url === void 0 ? void 0 : publicUrlOf(io.cwd, values.url);
67
+ const database = resolveDatabase(io.cwd, void 0);
68
+ io.out("Deploying with wrangler ...");
69
+ const printed = await wranglerDeploy(io);
70
+ let url = asked ?? (configured !== void 0 && !isLoopback(configured) ? configured : void 0);
71
+ url ??= workersDevUrlOf(printed);
72
+ if (url === void 0) throw new CliError("wrangler deployed the Worker but printed no workers.dev URL. Run \"hollware deploy --url <the public URL of the Worker>\".");
73
+ if (url !== configured) {
74
+ writeFileSync(file, withPublicUrl(readFileSync(file, "utf8"), url));
75
+ io.out(`Set HOLLWARE_PUBLIC_URL to ${url} in wrangler.jsonc. Deploying again with it ...`);
76
+ await wranglerDeploy(io);
77
+ }
78
+ await migrate([
79
+ "--remote",
80
+ "--database",
81
+ database
82
+ ], io);
83
+ const [owners = []] = await query(io.wrangler, database, "remote", ownerQuery());
84
+ io.out([
85
+ `Hollware is live at ${url}.`,
86
+ "",
87
+ ` MCP ${url}/mcp`,
88
+ ` Admin ${url}/admin/`,
89
+ "",
90
+ "Commit wrangler.jsonc: it holds the ids of what the deploy created, and the public URL.",
91
+ ...owners.length === 0 ? [
92
+ "",
93
+ "Next: create your site and its owner in the remote database, with the key of the site in",
94
+ "hollware.config.ts. It prints a token for your agents and a link to set up your passkey:",
95
+ " hollware init --site <key> --remote"
96
+ ] : []
97
+ ].join("\n"));
98
+ return 0;
99
+ }
100
+ //#endregion
101
+ export { deploy };
@@ -0,0 +1,113 @@
1
+ import { r as CliError } from "./format-BpRYEsGB.mjs";
2
+ import { n as failureReason } from "./wrangler-BLIfwbcu.mjs";
3
+ import { t as parseFlags } from "./args-CljWh-kZ.mjs";
4
+ import { S as parseJsonc, f as ownerQuery, r as query, t as chooseTarget } from "./d1-D5A6OVDD.mjs";
5
+ import { a as parsePendingMigrations } from "./migrate-D6Sgxnwx.mjs";
6
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ //#region src/commands/doctor.ts
9
+ const DOCTOR_USAGE = [
10
+ "Usage: hollware doctor [--local | --remote]",
11
+ "",
12
+ "Checks the project in the current directory: its wrangler.jsonc, the bindings and flags",
13
+ "Hollware needs, the built files of the admin, the migrations of the database and its owner.",
14
+ "Each problem comes with what to do about it. Exits with code 1 when something is wrong.",
15
+ "",
16
+ "Options:",
17
+ " --local Check the local database (the default)",
18
+ " --remote Check the database in Cloudflare",
19
+ " --help Show this help"
20
+ ].join("\n");
21
+ /** The flags of `compatibility_flags` that Hollware runs with (ADR-018, ADR-026). */
22
+ const REQUIRED_FLAGS = [
23
+ "no_nodejs_compat",
24
+ "no_nodejs_compat_v2",
25
+ "global_fetch_strictly_public"
26
+ ];
27
+ const pass = (what) => ({
28
+ ok: true,
29
+ what
30
+ });
31
+ const fail = (what, fix) => ({
32
+ ok: false,
33
+ what,
34
+ fix
35
+ });
36
+ function isRecord(value) {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+ function records(value) {
40
+ return Array.isArray(value) ? value.filter(isRecord) : [];
41
+ }
42
+ /** What `wrangler.jsonc` says, checked without reaching Cloudflare or the database. */
43
+ function checkConfig(cwd, config) {
44
+ const findings = [];
45
+ const flags = Array.isArray(config["compatibility_flags"]) ? config["compatibility_flags"] : [];
46
+ const missing = REQUIRED_FLAGS.filter((flag) => !flags.includes(flag));
47
+ findings.push(missing.length === 0 ? pass(`compatibility_flags has ${REQUIRED_FLAGS.join(", ")}`) : fail(`compatibility_flags lacks ${missing.join(", ")}`, `Add ${missing.map((flag) => `"${flag}"`).join(", ")} to compatibility_flags in wrangler.jsonc.`));
48
+ const url = (isRecord(config["vars"]) ? config["vars"] : {})["HOLLWARE_PUBLIC_URL"];
49
+ const urlOk = typeof url === "string" && URL.canParse(url) && /^https?:$/.test(new URL(url).protocol);
50
+ findings.push(urlOk ? pass(`HOLLWARE_PUBLIC_URL is ${new URL(url).origin}`) : fail("HOLLWARE_PUBLIC_URL is missing or is no http or https URL", "Set vars.HOLLWARE_PUBLIC_URL in wrangler.jsonc to the public URL of the Worker, such as https://cms.example.com."));
51
+ const db = records(config["d1_databases"]).find((entry) => entry["binding"] === "DB");
52
+ const migrationsDir = db !== void 0 && typeof db["migrations_dir"] === "string" ? join(cwd, db["migrations_dir"]) : void 0;
53
+ const migrations = migrationsDir !== void 0 && existsSync(migrationsDir) ? readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")) : [];
54
+ findings.push(db === void 0 ? fail(`there is no D1 binding "DB"`, "Add it to d1_databases in wrangler.jsonc, as the starter has it.") : migrations.length === 0 ? fail(`the D1 binding "DB" has no migrations in its migrations_dir`, "Set migrations_dir to \"node_modules/@hollware/server/migrations\", and install the dependencies.") : pass(`the D1 binding "DB" has ${migrations.length} migrations`));
55
+ const kv = records(config["kv_namespaces"]).some((entry) => entry["binding"] === "OAUTH_KV");
56
+ findings.push(kv ? pass("the KV binding \"OAUTH_KV\" is there") : fail("there is no KV binding \"OAUTH_KV\", which the OAuth server needs", "Add { \"binding\": \"OAUTH_KV\" } to kv_namespaces in wrangler.jsonc."));
57
+ const assets = isRecord(config["assets"]) ? config["assets"] : void 0;
58
+ const directory = typeof assets?.["directory"] === "string" ? assets["directory"] : void 0;
59
+ const admin = directory !== void 0 && existsSync(join(cwd, directory, "admin"));
60
+ findings.push(admin ? pass("the admin is built, in the static assets") : fail("the static assets do not hold the admin", "Install the dependencies; assets.directory of wrangler.jsonc must be \"node_modules/@hollware/admin/dist/client\"."));
61
+ return findings;
62
+ }
63
+ /** What the database says: the migrations it lacks and whether it has an owner. */
64
+ async function checkDatabase(io, target) {
65
+ const listed = await io.wrangler([
66
+ "d1",
67
+ "migrations",
68
+ "list",
69
+ "DB",
70
+ `--${target}`
71
+ ]);
72
+ if (listed.code !== 0) return [fail(`wrangler could not list the migrations of the ${target} database: ${failureReason(listed)}`, target === "remote" ? "Run \"wrangler login\", then \"hollware deploy\"." : "Check wrangler.jsonc.")];
73
+ const pending = parsePendingMigrations(listed.stdout);
74
+ if (pending.length > 0) return [fail(`the ${target} database lacks ${pending.length} ${pending.length === 1 ? "migration" : "migrations"}`, `Run "hollware migrate --${target}".`)];
75
+ const [owners = []] = await query(io.wrangler, "DB", target, ownerQuery());
76
+ return [pass(`the ${target} database has every migration`), owners.length > 0 ? pass(`the ${target} database has an owner`) : fail(`the ${target} database has no owner yet`, `Run "hollware init --site <key> --${target}", with the key of your site in hollware.config.ts.`)];
77
+ }
78
+ async function doctor(argv, io) {
79
+ const { values } = parseFlags({
80
+ args: [...argv],
81
+ allowPositionals: false,
82
+ options: {
83
+ local: { type: "boolean" },
84
+ remote: { type: "boolean" },
85
+ help: { type: "boolean" }
86
+ }
87
+ });
88
+ if (values.help === true) {
89
+ io.out(DOCTOR_USAGE);
90
+ return 0;
91
+ }
92
+ const target = chooseTarget(values);
93
+ const file = join(io.cwd, "wrangler.jsonc");
94
+ if (!existsSync(file)) throw new CliError(`${file} is missing: run hollware doctor in the directory of the project.`);
95
+ let config;
96
+ try {
97
+ config = parseJsonc(readFileSync(file, "utf8"));
98
+ } catch (error) {
99
+ throw new CliError(`wrangler.jsonc is not valid JSONC: ${String(error)}`);
100
+ }
101
+ if (!isRecord(config)) throw new CliError("wrangler.jsonc holds no object.");
102
+ const findings = [...checkConfig(io.cwd, config), ...await checkDatabase(io, target)];
103
+ io.out(findings.map((finding) => finding.ok ? `ok ${finding.what}` : `FAIL ${finding.what}\n ${finding.fix ?? ""}`).join("\n"));
104
+ const failed = findings.filter((finding) => !finding.ok).length;
105
+ if (failed === 0) {
106
+ io.out("Everything Hollware needs is in place.");
107
+ return 0;
108
+ }
109
+ io.err(`hollware doctor: ${failed} ${failed === 1 ? "problem" : "problems"} to fix.`);
110
+ return 1;
111
+ }
112
+ //#endregion
113
+ export { doctor };
@@ -0,0 +1,27 @@
1
+ import { ID_PREFIXES, generatePersonalToken, generateReadToken, 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
+ /** A new read token (`hw_read_`), from the same randomness. */
12
+ function newReadToken(io) {
13
+ return generateReadToken(io.randomBytes(52));
14
+ }
15
+ const DAY_MS = 864e5;
16
+ /** The moment, in ISO 8601 UTC, that is `days` days after `from`. */
17
+ function inDays(from, days) {
18
+ return new Date(from.getTime() + days * DAY_MS).toISOString();
19
+ }
20
+ /** A new invitation secret (`hw_inv_` and 43 base64url characters) from the random source of `io`. */
21
+ function newInvitationSecret(io) {
22
+ let binary = "";
23
+ for (const byte of io.randomBytes(32)) binary += String.fromCharCode(byte);
24
+ return `hw_inv_${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
25
+ }
26
+ //#endregion
27
+ export { newReadToken as a, newPersonalToken as i, newId as n, newInvitationSecret as r, inDays as t };
@@ -195,7 +195,8 @@ const IMPORT_USAGE = [
195
195
  " --dry-run Only validate: write nothing, publish nothing",
196
196
  " --help Show this help",
197
197
  "",
198
- "The token needs content:read and content:write, and content:publish for --publish."
198
+ "The token needs content:read and content:write, and content:publish for --publish: without it,",
199
+ "each entry asks for a review, which a person who may publish approves in the admin."
199
200
  ].join("\n");
200
201
  async function importContent(argv, io) {
201
202
  const { values, positionals } = parseFlags({
@@ -263,7 +264,7 @@ async function importFiles({ io, token, endpoint, directory, root, publish: publ
263
264
  if (drafts.failed > 0) throw new CliError(`${count(drafts.failed, "file")} could not be imported (see above).${publishToo ? " Nothing was published." : ""} Fix ${drafts.failed === 1 ? "it" : "them"} and run the import again: what is already there stays unchanged.`);
264
265
  if (!publishToo) return 0;
265
266
  const published = await publishAll(session, drafts.entries.filter((imported) => imported.summary.hasUnpublishedChanges), out);
266
- out(`Publishing: ${published.published} published, ${published.needsAcknowledgement} need acknowledgement, ${published.blocked} blocked${published.other === 0 ? "" : `, ${published.other} failed`}.`);
267
+ out(`Publishing: ${published.published} published, ${published.needsAcknowledgement} need acknowledgement, ${published.blocked} blocked${published.reviews === 0 ? "" : `, ${published.reviews} asked for review`}${published.other === 0 ? "" : `, ${published.other} failed`}.`);
267
268
  const problems = published.needsAcknowledgement + published.blocked + published.other;
268
269
  if (problems > 0) throw new CliError(`${count(problems, "entry", "entries")} not published: warnings are never acknowledged here, and errors block publishing. Fix the content and run the import again with --publish: what is already published is skipped.`);
269
270
  return 0;
@@ -442,50 +443,89 @@ function draftLine(kind, label, entry, verdict) {
442
443
  const notes = [errors > 0 ? count(errors, "error") : void 0, warnings > 0 ? count(warnings, "warning") : void 0].filter((note) => note !== void 0);
443
444
  return `${kind.padEnd(9)} ${label} ${entry.id} v${entry.version}${notes.length === 0 ? "" : ` (${notes.join(", ")})`}`;
444
445
  }
446
+ /** The most entries one publish takes: the entry and a group of 99 (ADR-024). */
447
+ const GROUP_SIZE = 100;
445
448
  /**
446
- * Phase two: publishes each imported entry that has unpublished changes, at the version phase
447
- * one left it. It acknowledges no warning: an entry with warnings is reported, not published.
449
+ * Phase two: publishes the imported entries that have unpublished changes, at the version phase
450
+ * one left them, as one group (in groups of 100), so that entries that link to each other are
451
+ * published together (ADR-024). When an entry stops its group, it is reported and taken out, and
452
+ * the rest is published again without it. It acknowledges no warning: an entry with warnings is
453
+ * reported, not published. A token without content:publish may only ask for a review of a lone
454
+ * entry (`pending_review`): its group is refused, and then every entry asks on its own.
448
455
  */
449
456
  async function publishAll(session, entries, out) {
450
457
  const result = {
451
458
  published: 0,
452
459
  needsAcknowledgement: 0,
453
460
  blocked: 0,
461
+ reviews: 0,
454
462
  other: 0
455
463
  };
456
- for (const { file, summary } of entries) {
457
- const label = printable(file.path.replace(/\.md$/, ""));
458
- const line = (outcome, details = []) => [`${outcome.padEnd(21)} ${label} ${summary.id}`, ...details.map((detail) => ` ${detail}`)].join("\n");
459
- try {
460
- const answer = await session.call(publish, {
461
- id: summary.id,
462
- version: summary.version
463
- });
464
+ const labelOf = ({ file }) => printable(file.path.replace(/\.md$/, ""));
465
+ const line = (imported, outcome, details = []) => [`${outcome.padEnd(21)} ${labelOf(imported)} ${imported.summary.id}`, ...details.map((detail) => ` ${detail}`)].join("\n");
466
+ /**
467
+ * Whether entries go one by one, once a group was refused: a token without content:publish may
468
+ * only ask for a review of a lone entry.
469
+ */
470
+ let alone = false;
471
+ for (let start = 0; start < entries.length; start += GROUP_SIZE) {
472
+ let pending = entries.slice(start, start + GROUP_SIZE);
473
+ while (pending.length > 0) {
474
+ const [first, ...rest] = pending;
475
+ if (first === void 0) break;
476
+ const batch = alone ? [first] : pending;
477
+ let answer;
478
+ try {
479
+ answer = await session.call(publish, {
480
+ id: first.summary.id,
481
+ version: first.summary.version,
482
+ ...alone ? {} : { group: rest.map(({ summary }) => ({
483
+ id: summary.id,
484
+ version: summary.version
485
+ })) }
486
+ });
487
+ } catch (error) {
488
+ if (!(error instanceof ToolFailure)) throw error;
489
+ if (!alone && batch.length > 1) {
490
+ alone = true;
491
+ continue;
492
+ }
493
+ for (const imported of batch) out(line(imported, "failed", [error.message]));
494
+ result.other += batch.length;
495
+ pending = pending.filter((imported) => !batch.includes(imported));
496
+ continue;
497
+ }
498
+ if (answer.outcome === "published" || answer.outcome === "scheduled") {
499
+ for (const imported of batch) out(line(imported, "published"));
500
+ result.published += batch.length;
501
+ pending = pending.filter((imported) => !batch.includes(imported));
502
+ continue;
503
+ }
504
+ if (answer.outcome === "pending_review") {
505
+ for (const imported of batch) out(line(imported, "pending_review"));
506
+ result.reviews += batch.length;
507
+ pending = pending.filter((imported) => !batch.includes(imported));
508
+ continue;
509
+ }
510
+ const failed = batch.find(({ summary }) => summary.id === answer.failedEntry) ?? first;
464
511
  switch (answer.outcome) {
465
- case "published":
466
- result.published += 1;
467
- out(line("published"));
468
- break;
469
512
  case "needs_acknowledgement":
470
513
  result.needsAcknowledgement += 1;
471
- out(line("needs_acknowledgement", (answer.verdict?.warnings ?? []).map(violationLine)));
514
+ out(line(failed, "needs_acknowledgement", (answer.verdict?.warnings ?? []).map(violationLine)));
472
515
  break;
473
516
  case "blocked":
474
517
  result.blocked += 1;
475
- out(line("blocked", (answer.verdict?.errors ?? []).map(violationLine)));
518
+ out(line(failed, "blocked", (answer.verdict?.errors ?? []).map(violationLine)));
476
519
  break;
477
520
  case "conflict":
478
521
  result.other += 1;
479
- out(line("conflict", ["someone changed the entry after it was imported"]));
522
+ out(line(failed, "conflict", ["someone changed the entry after it was imported"]));
480
523
  break;
481
524
  case "not_found":
482
525
  result.other += 1;
483
- out(line("not_found", ["the entry is gone"]));
526
+ out(line(failed, "not_found", ["the entry is gone"]));
484
527
  }
485
- } catch (error) {
486
- if (!(error instanceof ToolFailure)) throw error;
487
- result.other += 1;
488
- out(line("failed", [error.message]));
528
+ pending = pending.filter((imported) => imported !== failed);
489
529
  }
490
530
  }
491
531
  return result;
package/dist/index.mjs CHANGED
@@ -16,10 +16,22 @@ const COMMANDS = [
16
16
  name: "migrate",
17
17
  summary: "Apply D1 migrations"
18
18
  },
19
+ {
20
+ name: "deploy",
21
+ summary: "Deploy to Cloudflare and migrate the remote database"
22
+ },
23
+ {
24
+ name: "doctor",
25
+ summary: "Check the project, its bindings, its database and its owner"
26
+ },
19
27
  {
20
28
  name: "token",
21
29
  summary: "Create, list and revoke personal tokens"
22
30
  },
31
+ {
32
+ name: "invite",
33
+ summary: "Invite a person to the admin, as an editor or an author"
34
+ },
23
35
  {
24
36
  name: "import",
25
37
  summary: "Import Markdown files over MCP"
@@ -41,7 +53,8 @@ const USAGE = [
41
53
  " --version Print the version",
42
54
  "",
43
55
  "Run \"hollware <command> --help\" for the options of a command.",
44
- "init, migrate and token work on the project in the current directory, through its wrangler."
56
+ "init, migrate, deploy, doctor, token and invite work on the project in the current directory,",
57
+ "through its wrangler."
45
58
  ].join("\n");
46
59
  const TOKEN_COMMANDS = [
47
60
  "create",
@@ -71,7 +84,10 @@ async function main(argv, io) {
71
84
  if (![
72
85
  "init",
73
86
  "migrate",
87
+ "deploy",
88
+ "doctor",
74
89
  "token",
90
+ "invite",
75
91
  "import"
76
92
  ].includes(first)) {
77
93
  io.err(`hollware: unknown command or flag "${printable(first)}"\n\n${USAGE}`);
@@ -89,10 +105,13 @@ async function main(argv, io) {
89
105
  }
90
106
  async function run(command, args, io) {
91
107
  switch (command) {
92
- case "init": return (await import("./init-Cl9Epsbp.mjs")).init(args, io);
93
- case "migrate": return (await import("./migrate-CLHWuUje.mjs")).migrate(args, io);
94
- case "token": return (await import("./token-BLkrPLio.mjs")).token(args, io);
95
- default: return (await import("./import-Dlpn_w3s.mjs")).importContent(args, io);
108
+ case "init": return (await import("./init-D-q1JzcO.mjs")).init(args, io);
109
+ case "migrate": return (await import("./migrate-R7NAXgQT.mjs")).migrate(args, io);
110
+ case "deploy": return (await import("./deploy-B89D8gdO.mjs")).deploy(args, io);
111
+ case "doctor": return (await import("./doctor-Djx_3mLE.mjs")).doctor(args, io);
112
+ case "token": return (await import("./token-DeVLRP0z.mjs")).token(args, io);
113
+ case "invite": return (await import("./invite-PpZ5QNLx.mjs")).invite(args, io);
114
+ default: return (await import("./import-DG8AABZQ.mjs")).importContent(args, io);
96
115
  }
97
116
  }
98
117
  function isEntryPoint() {
@@ -0,0 +1,189 @@
1
+ import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
2
+ import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
+ import { _ as parseSiteKey, a as runFile, c as insertInvitation, f as ownerQuery, h as parseOwnerName, i as resolveDatabase, n as describeDatabase, o as anotherSiteScript, r as query, s as initScript, t as chooseTarget } from "./d1-D5A6OVDD.mjs";
4
+ import { n as setupLink, t as publicUrlOf } from "./public-url-DURHzCaC.mjs";
5
+ import { i as newPersonalToken, n as newId, r as newInvitationSecret, t as inDays } from "./entropy-5uxirwFD.mjs";
6
+ import { rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { PERSONAL_TOKEN_TTL_DAYS, SCOPES, hashPersonalToken } from "@hollware/core";
9
+ //#region src/commands/init.ts
10
+ const INIT_USAGE = [
11
+ "Usage: hollware init --site <key> [--local | --remote] [--owner-name <name>] [--database <name>] [--url <url>]",
12
+ "",
13
+ "Creates a site, its owner and the owner's first personal token in the project's D1 database,",
14
+ "in one step, and prints the token once, with a link to set up the passkey of the admin (valid",
15
+ "for 7 days). On a database that has an owner already, it adds the site to that owner, who signs",
16
+ "in to the admin as before, and prints a token of the new site. It does nothing when the site",
17
+ "has an owner.",
18
+ "Run \"hollware migrate\" first: the database needs its tables.",
19
+ "",
20
+ "Options:",
21
+ " --site <key> The site, by the key it has in hollware.config.ts (required)",
22
+ " --local Use the local database (the default)",
23
+ " --remote Use the database in Cloudflare",
24
+ " --owner-name <name> The name of the owner",
25
+ " --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
26
+ " --url <url> The public URL of Hollware, for the link (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc)",
27
+ " --help Show this help"
28
+ ].join("\n");
29
+ /** The label of the token `init` creates. */
30
+ const FIRST_TOKEN_LABEL = "hollware init";
31
+ async function init(argv, io) {
32
+ const { values } = parseFlags({
33
+ args: [...argv],
34
+ allowPositionals: false,
35
+ options: {
36
+ site: { type: "string" },
37
+ local: { type: "boolean" },
38
+ remote: { type: "boolean" },
39
+ "owner-name": { type: "string" },
40
+ database: { type: "string" },
41
+ url: { type: "string" },
42
+ help: { type: "boolean" }
43
+ }
44
+ });
45
+ if (values.help === true) {
46
+ io.out(INIT_USAGE);
47
+ return 0;
48
+ }
49
+ if (values.site === void 0) throw new UsageError("--site is required: name the site, as in hollware.config.ts.");
50
+ const site = parseSiteKey(values.site);
51
+ const ownerName = values["owner-name"] === void 0 ? void 0 : parseOwnerName(values["owner-name"]);
52
+ const target = chooseTarget(values);
53
+ const database = resolveDatabase(io.cwd, values.database);
54
+ const url = publicUrlOf(io.cwd, values.url);
55
+ const [owners = []] = await query(io.wrangler, database, target, ownerQuery());
56
+ const ofSite = owners.find((owner) => owner["site"] === site);
57
+ if (ofSite !== void 0) {
58
+ const id = typeof ofSite["id"] === "string" ? ` (${ofSite["id"]})` : "";
59
+ throw new CliError(`the site "${site}" already has an owner${id}, so there is nothing to set up. Create another token with "hollware token create --site ${site} --label <text>".`);
60
+ }
61
+ const [first] = owners;
62
+ if (first !== void 0 && typeof first["id"] === "string") return await addSite(io, {
63
+ site,
64
+ ownerId: first["id"],
65
+ database,
66
+ target
67
+ });
68
+ const now = io.now();
69
+ const timestamp = now.toISOString();
70
+ const ownerId = newId(io, "user");
71
+ const tokenId = newId(io, "token");
72
+ const secret = newPersonalToken(io);
73
+ const expiresAt = inDays(now, PERSONAL_TOKEN_TTL_DAYS);
74
+ const setupSecret = newInvitationSecret(io);
75
+ const setupExpiresAt = inDays(now, 7);
76
+ const setup = insertInvitation({
77
+ id: newId(io, "invitation"),
78
+ site,
79
+ hash: await hashPersonalToken(setupSecret),
80
+ role: "owner",
81
+ userId: ownerId,
82
+ expiresAt: setupExpiresAt,
83
+ now: timestamp
84
+ });
85
+ const memberId = newId(io, "member");
86
+ const script = initScript({
87
+ site,
88
+ ownerId,
89
+ memberId,
90
+ ownerName,
91
+ token: {
92
+ id: tokenId,
93
+ site,
94
+ userId: ownerId,
95
+ label: FIRST_TOKEN_LABEL,
96
+ hash: await hashPersonalToken(secret),
97
+ scopes: SCOPES,
98
+ expiresAt,
99
+ now: timestamp
100
+ }
101
+ }).concat(`${setup}\n`);
102
+ const directory = io.tempDir();
103
+ try {
104
+ const file = join(directory, "init.sql");
105
+ writeFileSync(file, script, { mode: 384 });
106
+ await runFile(io.wrangler, database, target, file);
107
+ } finally {
108
+ rmSync(directory, {
109
+ recursive: true,
110
+ force: true
111
+ });
112
+ }
113
+ io.out([
114
+ `Hollware is ready on ${describeDatabase(target, database)}.`,
115
+ "",
116
+ `Site ${site}`,
117
+ `Owner ${ownerId}`,
118
+ `Token ${tokenId}`,
119
+ `Scopes ${SCOPES.join(" ")}`,
120
+ `Expires ${expiresAt} (in ${PERSONAL_TOKEN_TTL_DAYS} days)`,
121
+ "",
122
+ "Your personal token. It is shown only once: Hollware keeps just its hash.",
123
+ "",
124
+ secret,
125
+ "",
126
+ `Set up your passkey for the admin, once, by ${setupExpiresAt}:`,
127
+ ` ${setupLink(url, setupSecret)}`,
128
+ "",
129
+ "Next:",
130
+ " Save the token in a file only you can read, then import content:",
131
+ " hollware import <directory> --url <your Hollware URL> --token-file <file>",
132
+ " An MCP client sends it as the header \"Authorization: Bearer <token>\" to <your Hollware URL>/mcp.",
133
+ " More tokens: hollware token create --label <text>"
134
+ ].join("\n"));
135
+ return 0;
136
+ }
137
+ /** `init` for one more site of a deployment whose owner exists: that owner owns the new site too. */
138
+ async function addSite(io, where) {
139
+ const { site, ownerId, database, target } = where;
140
+ const now = io.now();
141
+ const timestamp = now.toISOString();
142
+ const tokenId = newId(io, "token");
143
+ const secret = newPersonalToken(io);
144
+ const expiresAt = inDays(now, PERSONAL_TOKEN_TTL_DAYS);
145
+ const script = anotherSiteScript({
146
+ site,
147
+ ownerId,
148
+ memberId: newId(io, "member"),
149
+ token: {
150
+ id: tokenId,
151
+ site,
152
+ userId: ownerId,
153
+ label: FIRST_TOKEN_LABEL,
154
+ hash: await hashPersonalToken(secret),
155
+ scopes: SCOPES,
156
+ expiresAt,
157
+ now: timestamp
158
+ }
159
+ });
160
+ const directory = io.tempDir();
161
+ try {
162
+ const file = join(directory, "init.sql");
163
+ writeFileSync(file, script, { mode: 384 });
164
+ await runFile(io.wrangler, database, target, file);
165
+ } finally {
166
+ rmSync(directory, {
167
+ recursive: true,
168
+ force: true
169
+ });
170
+ }
171
+ io.out([
172
+ `Added the site "${site}" on ${describeDatabase(target, database)}, owned by the owner of the database.`,
173
+ "",
174
+ `Site ${site}`,
175
+ `Owner ${ownerId}`,
176
+ `Token ${tokenId}`,
177
+ `Scopes ${SCOPES.join(" ")}`,
178
+ `Expires ${expiresAt} (in ${PERSONAL_TOKEN_TTL_DAYS} days)`,
179
+ "",
180
+ "A personal token of the new site. It is shown only once: Hollware keeps just its hash.",
181
+ "",
182
+ secret,
183
+ "",
184
+ "The owner signs in to the admin as before, with the same passkey, and switches to the site."
185
+ ].join("\n"));
186
+ return 0;
187
+ }
188
+ //#endregion
189
+ export { init };
@@ -0,0 +1,88 @@
1
+ import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
2
+ import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
+ import { _ as parseSiteKey, c as insertInvitation, d as ownerOfSiteQuery, i as resolveDatabase, p as parseExpiresInDays, r as query, t as chooseTarget, x as sitesQuery } from "./d1-D5A6OVDD.mjs";
4
+ import { n as setupLink, t as publicUrlOf } from "./public-url-DURHzCaC.mjs";
5
+ import { n as newId, r as newInvitationSecret, t as inDays } from "./entropy-5uxirwFD.mjs";
6
+ import { o as WHERE_OPTIONS, s as chooseSite } from "./token-aUnd79vh.mjs";
7
+ import { hashPersonalToken } from "@hollware/core";
8
+ //#region src/commands/invite.ts
9
+ const INVITE_USAGE = [
10
+ "Usage: hollware invite (--role <editor | author> | --owner) [--days <n>] [--url <url>]",
11
+ " [--site <key>] [--local | --remote] [--database <name>]",
12
+ "",
13
+ "Invites a person to the admin (ADR-026): prints a link, valid once and for 7 days by default,",
14
+ "where they set up a passkey. An editor reads, writes and publishes; an author reads and writes,",
15
+ "and what an author publishes goes through review. With --owner, the link is for the owner of",
16
+ "the site, to set up a passkey: on a deployment that was made before the admin existed, or on a",
17
+ "new device.",
18
+ "",
19
+ "Options:",
20
+ " --role <role> editor or author",
21
+ " --owner A setup link for the owner of the site, instead of a new person",
22
+ " --days <n> Days until the link expires, 1 to 365 (default: 7)",
23
+ " --url <url> The public URL of Hollware (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc)",
24
+ " --site <key> The site (default: the only site of the database)",
25
+ " --local Use the local database (the default)",
26
+ " --remote Use the database in Cloudflare",
27
+ " --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
28
+ " --help Show this help"
29
+ ].join("\n");
30
+ /** The role of `--role`: editor or author, and nothing else. */
31
+ function roleOf(value) {
32
+ if (value === "editor" || value === "author") return value;
33
+ throw new UsageError("--role is required, and it is editor or author.");
34
+ }
35
+ async function invite(argv, io) {
36
+ const { values } = parseFlags({
37
+ args: [...argv],
38
+ allowPositionals: false,
39
+ options: {
40
+ ...WHERE_OPTIONS,
41
+ role: { type: "string" },
42
+ owner: { type: "boolean" },
43
+ days: { type: "string" },
44
+ url: { type: "string" }
45
+ }
46
+ });
47
+ if (values.help === true) {
48
+ io.out(INVITE_USAGE);
49
+ return 0;
50
+ }
51
+ const forOwner = values.owner === true;
52
+ if (forOwner && values.role !== void 0) throw new UsageError("--owner and --role cannot be used together: choose one.");
53
+ const role = forOwner ? "owner" : roleOf(values.role);
54
+ const days = values.days === void 0 ? 7 : parseExpiresInDays(values.days);
55
+ const siteFlag = values.site === void 0 ? void 0 : parseSiteKey(values.site);
56
+ const target = chooseTarget(values);
57
+ const database = resolveDatabase(io.cwd, values.database);
58
+ const url = publicUrlOf(io.cwd, values.url);
59
+ const [siteRows = []] = await query(io.wrangler, database, target, sitesQuery());
60
+ const site = chooseSite(siteRows, siteFlag);
61
+ let userId = null;
62
+ if (forOwner) {
63
+ const [owners = []] = await query(io.wrangler, database, target, ownerOfSiteQuery(site));
64
+ const [owner] = owners;
65
+ if (owner === void 0 || typeof owner["id"] !== "string") throw new CliError(`the site "${site}" has no owner. Run "hollware init" to make one.`);
66
+ userId = owner["id"];
67
+ }
68
+ const now = io.now();
69
+ const secret = newInvitationSecret(io);
70
+ const expiresAt = inDays(now, days);
71
+ await query(io.wrangler, database, target, insertInvitation({
72
+ id: newId(io, "invitation"),
73
+ site,
74
+ hash: await hashPersonalToken(secret),
75
+ role,
76
+ userId,
77
+ expiresAt,
78
+ now: now.toISOString()
79
+ }));
80
+ io.out([
81
+ userId === null ? `Invited an ${role} to the site "${site}". The link works once, until ${expiresAt}:` : `A setup link for the owner (${userId}) of the site "${site}", to set up a passkey. It works once, until ${expiresAt}:`,
82
+ "",
83
+ ` ${setupLink(url, secret)}`
84
+ ].join("\n"));
85
+ return 0;
86
+ }
87
+ //#endregion
88
+ export { invite };
@@ -1,7 +1,7 @@
1
1
  import { r as CliError } from "./format-BpRYEsGB.mjs";
2
2
  import { n as failureReason, r as stripAnsi } from "./wrangler-BLIfwbcu.mjs";
3
3
  import { t as parseFlags } from "./args-CljWh-kZ.mjs";
4
- import { i as resolveDatabase, n as describeDatabase, t as chooseTarget } from "./d1-Bd-sdoDd.mjs";
4
+ import { i as resolveDatabase, n as describeDatabase, t as chooseTarget } from "./d1-D5A6OVDD.mjs";
5
5
  import { existsSync, mkdirSync, statSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  //#region src/commands/migrate.ts
@@ -10,7 +10,8 @@ const MIGRATE_USAGE = [
10
10
  "",
11
11
  "Applies the D1 migrations the project has not applied yet, with wrangler. Before it applies",
12
12
  "anything to the remote database it exports a backup, and it stops if the backup fails. The",
13
- "export can make the database unavailable for a moment; with nothing pending there is no backup.",
13
+ "export can make the database unavailable for a moment; with nothing pending, or on a new",
14
+ "database that has none of the migrations yet, there is no backup.",
14
15
  "",
15
16
  "Options:",
16
17
  " --local Use the local database (the default)",
@@ -19,6 +20,8 @@ const MIGRATE_USAGE = [
19
20
  " --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
20
21
  " --help Show this help"
21
22
  ].join("\n");
23
+ /** The first migration of the server: a database that lacks it has no Hollware tables yet. */
24
+ const FIRST_MIGRATION = "0000_init.sql";
22
25
  /** Where the backups before a remote migration go, below the project: wrangler's own, git-ignored folder. */
23
26
  const BACKUP_DIRECTORY = join(".wrangler", "hollware-backups");
24
27
  async function migrate(argv, io) {
@@ -58,7 +61,8 @@ async function migrate(argv, io) {
58
61
  io.out([`${count} pending on ${where}:`, ...pending.map((name) => ` ${name}`)].join("\n"));
59
62
  throw new CliError(`${count} pending. Run "hollware migrate --${target}" to apply them.`);
60
63
  }
61
- const backup = target === "remote" ? await takeBackup(io, database) : void 0;
64
+ const fresh = pending.includes(FIRST_MIGRATION);
65
+ const backup = target === "remote" && !fresh ? await takeBackup(io, database) : void 0;
62
66
  const applied = await io.wrangler([
63
67
  "d1",
64
68
  "migrations",
@@ -115,4 +119,4 @@ function timestamp(date) {
115
119
  return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
116
120
  }
117
121
  //#endregion
118
- export { migrate };
122
+ export { parsePendingMigrations as a, migrate as i, FIRST_MIGRATION as n, MIGRATE_USAGE as r, BACKUP_DIRECTORY as t };
@@ -0,0 +1,2 @@
1
+ import { i as migrate } from "./migrate-D6Sgxnwx.mjs";
2
+ export { migrate };
@@ -0,0 +1,32 @@
1
+ import { i as UsageError } from "./format-BpRYEsGB.mjs";
2
+ import { S as parseJsonc } from "./d1-D5A6OVDD.mjs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ //#region src/public-url.ts
6
+ /**
7
+ * The public URL of the project's Hollware, for the links the CLI prints: `--url` when given,
8
+ * else `vars.HOLLWARE_PUBLIC_URL` of the project's `wrangler.jsonc`, else `undefined`.
9
+ */
10
+ function publicUrlOf(cwd, flag) {
11
+ if (flag !== void 0) {
12
+ if (!URL.canParse(flag)) throw new UsageError(`--url is no URL: ${flag}`);
13
+ return new URL(flag).origin;
14
+ }
15
+ const file = ["wrangler.jsonc", "wrangler.json"].map((name) => join(cwd, name)).find(existsSync);
16
+ if (file === void 0) return void 0;
17
+ let config;
18
+ try {
19
+ config = parseJsonc(readFileSync(file, "utf8"));
20
+ } catch {
21
+ return;
22
+ }
23
+ const vars = typeof config === "object" && config !== null && "vars" in config ? config.vars : void 0;
24
+ const url = typeof vars === "object" && vars !== null && "HOLLWARE_PUBLIC_URL" in vars ? vars.HOLLWARE_PUBLIC_URL : void 0;
25
+ return typeof url === "string" && URL.canParse(url) ? new URL(url).origin : void 0;
26
+ }
27
+ /** The link that lets a person set up a passkey with an invitation. */
28
+ function setupLink(url, secret) {
29
+ return `${url ?? "<your Hollware URL>"}/setup?invitation=${secret}`;
30
+ }
31
+ //#endregion
32
+ export { setupLink as n, publicUrlOf as t };
@@ -0,0 +1,2 @@
1
+ import { c as token } from "./token-aUnd79vh.mjs";
2
+ export { token };
@@ -1,7 +1,7 @@
1
1
  import { i as UsageError, n as table, r as CliError, t as printable } from "./format-BpRYEsGB.mjs";
2
2
  import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
- import { _ as sitesAndOwnerQuery, c as listTokens, d as parseLabel, g as revokeToken, h as parseTokenId, i as resolveDatabase, m as parseSiteKey, p as parseScopes, r as query, s as insertToken, t as chooseTarget, u as parseExpiresInDays, v as sitesQuery } from "./d1-Bd-sdoDd.mjs";
4
- import { n as newId, r as newPersonalToken, t as inDays } from "./entropy-CIYbTHkj.mjs";
3
+ import { _ as parseSiteKey, b as sitesAndOwnerQuery, g as parseScopes, i as resolveDatabase, l as insertToken, m as parseLabel, p as parseExpiresInDays, r as query, t as chooseTarget, u as listTokens, v as parseTokenId, x as sitesQuery, y as revokeToken } from "./d1-D5A6OVDD.mjs";
4
+ import { a as newReadToken, i as newPersonalToken, n as newId, t as inDays } from "./entropy-5uxirwFD.mjs";
5
5
  import { PERSONAL_TOKEN_TTL_DAYS, hashPersonalToken } from "@hollware/core";
6
6
  //#region src/commands/token.ts
7
7
  const WHERE_FLAGS = [
@@ -22,13 +22,16 @@ const TOKEN_USAGE = [
22
22
  "Run \"hollware token <command> --help\" for the options of a command."
23
23
  ].join("\n");
24
24
  const TOKEN_CREATE_USAGE = [
25
- "Usage: hollware token create --label <text> [--scopes <list>] [--expires-in-days <n>]",
25
+ "Usage: hollware token create --label <text> [--scopes <list> | --read] [--expires-in-days <n>]",
26
26
  " [--site <key>] [--local | --remote] [--database <name>]",
27
27
  "",
28
28
  "Creates a personal token for the site's owner and prints it once; Hollware keeps only its hash.",
29
+ "With --read, a read token (hw_read_): it reads the published content of the site over the read",
30
+ "API (GET /v1/...) and nothing else, so a site may use it at build time or in the browser.",
29
31
  "",
30
32
  "Options:",
31
33
  " --label <text> What the token is for, 1 to 64 characters (required)",
34
+ " --read A read token: content:read, published content, the read API only",
32
35
  " --scopes <list> Comma-separated: content:read, content:write, content:publish, schema:admin",
33
36
  " (default: content:read,content:write)",
34
37
  ` --expires-in-days <n> Days until it expires, 1 to 365 (default: ${PERSONAL_TOKEN_TTL_DAYS})`,
@@ -80,6 +83,7 @@ async function createToken(argv, io) {
80
83
  ...WHERE_OPTIONS,
81
84
  label: { type: "string" },
82
85
  scopes: { type: "string" },
86
+ read: { type: "boolean" },
83
87
  "expires-in-days": { type: "string" }
84
88
  }
85
89
  });
@@ -87,10 +91,12 @@ async function createToken(argv, io) {
87
91
  io.out(TOKEN_CREATE_USAGE);
88
92
  return 0;
89
93
  }
94
+ const read = values.read === true;
95
+ if (read && values.scopes !== void 0) throw new UsageError("--read and --scopes cannot be used together: a read token only reads.");
90
96
  if (values.label === void 0) throw new UsageError("--label is required: say what the token is for, such as \"ci\" or \"claude-code\".");
91
97
  const label = parseLabel(values.label);
92
98
  const siteFlag = values.site === void 0 ? void 0 : parseSiteKey(values.site);
93
- const scopes = values.scopes === void 0 ? DEFAULT_SCOPES : parseScopes(values.scopes);
99
+ const scopes = read ? ["content:read"] : values.scopes === void 0 ? DEFAULT_SCOPES : parseScopes(values.scopes);
94
100
  const days = values["expires-in-days"] === void 0 ? PERSONAL_TOKEN_TTL_DAYS : parseExpiresInDays(values["expires-in-days"]);
95
101
  const target = chooseTarget(values);
96
102
  const database = resolveDatabase(io.cwd, values.database);
@@ -100,9 +106,10 @@ async function createToken(argv, io) {
100
106
  if (ownerId === void 0) throw new CliError("the database has no owner yet. Run \"hollware init --site <key>\" first.");
101
107
  const now = io.now();
102
108
  const id = newId(io, "token");
103
- const secret = newPersonalToken(io);
109
+ const secret = read ? newReadToken(io) : newPersonalToken(io);
104
110
  const expiresAt = inDays(now, days);
105
111
  await query(io.wrangler, database, target, insertToken({
112
+ ...read ? { kind: "read" } : {},
106
113
  id,
107
114
  site,
108
115
  userId: ownerId,
@@ -113,7 +120,7 @@ async function createToken(argv, io) {
113
120
  now: now.toISOString()
114
121
  }));
115
122
  io.out([
116
- `Created token ${id} for the site "${site}".`,
123
+ `Created ${read ? "read token" : "token"} ${id} for the site "${site}".`,
117
124
  "",
118
125
  `Label ${label}`,
119
126
  `Scopes ${scopes.join(" ")}`,
@@ -225,4 +232,4 @@ function scopesOf(value) {
225
232
  return printable(value);
226
233
  }
227
234
  //#endregion
228
- export { token };
235
+ export { TOKEN_USAGE as a, token as c, TOKEN_REVOKE_USAGE as i, TOKEN_CREATE_USAGE as n, WHERE_OPTIONS as o, TOKEN_LIST_USAGE as r, chooseSite as s, DEFAULT_SCOPES as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hollware",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Hollware CLI: init, migrate, token, import and export.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -31,7 +31,7 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@hollware/core": "0.2.0",
34
+ "@hollware/core": "0.4.0",
35
35
  "@modelcontextprotocol/client": "2.3.1",
36
36
  "zod": "4.6.5"
37
37
  },
@@ -1,17 +0,0 @@
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 };
@@ -1,108 +0,0 @@
1
- import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
2
- import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
- import { a as runFile, f as parseOwnerName, i as resolveDatabase, l as ownerQuery, m as parseSiteKey, n as describeDatabase, o as initScript, r as query, t as chooseTarget } from "./d1-Bd-sdoDd.mjs";
4
- import { n as newId, r as newPersonalToken, t as inDays } from "./entropy-CIYbTHkj.mjs";
5
- import { rmSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { PERSONAL_TOKEN_TTL_DAYS, SCOPES, hashPersonalToken } from "@hollware/core";
8
- //#region src/commands/init.ts
9
- const INIT_USAGE = [
10
- "Usage: hollware init --site <key> [--local | --remote] [--owner-name <name>] [--database <name>]",
11
- "",
12
- "Creates a site, its owner and the owner's first personal token in the project's D1 database,",
13
- "in one step, and prints the token once. It does nothing when the database already has an owner.",
14
- "Run \"hollware migrate\" first: the database needs its tables.",
15
- "",
16
- "Options:",
17
- " --site <key> The site, by the key it has in hollware.config.ts (required)",
18
- " --local Use the local database (the default)",
19
- " --remote Use the database in Cloudflare",
20
- " --owner-name <name> The name of the owner",
21
- " --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
22
- " --help Show this help"
23
- ].join("\n");
24
- /** The label of the token `init` creates. */
25
- const FIRST_TOKEN_LABEL = "hollware init";
26
- async function init(argv, io) {
27
- const { values } = parseFlags({
28
- args: [...argv],
29
- allowPositionals: false,
30
- options: {
31
- site: { type: "string" },
32
- local: { type: "boolean" },
33
- remote: { type: "boolean" },
34
- "owner-name": { type: "string" },
35
- database: { type: "string" },
36
- help: { type: "boolean" }
37
- }
38
- });
39
- if (values.help === true) {
40
- io.out(INIT_USAGE);
41
- return 0;
42
- }
43
- if (values.site === void 0) throw new UsageError("--site is required: name the site, as in hollware.config.ts.");
44
- const site = parseSiteKey(values.site);
45
- const ownerName = values["owner-name"] === void 0 ? void 0 : parseOwnerName(values["owner-name"]);
46
- const target = chooseTarget(values);
47
- const database = resolveDatabase(io.cwd, values.database);
48
- const [owners = []] = await query(io.wrangler, database, target, ownerQuery());
49
- const [existing] = owners;
50
- if (existing !== void 0) {
51
- const id = typeof existing.id === "string" ? ` (${existing.id})` : "";
52
- throw new CliError(`the database already has an owner${id}, so there is nothing to set up. Create another token with "hollware token create --label <text>".`);
53
- }
54
- const now = io.now();
55
- const timestamp = now.toISOString();
56
- const ownerId = newId(io, "user");
57
- const tokenId = newId(io, "token");
58
- const secret = newPersonalToken(io);
59
- const expiresAt = inDays(now, PERSONAL_TOKEN_TTL_DAYS);
60
- const script = initScript({
61
- site,
62
- ownerId,
63
- ownerName,
64
- token: {
65
- id: tokenId,
66
- site,
67
- userId: ownerId,
68
- label: FIRST_TOKEN_LABEL,
69
- hash: await hashPersonalToken(secret),
70
- scopes: SCOPES,
71
- expiresAt,
72
- now: timestamp
73
- }
74
- });
75
- const directory = io.tempDir();
76
- try {
77
- const file = join(directory, "init.sql");
78
- writeFileSync(file, script, { mode: 384 });
79
- await runFile(io.wrangler, database, target, file);
80
- } finally {
81
- rmSync(directory, {
82
- recursive: true,
83
- force: true
84
- });
85
- }
86
- io.out([
87
- `Hollware is ready on ${describeDatabase(target, database)}.`,
88
- "",
89
- `Site ${site}`,
90
- `Owner ${ownerId}`,
91
- `Token ${tokenId}`,
92
- `Scopes ${SCOPES.join(" ")}`,
93
- `Expires ${expiresAt} (in ${PERSONAL_TOKEN_TTL_DAYS} days)`,
94
- "",
95
- "Your personal token. It is shown only once: Hollware keeps just its hash.",
96
- "",
97
- secret,
98
- "",
99
- "Next:",
100
- " Save the token in a file only you can read, then import content:",
101
- " hollware import <directory> --url <your Hollware URL> --token-file <file>",
102
- " An MCP client sends it as the header \"Authorization: Bearer <token>\" to <your Hollware URL>/mcp.",
103
- " More tokens: hollware token create --label <text>"
104
- ].join("\n"));
105
- return 0;
106
- }
107
- //#endregion
108
- export { init };