hollware 0.2.0 → 0.3.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.
@@ -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 an owner: a member of a site with the role owner (ADR-026). */
140
140
  function ownerQuery() {
141
- return "SELECT id FROM users WHERE role = 'owner' LIMIT 1";
141
+ return "SELECT user_id AS id FROM members WHERE role = 'owner' LIMIT 1";
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() {
@@ -153,18 +157,25 @@ function insertToken(token) {
153
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)});`;
154
158
  }
155
159
  /**
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.
160
+ * What `init` writes, in one script: the site (left alone if it is already there), its owner, a
161
+ * member of the site with the role owner, and the owner's first token. `users.role` gets the
162
+ * owner too, the one name of a role that its CHECK and `members` share.
158
163
  */
159
- function initScript({ site, ownerId, ownerName, token }) {
164
+ function initScript({ site, ownerId, memberId, ownerName, token }) {
160
165
  const name = ownerName === void 0 ? "NULL" : quote(ownerName);
161
166
  return [
162
167
  `INSERT INTO sites (id, created_at, updated_at) VALUES (${quote(site)}, ${quote(token.now)}, ${quote(token.now)}) ON CONFLICT (id) DO NOTHING;`,
163
168
  `INSERT INTO users (id, name, role, created_at, updated_at) VALUES (${quote(ownerId)}, ${name}, 'owner', ${quote(token.now)}, ${quote(token.now)});`,
169
+ `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
170
  insertToken(token),
165
171
  ""
166
172
  ].join("\n");
167
173
  }
174
+ /** The row of an invitation (ADR-026): the owner's setup link, or an invitation with a role. */
175
+ function insertInvitation(invitation) {
176
+ const user = invitation.userId === null ? "NULL" : quote(invitation.userId);
177
+ 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)});`;
178
+ }
168
179
  /** The tokens of a site. It never selects the hash. */
169
180
  function listTokens(site) {
170
181
  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 +277,24 @@ function readAnswer(result, database, target) {
266
277
  }
267
278
  throw new CliError(`wrangler could not run the SQL: ${reason}`);
268
279
  }
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
- }
280
+ const json = jsonOf(result.stdout);
281
+ if (json === void 0) throw new CliError(`wrangler did not answer with JSON: ${failureReason(result)}`);
275
282
  const answer = Answer.safeParse(json);
276
283
  if (!answer.success) throw new CliError("wrangler answered with JSON that has an unexpected shape.");
277
284
  if (answer.data.some((statement) => !statement.success)) throw new CliError("wrangler reported that a statement did not succeed.");
278
285
  return answer.data.map((statement) => statement.results);
279
286
  }
287
+ /**
288
+ * The JSON of an answer of `wrangler d1 execute --json`: all of stdout, or, when wrangler printed a
289
+ * warning before it (a config it finds incomplete, an update that is out), what follows the first
290
+ * line that opens the array. `undefined` when there is no JSON. Reading past a warning matters:
291
+ * `hollware init` prints its token only after the SQL answered, so a failure here loses it.
292
+ */
293
+ function jsonOf(stdout) {
294
+ const attempts = [stdout, ...stdout.split("\n").flatMap((line, index, lines) => line.trimStart().startsWith("[") ? [lines.slice(index).join("\n")] : [])];
295
+ for (const text of attempts) try {
296
+ return JSON.parse(text);
297
+ } catch {}
298
+ }
280
299
  //#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 };
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 };
@@ -13,5 +13,11 @@ const DAY_MS = 864e5;
13
13
  function inDays(from, days) {
14
14
  return new Date(from.getTime() + days * DAY_MS).toISOString();
15
15
  }
16
+ /** A new invitation secret (`hw_inv_` and 43 base64url characters) from the random source of `io`. */
17
+ function newInvitationSecret(io) {
18
+ let binary = "";
19
+ for (const byte of io.randomBytes(32)) binary += String.fromCharCode(byte);
20
+ return `hw_inv_${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
21
+ }
16
22
  //#endregion
17
- export { newId as n, newPersonalToken as r, inDays as t };
23
+ export { 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
+ "publish asks for a review of one entry, and refuses a group."
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,76 @@ 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 gets a review of a lone entry
454
+ * (`pending_review`), and its group is refused, which reports every entry of it as failed.
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
+ for (let start = 0; start < entries.length; start += GROUP_SIZE) {
467
+ let pending = entries.slice(start, start + GROUP_SIZE);
468
+ while (pending.length > 0) {
469
+ const [first, ...rest] = pending;
470
+ if (first === void 0) break;
471
+ let answer;
472
+ try {
473
+ answer = await session.call(publish, {
474
+ id: first.summary.id,
475
+ version: first.summary.version,
476
+ group: rest.map(({ summary }) => ({
477
+ id: summary.id,
478
+ version: summary.version
479
+ }))
480
+ });
481
+ } catch (error) {
482
+ 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;
486
+ }
487
+ if (answer.outcome === "published" || answer.outcome === "scheduled") {
488
+ for (const imported of pending) out(line(imported, "published"));
489
+ result.published += pending.length;
490
+ break;
491
+ }
492
+ if (answer.outcome === "pending_review") {
493
+ for (const imported of pending) out(line(imported, "pending_review"));
494
+ result.reviews += pending.length;
495
+ break;
496
+ }
497
+ const failed = pending.find(({ summary }) => summary.id === answer.failedEntry) ?? first;
464
498
  switch (answer.outcome) {
465
- case "published":
466
- result.published += 1;
467
- out(line("published"));
468
- break;
469
499
  case "needs_acknowledgement":
470
500
  result.needsAcknowledgement += 1;
471
- out(line("needs_acknowledgement", (answer.verdict?.warnings ?? []).map(violationLine)));
501
+ out(line(failed, "needs_acknowledgement", (answer.verdict?.warnings ?? []).map(violationLine)));
472
502
  break;
473
503
  case "blocked":
474
504
  result.blocked += 1;
475
- out(line("blocked", (answer.verdict?.errors ?? []).map(violationLine)));
505
+ out(line(failed, "blocked", (answer.verdict?.errors ?? []).map(violationLine)));
476
506
  break;
477
507
  case "conflict":
478
508
  result.other += 1;
479
- out(line("conflict", ["someone changed the entry after it was imported"]));
509
+ out(line(failed, "conflict", ["someone changed the entry after it was imported"]));
480
510
  break;
481
511
  case "not_found":
482
512
  result.other += 1;
483
- out(line("not_found", ["the entry is gone"]));
513
+ out(line(failed, "not_found", ["the entry is gone"]));
484
514
  }
485
- } catch (error) {
486
- if (!(error instanceof ToolFailure)) throw error;
487
- result.other += 1;
488
- out(line("failed", [error.message]));
515
+ pending = pending.filter((imported) => imported !== failed);
489
516
  }
490
517
  }
491
518
  return result;
package/dist/index.mjs CHANGED
@@ -20,6 +20,10 @@ const COMMANDS = [
20
20
  name: "token",
21
21
  summary: "Create, list and revoke personal tokens"
22
22
  },
23
+ {
24
+ name: "invite",
25
+ summary: "Invite a person to the admin, as an editor or an author"
26
+ },
23
27
  {
24
28
  name: "import",
25
29
  summary: "Import Markdown files over MCP"
@@ -41,7 +45,7 @@ const USAGE = [
41
45
  " --version Print the version",
42
46
  "",
43
47
  "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."
48
+ "init, migrate, token and invite work on the project in the current directory, through its wrangler."
45
49
  ].join("\n");
46
50
  const TOKEN_COMMANDS = [
47
51
  "create",
@@ -72,6 +76,7 @@ async function main(argv, io) {
72
76
  "init",
73
77
  "migrate",
74
78
  "token",
79
+ "invite",
75
80
  "import"
76
81
  ].includes(first)) {
77
82
  io.err(`hollware: unknown command or flag "${printable(first)}"\n\n${USAGE}`);
@@ -89,10 +94,11 @@ async function main(argv, io) {
89
94
  }
90
95
  async function run(command, args, io) {
91
96
  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);
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);
96
102
  }
97
103
  }
98
104
  function isEntryPoint() {
@@ -1,16 +1,18 @@
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, 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";
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";
5
6
  import { rmSync, writeFileSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { PERSONAL_TOKEN_TTL_DAYS, SCOPES, hashPersonalToken } from "@hollware/core";
8
9
  //#region src/commands/init.ts
9
10
  const INIT_USAGE = [
10
- "Usage: hollware init --site <key> [--local | --remote] [--owner-name <name>] [--database <name>]",
11
+ "Usage: hollware init --site <key> [--local | --remote] [--owner-name <name>] [--database <name>] [--url <url>]",
11
12
  "",
12
13
  "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
+ "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.",
14
16
  "Run \"hollware migrate\" first: the database needs its tables.",
15
17
  "",
16
18
  "Options:",
@@ -19,6 +21,7 @@ const INIT_USAGE = [
19
21
  " --remote Use the database in Cloudflare",
20
22
  " --owner-name <name> The name of the owner",
21
23
  " --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
24
+ " --url <url> The public URL of Hollware, for the link (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc)",
22
25
  " --help Show this help"
23
26
  ].join("\n");
24
27
  /** The label of the token `init` creates. */
@@ -33,6 +36,7 @@ async function init(argv, io) {
33
36
  remote: { type: "boolean" },
34
37
  "owner-name": { type: "string" },
35
38
  database: { type: "string" },
39
+ url: { type: "string" },
36
40
  help: { type: "boolean" }
37
41
  }
38
42
  });
@@ -45,6 +49,7 @@ async function init(argv, io) {
45
49
  const ownerName = values["owner-name"] === void 0 ? void 0 : parseOwnerName(values["owner-name"]);
46
50
  const target = chooseTarget(values);
47
51
  const database = resolveDatabase(io.cwd, values.database);
52
+ const url = publicUrlOf(io.cwd, values.url);
48
53
  const [owners = []] = await query(io.wrangler, database, target, ownerQuery());
49
54
  const [existing] = owners;
50
55
  if (existing !== void 0) {
@@ -57,9 +62,22 @@ async function init(argv, io) {
57
62
  const tokenId = newId(io, "token");
58
63
  const secret = newPersonalToken(io);
59
64
  const expiresAt = inDays(now, PERSONAL_TOKEN_TTL_DAYS);
65
+ const setupSecret = newInvitationSecret(io);
66
+ const setupExpiresAt = inDays(now, 7);
67
+ const setup = insertInvitation({
68
+ id: newId(io, "invitation"),
69
+ site,
70
+ hash: await hashPersonalToken(setupSecret),
71
+ role: "owner",
72
+ userId: ownerId,
73
+ expiresAt: setupExpiresAt,
74
+ now: timestamp
75
+ });
76
+ const memberId = newId(io, "member");
60
77
  const script = initScript({
61
78
  site,
62
79
  ownerId,
80
+ memberId,
63
81
  ownerName,
64
82
  token: {
65
83
  id: tokenId,
@@ -71,7 +89,7 @@ async function init(argv, io) {
71
89
  expiresAt,
72
90
  now: timestamp
73
91
  }
74
- });
92
+ }).concat(`${setup}\n`);
75
93
  const directory = io.tempDir();
76
94
  try {
77
95
  const file = join(directory, "init.sql");
@@ -96,6 +114,9 @@ async function init(argv, io) {
96
114
  "",
97
115
  secret,
98
116
  "",
117
+ `Set up your passkey for the admin, once, by ${setupExpiresAt}:`,
118
+ ` ${setupLink(url, setupSecret)}`,
119
+ "",
99
120
  "Next:",
100
121
  " Save the token in a file only you can read, then import content:",
101
122
  " hollware import <directory> --url <your Hollware URL> --token-file <file>",
@@ -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 { 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";
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-Dobi7CSp.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
@@ -0,0 +1,32 @@
1
+ import { i as UsageError } from "./format-BpRYEsGB.mjs";
2
+ import { x as parseJsonc } from "./d1-Dobi7CSp.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-BjTgNwWV.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 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";
5
5
  import { PERSONAL_TOKEN_TTL_DAYS, hashPersonalToken } from "@hollware/core";
6
6
  //#region src/commands/token.ts
7
7
  const WHERE_FLAGS = [
@@ -225,4 +225,4 @@ function scopesOf(value) {
225
225
  return printable(value);
226
226
  }
227
227
  //#endregion
228
- export { token };
228
+ 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.3.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.3.0",
35
35
  "@modelcontextprotocol/client": "2.3.1",
36
36
  "zod": "4.6.5"
37
37
  },