hollware 0.3.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,9 +136,9 @@ 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: a member of a site with the role owner (ADR-026). */
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 user_id AS id FROM members 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() {
@@ -154,7 +154,8 @@ function sitesQuery() {
154
154
  }
155
155
  /** The row of a new token. */
156
156
  function insertToken(token) {
157
- 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]});`;
158
159
  }
159
160
  /**
160
161
  * What `init` writes, in one script: the site (left alone if it is already there), its owner, a
@@ -171,6 +172,18 @@ function initScript({ site, ownerId, memberId, ownerName, token }) {
171
172
  ""
172
173
  ].join("\n");
173
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)});`,
183
+ insertToken(token),
184
+ ""
185
+ ].join("\n");
186
+ }
174
187
  /** The row of an invitation (ADR-026): the owner's setup link, or an invitation with a role. */
175
188
  function insertInvitation(invitation) {
176
189
  const user = invitation.userId === null ? "NULL" : quote(invitation.userId);
@@ -297,4 +310,4 @@ function jsonOf(stdout) {
297
310
  } catch {}
298
311
  }
299
312
  //#endregion
300
- export { parseTokenId as _, runFile as a, sitesQuery as b, insertToken as c, ownerQuery as d, parseExpiresInDays as f, parseSiteKey as g, parseScopes as h, resolveDatabase as i, listTokens as l, parseOwnerName as m, describeDatabase as n, initScript as o, parseLabel as p, query as r, insertInvitation as s, chooseTarget as t, ownerOfSiteQuery as u, revokeToken as v, parseJsonc as x, sitesAndOwnerQuery as y };
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 };
@@ -1,4 +1,4 @@
1
- import { ID_PREFIXES, generatePersonalToken, ulid } from "@hollware/core";
1
+ import { ID_PREFIXES, generatePersonalToken, generateReadToken, ulid } from "@hollware/core";
2
2
  //#region src/entropy.ts
3
3
  /** A new id of a kind, such as `usr_01JAB…`, from the clock and the random source of `io`. */
4
4
  function newId(io, kind) {
@@ -8,6 +8,10 @@ function newId(io, kind) {
8
8
  function newPersonalToken(io) {
9
9
  return generatePersonalToken(io.randomBytes(52));
10
10
  }
11
+ /** A new read token (`hw_read_`), from the same randomness. */
12
+ function newReadToken(io) {
13
+ return generateReadToken(io.randomBytes(52));
14
+ }
11
15
  const DAY_MS = 864e5;
12
16
  /** The moment, in ISO 8601 UTC, that is `days` days after `from`. */
13
17
  function inDays(from, days) {
@@ -20,4 +24,4 @@ function newInvitationSecret(io) {
20
24
  return `hw_inv_${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
21
25
  }
22
26
  //#endregion
23
- export { newPersonalToken as i, newId as n, newInvitationSecret as r, inDays as t };
27
+ export { newReadToken as a, newPersonalToken as i, newId as n, newInvitationSecret as r, inDays as t };
@@ -196,7 +196,7 @@ const IMPORT_USAGE = [
196
196
  " --help Show this help",
197
197
  "",
198
198
  "The token needs content:read and content:write, and content:publish for --publish: without it,",
199
- "publish asks for a review of one entry, and refuses a group."
199
+ "each entry asks for a review, which a person who may publish approves in the admin."
200
200
  ].join("\n");
201
201
  async function importContent(argv, io) {
202
202
  const { values, positionals } = parseFlags({
@@ -450,8 +450,8 @@ const GROUP_SIZE = 100;
450
450
  * one left them, as one group (in groups of 100), so that entries that link to each other are
451
451
  * published together (ADR-024). When an entry stops its group, it is reported and taken out, and
452
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 gets a review of a lone entry
454
- * (`pending_review`), and its group is refused, which reports every entry of it as failed.
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.
455
455
  */
456
456
  async function publishAll(session, entries, out) {
457
457
  const result = {
@@ -463,38 +463,51 @@ async function publishAll(session, entries, out) {
463
463
  };
464
464
  const labelOf = ({ file }) => printable(file.path.replace(/\.md$/, ""));
465
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;
466
471
  for (let start = 0; start < entries.length; start += GROUP_SIZE) {
467
472
  let pending = entries.slice(start, start + GROUP_SIZE);
468
473
  while (pending.length > 0) {
469
474
  const [first, ...rest] = pending;
470
475
  if (first === void 0) break;
476
+ const batch = alone ? [first] : pending;
471
477
  let answer;
472
478
  try {
473
479
  answer = await session.call(publish, {
474
480
  id: first.summary.id,
475
481
  version: first.summary.version,
476
- group: rest.map(({ summary }) => ({
482
+ ...alone ? {} : { group: rest.map(({ summary }) => ({
477
483
  id: summary.id,
478
484
  version: summary.version
479
- }))
485
+ })) }
480
486
  });
481
487
  } catch (error) {
482
488
  if (!(error instanceof ToolFailure)) throw error;
483
- for (const imported of pending) out(line(imported, "failed", [error.message]));
484
- result.other += pending.length;
485
- break;
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;
486
497
  }
487
498
  if (answer.outcome === "published" || answer.outcome === "scheduled") {
488
- for (const imported of pending) out(line(imported, "published"));
489
- result.published += pending.length;
490
- break;
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;
491
503
  }
492
504
  if (answer.outcome === "pending_review") {
493
- for (const imported of pending) out(line(imported, "pending_review"));
494
- result.reviews += pending.length;
495
- break;
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;
496
509
  }
497
- const failed = pending.find(({ summary }) => summary.id === answer.failedEntry) ?? first;
510
+ const failed = batch.find(({ summary }) => summary.id === answer.failedEntry) ?? first;
498
511
  switch (answer.outcome) {
499
512
  case "needs_acknowledgement":
500
513
  result.needsAcknowledgement += 1;
package/dist/index.mjs CHANGED
@@ -16,6 +16,14 @@ 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"
@@ -45,7 +53,8 @@ const USAGE = [
45
53
  " --version Print the version",
46
54
  "",
47
55
  "Run \"hollware <command> --help\" for the options of a command.",
48
- "init, migrate, token and invite 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."
49
58
  ].join("\n");
50
59
  const TOKEN_COMMANDS = [
51
60
  "create",
@@ -75,6 +84,8 @@ async function main(argv, io) {
75
84
  if (![
76
85
  "init",
77
86
  "migrate",
87
+ "deploy",
88
+ "doctor",
78
89
  "token",
79
90
  "invite",
80
91
  "import"
@@ -94,11 +105,13 @@ async function main(argv, io) {
94
105
  }
95
106
  async function run(command, args, io) {
96
107
  switch (command) {
97
- case "init": return (await import("./init-BqlTvCVM.mjs")).init(args, io);
98
- case "migrate": return (await import("./migrate-CPtQl8vj.mjs")).migrate(args, io);
99
- case "token": return (await import("./token-B9r6vkEy.mjs")).token(args, io);
100
- case "invite": return (await import("./invite-CXNAswZJ.mjs")).invite(args, io);
101
- default: return (await import("./import-JnPkrGFE.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);
102
115
  }
103
116
  }
104
117
  function isEntryPoint() {
@@ -1,8 +1,8 @@
1
1
  import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
2
2
  import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
- import { a as runFile, d as ownerQuery, g as parseSiteKey, i as resolveDatabase, m as parseOwnerName, n as describeDatabase, o as initScript, r as query, s as insertInvitation, t as chooseTarget } from "./d1-Dobi7CSp.mjs";
4
- import { i as newPersonalToken, n as newId, r as newInvitationSecret, t as inDays } from "./entropy-CuRnfwXT.mjs";
5
- import { n as setupLink, t as publicUrlOf } from "./public-url-CwFcgWHr.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
6
  import { rmSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { PERSONAL_TOKEN_TTL_DAYS, SCOPES, hashPersonalToken } from "@hollware/core";
@@ -12,7 +12,9 @@ const INIT_USAGE = [
12
12
  "",
13
13
  "Creates a site, its owner and the owner's first personal token in the project's D1 database,",
14
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). It does nothing when the database already has an owner.",
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.",
16
18
  "Run \"hollware migrate\" first: the database needs its tables.",
17
19
  "",
18
20
  "Options:",
@@ -51,11 +53,18 @@ async function init(argv, io) {
51
53
  const database = resolveDatabase(io.cwd, values.database);
52
54
  const url = publicUrlOf(io.cwd, values.url);
53
55
  const [owners = []] = await query(io.wrangler, database, target, ownerQuery());
54
- const [existing] = owners;
55
- if (existing !== void 0) {
56
- const id = typeof existing.id === "string" ? ` (${existing.id})` : "";
57
- 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>".`);
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>".`);
58
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
+ });
59
68
  const now = io.now();
60
69
  const timestamp = now.toISOString();
61
70
  const ownerId = newId(io, "user");
@@ -125,5 +134,56 @@ async function init(argv, io) {
125
134
  ].join("\n"));
126
135
  return 0;
127
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
+ }
128
188
  //#endregion
129
189
  export { init };
@@ -1,9 +1,9 @@
1
1
  import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
2
2
  import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
- import { b as sitesQuery, f as parseExpiresInDays, g as parseSiteKey, i as resolveDatabase, r as query, s as insertInvitation, t as chooseTarget, u as ownerOfSiteQuery } from "./d1-Dobi7CSp.mjs";
4
- import { n as newId, r as newInvitationSecret, t as inDays } from "./entropy-CuRnfwXT.mjs";
5
- import { n as setupLink, t as publicUrlOf } from "./public-url-CwFcgWHr.mjs";
6
- import { o as WHERE_OPTIONS, s as chooseSite } from "./token-BjTgNwWV.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
7
  import { hashPersonalToken } from "@hollware/core";
8
8
  //#region src/commands/invite.ts
9
9
  const INVITE_USAGE = [
@@ -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-Dobi7CSp.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 };
@@ -1,5 +1,5 @@
1
1
  import { i as UsageError } from "./format-BpRYEsGB.mjs";
2
- import { x as parseJsonc } from "./d1-Dobi7CSp.mjs";
2
+ import { S as parseJsonc } from "./d1-D5A6OVDD.mjs";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  //#region src/public-url.ts
@@ -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 parseTokenId, b as sitesQuery, c as insertToken, f as parseExpiresInDays, g as parseSiteKey, h as parseScopes, i as resolveDatabase, l as listTokens, p as parseLabel, r as query, t as chooseTarget, v as revokeToken, y as sitesAndOwnerQuery } from "./d1-Dobi7CSp.mjs";
4
- import { i as newPersonalToken, n as newId, t as inDays } from "./entropy-CuRnfwXT.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(" ")}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hollware",
3
- "version": "0.3.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.3.0",
34
+ "@hollware/core": "0.4.0",
35
35
  "@modelcontextprotocol/client": "2.3.1",
36
36
  "zod": "4.6.5"
37
37
  },
@@ -1,2 +0,0 @@
1
- import { c as token } from "./token-BjTgNwWV.mjs";
2
- export { token };