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