hollware 0.1.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.
- package/dist/{d1-Bd-sdoDd.mjs → d1-Dobi7CSp.mjs} +32 -13
- package/dist/{entropy-CIYbTHkj.mjs → entropy-CuRnfwXT.mjs} +7 -1
- package/dist/{import-Dlpn_w3s.mjs → import-JnPkrGFE.mjs} +51 -24
- package/dist/index.mjs +11 -5
- package/dist/{init-Cl9Epsbp.mjs → init-BqlTvCVM.mjs} +26 -5
- package/dist/invite-CXNAswZJ.mjs +88 -0
- package/dist/{migrate-CLHWuUje.mjs → migrate-CPtQl8vj.mjs} +1 -1
- package/dist/public-url-CwFcgWHr.mjs +32 -0
- package/dist/token-B9r6vkEy.mjs +2 -0
- package/dist/{token-BLkrPLio.mjs → token-BjTgNwWV.mjs} +3 -3
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
447
|
-
* one left
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
93
|
-
case "migrate": return (await import("./migrate-
|
|
94
|
-
case "token": return (await import("./token-
|
|
95
|
-
|
|
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,
|
|
4
|
-
import { n as newId, r as
|
|
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
|
|
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-
|
|
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 };
|
|
@@ -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
|
|
4
|
-
import {
|
|
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.
|
|
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.
|
|
34
|
+
"@hollware/core": "0.3.0",
|
|
35
35
|
"@modelcontextprotocol/client": "2.3.1",
|
|
36
36
|
"zod": "4.6.5"
|
|
37
37
|
},
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"publint": "0.3.25",
|
|
42
42
|
"typescript": "7.0.2",
|
|
43
43
|
"vite-plus": "1.1.0",
|
|
44
|
-
"wrangler": "4.
|
|
44
|
+
"wrangler": "4.150.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=24.11.0"
|