hollware 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  The command line of [Hollware](https://github.com/hollware/hollware), an open-source headless
4
4
  CMS for the agent era, native to Cloudflare Workers.
5
5
 
6
- > **Status: pre-alpha.** `init`, `migrate`, `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
6
+ > **Status: pre-alpha.** `init`, `migrate`, `deploy`, `doctor`, `token`, `invite`, `import` and
7
+ > `export` 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
@@ -17,12 +17,25 @@ CMS for the agent era, native to Cloudflare Workers.
17
17
  | `doctor` | Check the project, its bindings, its database and its owner, with what to fix |
18
18
  | `token` | Create, list and revoke personal tokens, and read tokens with `--read` |
19
19
  | `invite` | Invite a person to the admin, as an editor or an author |
20
- | `import` | Import Markdown files over MCP, as drafts, and publish them with `--publish` |
21
- | `export` | Export entries as Markdown files (v0.2) |
20
+ | `import` | Import Markdown over MCP, as drafts; from a Nuxt Content site with `--from` |
21
+ | `export` | Export entries over MCP as Markdown files, the layout `import` reads |
22
22
 
23
23
  Every command answers `--help`. A command that fails prints one message on stderr, with what to
24
24
  do, and exits with code 1.
25
25
 
26
+ ### Moving a site to Hollware
27
+
28
+ `import --from nuxt-content` reads the content directory of a Nuxt Content site as the files Hollware
29
+ imports, and validates them all before it writes anything:
30
+
31
+ ```sh
32
+ # With --locale-dirs, content/<locale>/… and the translations of each page
33
+ npx hollware import ./content --from nuxt-content --collection docs --locale-dirs --url https://cms.example.com --token-file ./token.txt
34
+ ```
35
+
36
+ A page keeps its Markdown byte for byte (Comark reads MDC), with its slug as Nuxt Content routes
37
+ it and a `title` from its first heading when it has none.
38
+
26
39
  ## A first run
27
40
 
28
41
  In a Hollware project (the starter has the D1 binding `DB` in its `wrangler.jsonc`):
@@ -59,11 +72,25 @@ again changes nothing: every entry answers `unchanged`. With `--publish` it publ
59
72
  entries that have unpublished changes, and it acknowledges no warning: an entry with warnings is
60
73
  reported, and the command exits with code 1. The token never appears in its output.
61
74
 
75
+ `export` is its mirror: it writes the entries of the site of the token, as
76
+ `<collection>/<locale>/<slug>.md`, into a directory that must be empty or not exist:
77
+
78
+ ```sh
79
+ hollware export ./backup --url https://cms.example.com --token-file ./token.txt
80
+ hollware export ./live --url https://cms.example.com --token-file ./token.txt --published
81
+ ```
82
+
83
+ Each file is the revision as Hollware keeps it, byte for byte, so importing an export into the
84
+ same site changes nothing. By default it writes the latest revision of every entry, drafts
85
+ included; `--published` writes only the published entries, as they are published. Entries in
86
+ the trash are left out. `--collection` and `--locale` narrow it (repeat them for several),
87
+ `--dry-run` lists the files and writes nothing, and a token with `content:read` is enough.
88
+
62
89
  ## How it works
63
90
 
64
91
  `init`, `migrate`, `deploy`, `doctor` and `token` run the project's own `wrangler` (the nearest
65
92
  `node_modules/.bin/wrangler`) against its D1 database, and read the binding from `wrangler.jsonc`.
66
- `import` speaks MCP to `<url>/mcp` with the token as a bearer token. The structure of the sites
93
+ `import` and `export` speak MCP to `<url>/mcp` with the token as a bearer token. The structure of the sites
67
94
  lives in the database (ADR-033): `init` gives a new site its first definition from a preset, and
68
95
  its owner shapes it in the admin, or an agent over MCP.
69
96
 
@@ -155,7 +155,7 @@ function sitesQuery() {
155
155
  /** The row of a new token. */
156
156
  function insertToken(token) {
157
157
  const kind = token.kind === void 0 ? ["", ""] : [", kind", `, ${quote(token.kind)}`];
158
- return `INSERT INTO tokens (id, site_id, user_id, label, hash, scopes, expires_at, created_at, updated_at${kind[0]}) VALUES (${quote(token.id)}, ${quote(token.site)}, ${quote(token.userId)}, ${quote(token.label)}, ${quote(token.hash)}, ${quote(JSON.stringify(token.scopes))}, ${quote(token.expiresAt)}, ${quote(token.now)}, ${quote(token.now)}${kind[1]});`;
158
+ 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))}, ${token.expiresAt === null ? "NULL" : quote(token.expiresAt)}, ${quote(token.now)}, ${quote(token.now)}${kind[1]});`;
159
159
  }
160
160
  /** The row of the site: with a definition, given only to a site that has none yet. */
161
161
  function siteRow(site, now, structure) {
@@ -1,9 +1,9 @@
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 { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-CK4nF7p5.mjs";
5
- import { t as publicUrlOf } from "./public-url-NLPRzxCN.mjs";
6
- import { i as migrate } from "./migrate-BBfF-mkE.mjs";
4
+ import { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-BGChD8_u.mjs";
5
+ import { t as publicUrlOf } from "./public-url-B2BXf_pj.mjs";
6
+ import { i as migrate } from "./migrate-BqdgcoRJ.mjs";
7
7
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  //#region src/commands/deploy.ts
@@ -1,8 +1,8 @@
1
1
  import { r as CliError } from "./format-BpRYEsGB.mjs";
2
2
  import { n as failureReason } from "./wrangler-BLIfwbcu.mjs";
3
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-CK4nF7p5.mjs";
5
- import { a as parsePendingMigrations } from "./migrate-BBfF-mkE.mjs";
4
+ import { S as parseJsonc, f as ownerQuery, r as query, t as chooseTarget } from "./d1-BGChD8_u.mjs";
5
+ import { a as parsePendingMigrations } from "./migrate-BqdgcoRJ.mjs";
6
6
  import { existsSync, readFileSync, readdirSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  //#region src/commands/doctor.ts
@@ -0,0 +1,234 @@
1
+ import { i as UsageError, r as CliError, t as printable } from "./format-BpRYEsGB.mjs";
2
+ import { t as readVersion } from "./version-BMhdZaNo.mjs";
3
+ import { t as parseFlags } from "./args-CljWh-kZ.mjs";
4
+ import { a as redact, c as openSession, i as readToken, o as withoutToken, r as mcpEndpoint, s as ToolFailure } from "./import-D9LRrYtb.mjs";
5
+ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { describeSite, getEntry, listEntries, schemas } from "@hollware/core";
8
+ //#region src/commands/export.ts
9
+ const EXPORT_USAGE = [
10
+ "Usage: hollware export <directory> --url <hollware url> (--token-file <file> | token on stdin)",
11
+ " [--site <key>] [--collection <name>]... [--locale <code>]...",
12
+ " [--published] [--dry-run]",
13
+ "",
14
+ "Writes the entries of a running Hollware, read over MCP, as Markdown files at",
15
+ "<directory>/<collection>/<locale>/<slug>.md: the layout \"hollware import\" reads. Each file is",
16
+ "the revision byte for byte as Hollware keeps it, frontmatter included, so an import of the",
17
+ "export into the same site changes nothing. Entries in the trash are left out. The directory",
18
+ "must be empty or not exist: nothing of yours is overwritten.",
19
+ "",
20
+ "Options:",
21
+ " --url <url> The address of your Hollware, such as https://cms.example.com (MCP is at <url>/mcp)",
22
+ " --token-file <file> A file with a personal token; without it, the token is read from stdin",
23
+ " --site <key> Stop unless the token is of this site (a token reads the one site it is of)",
24
+ " --collection <name> Export only this collection; repeat it for several",
25
+ " --locale <code> Export only this locale; repeat it for several",
26
+ " --published Export only the published entries, as they are published; by default the",
27
+ " latest revision of every entry, drafts included",
28
+ " --dry-run List the files it would write, and write nothing",
29
+ " --help Show this help",
30
+ "",
31
+ "The token needs content:read."
32
+ ].join("\n");
33
+ /** The most entries one `list_entries` answers. */
34
+ const PAGE_SIZE = 100;
35
+ /**
36
+ * A folder name that is safe to write under the directory: no separator, no dot first, nothing a
37
+ * terminal or a file system acts on. Collection names and locales are narrower than this.
38
+ */
39
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
40
+ async function exportContent(argv, io) {
41
+ const { values, positionals } = parseFlags({
42
+ args: [...argv],
43
+ allowPositionals: true,
44
+ options: {
45
+ url: { type: "string" },
46
+ "token-file": { type: "string" },
47
+ site: { type: "string" },
48
+ collection: {
49
+ type: "string",
50
+ multiple: true
51
+ },
52
+ locale: {
53
+ type: "string",
54
+ multiple: true
55
+ },
56
+ published: { type: "boolean" },
57
+ "dry-run": { type: "boolean" },
58
+ help: { type: "boolean" }
59
+ }
60
+ });
61
+ if (values.help === true) {
62
+ io.out(EXPORT_USAGE);
63
+ return 0;
64
+ }
65
+ const [directory, ...extra] = positionals;
66
+ if (directory === void 0 || extra.length > 0) throw new UsageError("export takes one directory: hollware export <directory> --url <url>.");
67
+ if (values.url === void 0) throw new UsageError("--url is required: the address of your Hollware, such as https://cms.example.com.");
68
+ const endpoint = mcpEndpoint(values.url);
69
+ const root = resolve(io.cwd, directory);
70
+ checkTarget(root, directory);
71
+ const token = await readToken(io, values["token-file"]);
72
+ try {
73
+ return await exportEntries({
74
+ io,
75
+ token,
76
+ endpoint,
77
+ directory,
78
+ root,
79
+ site: values.site,
80
+ collections: values.collection,
81
+ locales: values.locale,
82
+ published: values.published === true,
83
+ dryRun: values["dry-run"] === true
84
+ });
85
+ } catch (error) {
86
+ throw withoutToken(error, token);
87
+ }
88
+ }
89
+ /** The directory must be empty or not exist, so that the export overwrites nothing. */
90
+ function checkTarget(root, directory) {
91
+ let names;
92
+ try {
93
+ names = readdirSync(root);
94
+ } catch (error) {
95
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
96
+ if (code === "ENOENT") return;
97
+ if (code === "ENOTDIR") throw new CliError(`${printable(directory)} is not a directory.`);
98
+ throw new CliError(`could not read ${printable(directory)}: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ if (names.length > 0) throw new CliError(`${printable(directory)} is not empty: export into a new or an empty directory, so that no file is overwritten. Nothing was written.`);
101
+ }
102
+ /**
103
+ * Reads everything first, then writes: a call that fails stops the export before any file is
104
+ * written.
105
+ */
106
+ async function exportEntries(plan) {
107
+ const { io, token, directory, root, published, dryRun } = plan;
108
+ const out = (message) => io.out(redact(message, token));
109
+ const session = await openSession(plan.endpoint, token, readVersion());
110
+ let files;
111
+ let siteKey;
112
+ try {
113
+ const { site } = await session.call(describeSite, {});
114
+ siteKey = site.key;
115
+ if (plan.site !== void 0 && plan.site !== site.key) throw new CliError(`the token is of the site ${printable(JSON.stringify(site.key))}, not ${printable(JSON.stringify(plan.site))}: a personal token reads the one site it is of. Use a token of ${printable(plan.site)}.`);
116
+ files = await readAll(session, await listAll(session, chosen("collection", site.collections.map((collection) => collection.name), plan.collections), plan.locales === void 0 ? [void 0] : chosen("locale", site.locales, plan.locales), published), published);
117
+ } finally {
118
+ await session.close();
119
+ }
120
+ const view = published ? "the published revisions" : "the latest revisions, drafts included";
121
+ if (files.length === 0) {
122
+ out(`No entries to export from the site ${printable(siteKey)} (${view}). Nothing was written.`);
123
+ return 0;
124
+ }
125
+ if (dryRun) {
126
+ out(files.map((file) => `would write ${printable(file.path)}`).join("\n"));
127
+ out(`Dry run: ${count(files.length, "entry", "entries")} of the site ${printable(siteKey)} would be written to ${printable(directory)} (${view}):\n${groupSummary(files)}\nNothing was written.`);
128
+ return 0;
129
+ }
130
+ let written = 0;
131
+ for (const file of files) {
132
+ const target = join(root, file.collection, file.locale, `${file.slug}.md`);
133
+ try {
134
+ mkdirSync(dirname(target), { recursive: true });
135
+ writeFileSync(target, file.markdown, { flag: "wx" });
136
+ } catch (error) {
137
+ throw new CliError(`could not write ${printable(file.path)}: ${error instanceof Error ? error.message : String(error)}. ${written} of ${count(files.length, "file")} written: remove ${printable(directory)} and run the export again.`);
138
+ }
139
+ written += 1;
140
+ }
141
+ out(`Exported ${count(files.length, "entry", "entries")} of the site ${printable(siteKey)} to ${printable(directory)} (${view}):\n${groupSummary(files)}`);
142
+ return 0;
143
+ }
144
+ /** The names asked for, each checked against what the site has; all of them when none is. */
145
+ function chosen(kind, available, asked) {
146
+ if (asked === void 0) return [...available];
147
+ const unknown = asked.filter((name) => !available.includes(name));
148
+ if (unknown.length > 0) throw new CliError(`the site has no ${kind} ${unknown.map((name) => printable(JSON.stringify(name))).join(", ")}: it has ${available.map(printable).join(", ")}.`);
149
+ return [...new Set(asked)];
150
+ }
151
+ /**
152
+ * Every entry of the collections, in the locales (`undefined`: every locale), page by page.
153
+ * The head view leaves out the trash; `published` lists only published entries. An entry whose
154
+ * collection, locale or slug cannot be a file name below the directory stops the export.
155
+ */
156
+ async function listAll(session, collections, locales, published) {
157
+ const byPath = /* @__PURE__ */ new Map();
158
+ for (const collection of collections) for (const locale of locales) {
159
+ let cursor;
160
+ do {
161
+ let page;
162
+ try {
163
+ page = await session.call(listEntries, {
164
+ collection,
165
+ ...locale === void 0 ? {} : { locale },
166
+ ...published ? { status: "published" } : {},
167
+ limit: PAGE_SIZE,
168
+ ...cursor === void 0 ? {} : { cursor }
169
+ });
170
+ } catch (error) {
171
+ if (!(error instanceof ToolFailure)) throw error;
172
+ throw new CliError(`could not list ${printable(locale === void 0 ? collection : `${collection}/${locale}`)}: ${sentence(error.message)}. Nothing was written.`);
173
+ }
174
+ for (const summary of page.entries) {
175
+ const path = `${summary.collection}/${summary.locale}/${summary.slug}.md`;
176
+ if (!SAFE_SEGMENT.test(summary.collection) || !SAFE_SEGMENT.test(summary.locale) || !schemas.slug.safeParse(summary.slug).success) throw new CliError(`the server listed an entry that cannot be a file below the directory: ${printable(JSON.stringify(path))}. Nothing was written.`);
177
+ byPath.set(path, {
178
+ collection: summary.collection,
179
+ locale: summary.locale,
180
+ slug: summary.slug,
181
+ path
182
+ });
183
+ }
184
+ cursor = page.nextCursor ?? void 0;
185
+ } while (cursor !== void 0);
186
+ }
187
+ return [...byPath.values()].sort((a, b) => compare(a.collection, b.collection) || compare(a.locale, b.locale) || compare(a.slug, b.slug));
188
+ }
189
+ /** The Markdown of each entry: its latest revision, or the published one. */
190
+ async function readAll(session, listed, published) {
191
+ const files = [];
192
+ for (const item of listed) {
193
+ let answer;
194
+ try {
195
+ answer = await session.call(getEntry, {
196
+ collection: item.collection,
197
+ locale: item.locale,
198
+ slug: item.slug,
199
+ formats: ["markdown"],
200
+ published
201
+ });
202
+ } catch (error) {
203
+ if (!(error instanceof ToolFailure)) throw error;
204
+ throw new CliError(`could not read ${printable(item.path)}: ${sentence(error.message)}. Nothing was written.`);
205
+ }
206
+ const markdown = answer.entry?.markdown;
207
+ if (answer.outcome === "not_found" || markdown === void 0) throw new CliError(`${printable(item.path)} is gone: it was ${published ? "unpublished" : "moved to the trash or renamed"} while the export ran. Nothing was written: run the export again.`);
208
+ files.push({
209
+ ...item,
210
+ markdown
211
+ });
212
+ }
213
+ return files;
214
+ }
215
+ function sentence(message) {
216
+ return message.replace(/[\s.]+$/, "");
217
+ }
218
+ function compare(a, b) {
219
+ return a < b ? -1 : a > b ? 1 : 0;
220
+ }
221
+ function count(n, singular, plural = `${singular}s`) {
222
+ return `${n} ${n === 1 ? singular : plural}`;
223
+ }
224
+ /** One line per collection and locale, with how many entries: ` blog/en: 3`. */
225
+ function groupSummary(files) {
226
+ const sizes = /* @__PURE__ */ new Map();
227
+ for (const file of files) {
228
+ const group = `${file.collection}/${file.locale}`;
229
+ sizes.set(group, (sizes.get(group) ?? 0) + 1);
230
+ }
231
+ return [...sizes].map(([group, size]) => ` ${printable(group)}: ${size}`).join("\n");
232
+ }
233
+ //#endregion
234
+ export { exportContent };
@@ -5,83 +5,6 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { REFUSAL_CONTRACT_IDS, createDraft, getEntry, isPersonalToken, publish, schemas, updateDraft, validate } from "@hollware/core";
7
7
  import { Client, InsufficientScopeError, ProtocolError, SdkError, SdkErrorCode, SdkHttpError, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client";
8
- //#region src/import/corpus.ts
9
- /**
10
- * Reads the entries of a directory laid out as `<collection>/<locale>/<slug>.md`. Only `.md`
11
- * files count. A Markdown file at any other depth is skipped and reported; one at the right
12
- * depth with a slug that is not a slug, or with bytes that are not UTF-8, is a problem. Files
13
- * and folders whose name starts with a dot, and symbolic links, are never read: a link could
14
- * lead out of the directory the person pointed at.
15
- */
16
- function readCorpus(root) {
17
- const corpus = {
18
- files: [],
19
- skipped: [],
20
- problems: []
21
- };
22
- walk(root, [], corpus);
23
- corpus.files.sort((a, b) => compare(a.collection, b.collection) || compare(a.locale, b.locale) || compare(a.slug, b.slug));
24
- corpus.skipped.sort(compare);
25
- corpus.problems.sort((a, b) => compare(a.path, b.path));
26
- return corpus;
27
- }
28
- function compare(a, b) {
29
- return a < b ? -1 : a > b ? 1 : 0;
30
- }
31
- function walk(directory, segments, corpus) {
32
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
33
- if (entry.name.startsWith(".")) continue;
34
- if (entry.isDirectory()) walk(join(directory, entry.name), [...segments, entry.name], corpus);
35
- else if (entry.isFile() && entry.name.endsWith(".md")) readFile(join(directory, entry.name), [...segments, entry.name], corpus);
36
- }
37
- }
38
- function readFile(file, segments, corpus) {
39
- const path = segments.join("/");
40
- const [collection, locale, name] = segments;
41
- if (segments.length !== 3 || collection === void 0 || locale === void 0 || name === void 0) {
42
- corpus.skipped.push(path);
43
- return;
44
- }
45
- const slug = name.slice(0, -3);
46
- if (!schemas.slug.safeParse(slug).success) {
47
- corpus.problems.push({
48
- path,
49
- reason: `${JSON.stringify(slug)} is not a slug: use lowercase letters and digits joined by hyphens, up to 200 characters.`
50
- });
51
- return;
52
- }
53
- let bytes;
54
- try {
55
- bytes = readFileSync(file);
56
- } catch (error) {
57
- corpus.problems.push({
58
- path,
59
- reason: `the file cannot be read: ${error instanceof Error ? error.message : String(error)}`
60
- });
61
- return;
62
- }
63
- let markdown;
64
- try {
65
- markdown = new TextDecoder("utf-8", {
66
- fatal: true,
67
- ignoreBOM: true
68
- }).decode(bytes);
69
- } catch {
70
- corpus.problems.push({
71
- path,
72
- reason: "the file is not valid UTF-8."
73
- });
74
- return;
75
- }
76
- corpus.files.push({
77
- path,
78
- collection,
79
- locale,
80
- slug,
81
- markdown
82
- });
83
- }
84
- //#endregion
85
8
  //#region src/import/session.ts
86
9
  /** A tool failed and said so in words, instead of answering with one of its outcomes. */
87
10
  var ToolFailure = class extends CliError {
@@ -178,10 +101,176 @@ function explainStatus(error, endpoint) {
178
101
  }
179
102
  }
180
103
  //#endregion
104
+ //#region src/import/corpus.ts
105
+ /**
106
+ * Reads the entries of a directory laid out as `<collection>/<locale>/<slug>.md`. Only `.md`
107
+ * files count. A Markdown file at any other depth is skipped and reported; one at the right
108
+ * depth with a slug that is not a slug, or with bytes that are not UTF-8, is a problem. Files
109
+ * and folders whose name starts with a dot, and symbolic links, are never read: a link could
110
+ * lead out of the directory the person pointed at.
111
+ */
112
+ function readCorpus(root) {
113
+ const corpus = {
114
+ files: [],
115
+ skipped: [],
116
+ problems: []
117
+ };
118
+ walk(root, [], corpus);
119
+ corpus.files.sort((a, b) => compare(a.collection, b.collection) || compare(a.locale, b.locale) || compare(a.slug, b.slug));
120
+ corpus.skipped.sort(compare);
121
+ corpus.problems.sort((a, b) => compare(a.path, b.path));
122
+ return corpus;
123
+ }
124
+ function compare(a, b) {
125
+ return a < b ? -1 : a > b ? 1 : 0;
126
+ }
127
+ function walk(directory, segments, corpus) {
128
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
129
+ if (entry.name.startsWith(".")) continue;
130
+ if (entry.isDirectory()) walk(join(directory, entry.name), [...segments, entry.name], corpus);
131
+ else if (entry.isFile() && entry.name.endsWith(".md")) readFile(join(directory, entry.name), [...segments, entry.name], corpus);
132
+ }
133
+ }
134
+ function readFile(file, segments, corpus) {
135
+ const path = segments.join("/");
136
+ const [collection, locale, name] = segments;
137
+ if (segments.length !== 3 || collection === void 0 || locale === void 0 || name === void 0) {
138
+ corpus.skipped.push(path);
139
+ return;
140
+ }
141
+ const slug = name.slice(0, -3);
142
+ if (!schemas.slug.safeParse(slug).success) {
143
+ corpus.problems.push({
144
+ path,
145
+ reason: `${JSON.stringify(slug)} is not a slug: use lowercase letters and digits joined by hyphens, up to 200 characters.`
146
+ });
147
+ return;
148
+ }
149
+ let bytes;
150
+ try {
151
+ bytes = readFileSync(file);
152
+ } catch (error) {
153
+ corpus.problems.push({
154
+ path,
155
+ reason: `the file cannot be read: ${error instanceof Error ? error.message : String(error)}`
156
+ });
157
+ return;
158
+ }
159
+ let markdown;
160
+ try {
161
+ markdown = new TextDecoder("utf-8", {
162
+ fatal: true,
163
+ ignoreBOM: true
164
+ }).decode(bytes);
165
+ } catch {
166
+ corpus.problems.push({
167
+ path,
168
+ reason: "the file is not valid UTF-8."
169
+ });
170
+ return;
171
+ }
172
+ corpus.files.push({
173
+ path,
174
+ collection,
175
+ locale,
176
+ slug,
177
+ markdown
178
+ });
179
+ }
180
+ //#endregion
181
+ //#region src/import/from-nuxt-content.ts
182
+ /** The byte order mark a file may start with, kept where it is. */
183
+ const BOM = String.fromCharCode(65279);
184
+ /** A segment of a path as Nuxt Content routes it: its numeric prefix gone. */
185
+ const routed = (segment) => segment.replace(/^\d+\./, "");
186
+ /** The frontmatter of `markdown` with `field` set to `value`, unless the page sets it already. */
187
+ function withField(markdown, field, value) {
188
+ const line = `${field}: ${JSON.stringify(value)}`;
189
+ const bom = markdown.startsWith(BOM) ? BOM : "";
190
+ const body = markdown.slice(bom.length);
191
+ const open = /^---\r?\n/.exec(body);
192
+ if (open === null) return `${bom}---\n${line}\n---\n\n${body}`;
193
+ const close = /^---\s*$/m.exec(body.slice(open[0].length));
194
+ if (close === null) return markdown;
195
+ const frontmatter = body.slice(open[0].length, open[0].length + close.index);
196
+ if (new RegExp(`^${field}\\s*:`, "m").test(frontmatter)) return markdown;
197
+ const at = open[0].length + close.index;
198
+ return `${bom}${body.slice(0, at)}${line}\n${body.slice(at)}`;
199
+ }
200
+ function corpusFromNuxtContent(root, options) {
201
+ const files = [];
202
+ const skipped = [];
203
+ const problems = [];
204
+ const bySlug = /* @__PURE__ */ new Map();
205
+ const walk = (directory, segments) => {
206
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
207
+ for (const entry of entries) {
208
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
209
+ const path = [...segments, entry.name];
210
+ if (entry.isDirectory()) {
211
+ walk(join(directory, entry.name), path);
212
+ continue;
213
+ }
214
+ if (!entry.isFile()) continue;
215
+ if (!entry.name.endsWith(".md")) {
216
+ skipped.push(path.join("/"));
217
+ continue;
218
+ }
219
+ const [first, ...rest] = path;
220
+ const locale = options.localeDirs ? first ?? "" : options.locale;
221
+ const below = options.localeDirs ? rest : path;
222
+ if (options.localeDirs && rest.length === 0) {
223
+ skipped.push(path.join("/"));
224
+ continue;
225
+ }
226
+ const route = below.map(routed);
227
+ route[route.length - 1] = (route.at(-1) ?? "").slice(0, -3);
228
+ if (route.length > 1 && route.at(-1) === "index") route.pop();
229
+ const slug = route.join("-");
230
+ const where = path.join("/");
231
+ if (!schemas.slug.safeParse(slug).success) {
232
+ problems.push({
233
+ path: where,
234
+ reason: `its route makes "${slug}", which is not a slug`
235
+ });
236
+ continue;
237
+ }
238
+ const key = `${locale}/${slug}`;
239
+ const other = bySlug.get(key);
240
+ if (other !== void 0) {
241
+ problems.push({
242
+ path: where,
243
+ reason: `its slug "${slug}" is the slug of ${other} too`
244
+ });
245
+ continue;
246
+ }
247
+ bySlug.set(key, where);
248
+ const read = readFileSync(join(directory, entry.name), "utf8");
249
+ const heading = /^#[ \t]+(.+?)[ \t]*$/m.exec(read)?.[1];
250
+ const text = withField(read, "title", heading ?? slug);
251
+ files.push({
252
+ path: `${options.collection}/${locale}/${slug}.md`,
253
+ collection: options.collection,
254
+ locale,
255
+ slug,
256
+ markdown: options.localeDirs ? withField(text, options.groupField, slug) : text
257
+ });
258
+ }
259
+ };
260
+ walk(root, []);
261
+ const order = (a, b) => a < b ? -1 : a > b ? 1 : 0;
262
+ files.sort((a, b) => order(a.path, b.path));
263
+ return {
264
+ files,
265
+ skipped: skipped.sort(order),
266
+ problems: problems.sort((a, b) => order(a.path, b.path))
267
+ };
268
+ }
269
+ //#endregion
181
270
  //#region src/commands/import.ts
182
271
  const IMPORT_USAGE = [
183
272
  "Usage: hollware import <directory> --url <hollware url> (--token-file <file> | token on stdin)",
184
- " [--publish] [--dry-run]",
273
+ " [--publish] [--dry-run] [--from markdown | nuxt-content]",
185
274
  "",
186
275
  "Sends the Markdown files of a directory to a running Hollware over MCP, as drafts. The files",
187
276
  "are <directory>/<collection>/<locale>/<slug>.md. Every file is validated first: if one would be",
@@ -193,6 +282,13 @@ const IMPORT_USAGE = [
193
282
  " --token-file <file> A file with a personal token; without it, the token is read from stdin",
194
283
  " --publish Then publish the entries that have unpublished changes, acknowledging no warnings",
195
284
  " --dry-run Only validate: write nothing, publish nothing",
285
+ " --from <source> What the directory is: markdown (the default), or the content/ of a",
286
+ " Nuxt Content site",
287
+ " --collection <name> nuxt-content: the collection its pages go to",
288
+ " --locale <code> nuxt-content: the locale of every page (default en)",
289
+ " --locale-dirs nuxt-content: the first folder of each page is its locale",
290
+ " --group-field <name> nuxt-content: the field of a translation group (default",
291
+ " translationGroup)",
196
292
  " --help Show this help",
197
293
  "",
198
294
  "The token needs content:read and content:write, and content:publish for --publish: without it,",
@@ -207,6 +303,11 @@ async function importContent(argv, io) {
207
303
  "token-file": { type: "string" },
208
304
  publish: { type: "boolean" },
209
305
  "dry-run": { type: "boolean" },
306
+ from: { type: "string" },
307
+ collection: { type: "string" },
308
+ locale: { type: "string" },
309
+ "locale-dirs": { type: "boolean" },
310
+ "group-field": { type: "string" },
210
311
  help: { type: "boolean" }
211
312
  }
212
313
  });
@@ -214,12 +315,21 @@ async function importContent(argv, io) {
214
315
  io.out(IMPORT_USAGE);
215
316
  return 0;
216
317
  }
318
+ const from = values.from ?? "markdown";
319
+ if (from !== "markdown" && from !== "nuxt-content") throw new UsageError(`--from takes markdown or nuxt-content, not "${printable(from)}".`);
320
+ if (from === "nuxt-content" && values.collection === void 0) throw new UsageError("--from nuxt-content needs --collection: the collection its pages go to.");
217
321
  const [directory, ...extra] = positionals;
218
322
  if (directory === void 0 || extra.length > 0) throw new UsageError("import takes one directory: hollware import <directory> --url <url>.");
219
323
  if (values.url === void 0) throw new UsageError("--url is required: the address of your Hollware, such as https://cms.example.com.");
220
324
  const endpoint = mcpEndpoint(values.url);
221
325
  const root = resolve(io.cwd, directory);
222
326
  if (!isDirectory(root)) throw new CliError(`${directory} is not a directory.`);
327
+ const corpus = sourceOf(from, root, {
328
+ collection: values.collection ?? "",
329
+ locale: values.locale ?? "en",
330
+ localeDirs: values["locale-dirs"] === true,
331
+ groupField: values["group-field"] ?? "translationGroup"
332
+ });
223
333
  const token = await readToken(io, values["token-file"]);
224
334
  try {
225
335
  return await importFiles({
@@ -227,19 +337,29 @@ async function importContent(argv, io) {
227
337
  token,
228
338
  endpoint,
229
339
  directory,
230
- root,
340
+ corpus,
231
341
  publish: values.publish === true,
232
342
  dryRun: values["dry-run"] === true
233
343
  });
234
344
  } catch (error) {
235
- if (error instanceof CliError) throw new CliError(redact(error.message, token));
236
- const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ");
237
- throw new CliError(`unexpected error: ${redact(printable(reason.slice(0, 300)), token)}. This is a bug in hollware: please report it at https://github.com/hollware/hollware/issues.`);
345
+ throw withoutToken(error, token);
238
346
  }
239
347
  }
240
- async function importFiles({ io, token, endpoint, directory, root, publish: publishToo, dryRun }) {
348
+ /**
349
+ * What a command that holds the token throws: its own `CliError` without the token, not even in a
350
+ * message of the server, and anything else as one line that says it is a bug.
351
+ */
352
+ function withoutToken(error, token) {
353
+ if (error instanceof CliError) return new CliError(redact(error.message, token));
354
+ const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ");
355
+ return new CliError(`unexpected error: ${redact(printable(reason.slice(0, 300)), token)}. This is a bug in hollware: please report it at https://github.com/hollware/hollware/issues.`);
356
+ }
357
+ /** The files of a directory of Markdown, or of the content/ of a Nuxt Content site. */
358
+ function sourceOf(from, root, options) {
359
+ return from === "markdown" ? readCorpus(root) : corpusFromNuxtContent(root, options);
360
+ }
361
+ async function importFiles({ io, token, endpoint, directory, corpus, publish: publishToo, dryRun }) {
241
362
  const out = (message) => io.out(redact(message, token));
242
- const corpus = readCorpus(root);
243
363
  if (corpus.files.length === 0 && corpus.problems.length === 0) throw new CliError(`no entries in ${directory}: expected Markdown files at <collection>/<locale>/<slug>.md, such as blog/en/hello.md.`);
244
364
  if (corpus.files.length > 0) out(`Found ${count(corpus.files.length, "entry", "entries")} in ${directory} (${collectionSummary(corpus.files)}).`);
245
365
  if (corpus.skipped.length > 0) out(`Skipped ${count(corpus.skipped.length, "Markdown file")} that ${corpus.skipped.length === 1 ? "is" : "are"} not at <collection>/<locale>/<slug>.md: ${listSome(corpus.skipped)}.`);
@@ -297,6 +417,10 @@ function isDirectory(path) {
297
417
  return false;
298
418
  }
299
419
  }
420
+ /**
421
+ * The personal token, from `--token-file` (relative to the working directory) or else from stdin,
422
+ * trimmed. Something that is not a personal token is refused, and never printed.
423
+ */
300
424
  async function readToken(io, tokenFile) {
301
425
  let raw;
302
426
  let source;
@@ -543,4 +667,4 @@ function listSome(paths) {
543
667
  return paths.length > shown.length ? `${shown.join(", ")} and ${paths.length - shown.length} more` : shown.join(", ");
544
668
  }
545
669
  //#endregion
546
- export { importContent };
670
+ export { redact as a, openSession as c, readToken as i, importContent as n, withoutToken as o, mcpEndpoint as r, ToolFailure as s, IMPORT_USAGE as t };
@@ -0,0 +1 @@
1
+ export { n as importContent } from "./import-D9LRrYtb.mjs";
package/dist/index.mjs CHANGED
@@ -38,7 +38,7 @@ const COMMANDS = [
38
38
  },
39
39
  {
40
40
  name: "export",
41
- summary: "Export entries as Markdown files (planned for v0.2, not implemented yet)"
41
+ summary: "Export entries as Markdown files over MCP"
42
42
  }
43
43
  ];
44
44
  const NAME_WIDTH = Math.max(...COMMANDS.map((command) => command.name.length));
@@ -77,10 +77,6 @@ async function main(argv, io) {
77
77
  io.out(`hollware ${readVersion()}`);
78
78
  return 0;
79
79
  }
80
- if (first === "export") {
81
- io.err("hollware export: not implemented yet (planned for v0.2)");
82
- return 1;
83
- }
84
80
  if (![
85
81
  "init",
86
82
  "migrate",
@@ -88,7 +84,8 @@ async function main(argv, io) {
88
84
  "doctor",
89
85
  "token",
90
86
  "invite",
91
- "import"
87
+ "import",
88
+ "export"
92
89
  ].includes(first)) {
93
90
  io.err(`hollware: unknown command or flag "${printable(first)}"\n\n${USAGE}`);
94
91
  return 1;
@@ -105,13 +102,14 @@ async function main(argv, io) {
105
102
  }
106
103
  async function run(command, args, io) {
107
104
  switch (command) {
108
- case "init": return (await import("./init-CBZPzLBs.mjs")).init(args, io);
109
- case "migrate": return (await import("./migrate-Ka2Mm06e.mjs")).migrate(args, io);
110
- case "deploy": return (await import("./deploy-4tusYOMV.mjs")).deploy(args, io);
111
- case "doctor": return (await import("./doctor-DiKAktVd.mjs")).doctor(args, io);
112
- case "token": return (await import("./token-BND4Rsxj.mjs")).token(args, io);
113
- case "invite": return (await import("./invite-Ck-aqpN8.mjs")).invite(args, io);
114
- default: return (await import("./import-DG8AABZQ.mjs")).importContent(args, io);
105
+ case "init": return (await import("./init-cS_QgC4n.mjs")).init(args, io);
106
+ case "migrate": return (await import("./migrate-BleH6hXz.mjs")).migrate(args, io);
107
+ case "deploy": return (await import("./deploy-8_Nvbr3b.mjs")).deploy(args, io);
108
+ case "doctor": return (await import("./doctor-BT_bTF1M.mjs")).doctor(args, io);
109
+ case "token": return (await import("./token-B0hS__kw.mjs")).token(args, io);
110
+ case "invite": return (await import("./invite-BvW1xuxU.mjs")).invite(args, io);
111
+ case "export": return (await import("./export-DprcsjU_.mjs")).exportContent(args, io);
112
+ default: return (await import("./import-JNLtBmyD.mjs")).importContent(args, io);
115
113
  }
116
114
  }
117
115
  function isEntryPoint() {
@@ -1,15 +1,16 @@
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 { _ 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-CK4nF7p5.mjs";
4
- import { n as setupLink, t as publicUrlOf } from "./public-url-NLPRzxCN.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-BGChD8_u.mjs";
4
+ import { n as setupLink, t as publicUrlOf } from "./public-url-B2BXf_pj.mjs";
5
5
  import { i as newPersonalToken, n as newId, r as newInvitationSecret, t as inDays } from "./entropy-5uxirwFD.mjs";
6
6
  import { existsSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
- import { PERSONAL_TOKEN_TTL_DAYS, PRESETS, SCOPES, hashPersonalToken, sitePreset } from "@hollware/core";
8
+ import { AUDIT_RETENTION_MAX_DAYS, AUDIT_RETENTION_MIN_DAYS, PERSONAL_TOKEN_TTL_DAYS, PRESETS, SCOPES, hashPersonalToken, sitePreset } from "@hollware/core";
9
9
  //#region src/commands/init.ts
10
10
  const INIT_USAGE = [
11
11
  "Usage: hollware init --site <key> [--preset <blog | docs | empty>] [--locales <list>]",
12
12
  " [--local | --remote] [--owner-name <name>] [--database <name>] [--url <url>]",
13
+ " [--ip-addresses <off | hashed>] [--audit-retention-days <n>]",
13
14
  "",
14
15
  "Creates a site, its owner and the owner's first personal token in the project's D1 database,",
15
16
  "in one step, and prints the token once, with a link to set up the passkey of the admin (valid",
@@ -30,6 +31,11 @@ const INIT_USAGE = [
30
31
  " --owner-name <name> The name of the owner",
31
32
  " --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
32
33
  " --url <url> The public URL of Hollware, for the link (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc)",
34
+ " --ip-addresses <off | hashed>",
35
+ " Whether the activity log keeps a keyed hash of the address of each request",
36
+ " (default: off, nothing of it): an address is personal data",
37
+ " --audit-retention-days <n>",
38
+ " The days each line of the activity log is kept, 30 to 3650 (default: forever)",
33
39
  " --help Show this help"
34
40
  ].join("\n");
35
41
  /** The label of the token `init` creates. */
@@ -47,6 +53,8 @@ async function init(argv, io) {
47
53
  locales: { type: "string" },
48
54
  database: { type: "string" },
49
55
  url: { type: "string" },
56
+ "ip-addresses": { type: "string" },
57
+ "audit-retention-days": { type: "string" },
50
58
  help: { type: "boolean" }
51
59
  }
52
60
  });
@@ -56,7 +64,8 @@ async function init(argv, io) {
56
64
  }
57
65
  if (values.site === void 0) throw new UsageError("--site is required: name the site, such as --site my-site.");
58
66
  const site = parseSiteKey(values.site);
59
- const definition = initialDefinition(io.cwd, values.preset, values.locales);
67
+ const audit = auditOf(values["ip-addresses"], values["audit-retention-days"]);
68
+ const definition = initialDefinition(io.cwd, values.preset, values.locales, audit);
60
69
  const ownerName = values["owner-name"] === void 0 ? void 0 : parseOwnerName(values["owner-name"]);
61
70
  const target = chooseTarget(values);
62
71
  const database = resolveDatabase(io.cwd, values.database);
@@ -73,7 +82,8 @@ async function init(argv, io) {
73
82
  ownerId: first["id"],
74
83
  database,
75
84
  target,
76
- definition
85
+ definition,
86
+ audit
77
87
  });
78
88
  const now = io.now();
79
89
  const timestamp = now.toISOString();
@@ -145,7 +155,8 @@ async function init(argv, io) {
145
155
  " Save the token in a file only you can read, then import content:",
146
156
  " hollware import <directory> --url <your Hollware URL> --token-file <file>",
147
157
  " An MCP client sends it as the header \"Authorization: Bearer <token>\" to <your Hollware URL>/mcp.",
148
- " More tokens: hollware token create --label <text>"
158
+ " More tokens: hollware token create --label <text>",
159
+ ...definition === null ? [] : ["", activityNote(audit)]
149
160
  ].join("\n"));
150
161
  return 0;
151
162
  }
@@ -202,7 +213,8 @@ async function addSite(io, where) {
202
213
  "",
203
214
  secret,
204
215
  "",
205
- "The owner signs in to the admin as before, with the same passkey, and switches to the site."
216
+ "The owner signs in to the admin as before, with the same passkey, and switches to the site.",
217
+ ...definition === null ? [] : ["", activityNote(where.audit)]
206
218
  ].join("\n"));
207
219
  return 0;
208
220
  }
@@ -212,9 +224,9 @@ const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
212
224
  * The first definition of the site (ADR-033), from `--preset` and `--locales`; `null` for a
213
225
  * project with a hollware.config.ts, whose Worker moves it into the database on its first request.
214
226
  */
215
- function initialDefinition(cwd, preset, locales) {
227
+ function initialDefinition(cwd, preset, locales, audit = DEFAULT_AUDIT_CHOICE) {
216
228
  if (existsSync(join(cwd, "hollware.config.ts"))) {
217
- if (preset !== void 0 || locales !== void 0) throw new UsageError("this project has a hollware.config.ts, which the Worker moves into the database: leave out --preset and --locales, or delete the config first.");
229
+ if (preset !== void 0 || locales !== void 0 || audit.given) throw new UsageError("this project has a hollware.config.ts, which the Worker moves into the database: leave out --preset, --locales, --ip-addresses and --audit-retention-days, or delete the config first.");
218
230
  return null;
219
231
  }
220
232
  const chosen = preset ?? "blog";
@@ -222,7 +234,36 @@ function initialDefinition(cwd, preset, locales) {
222
234
  const list = (locales ?? "en").split(",").map((locale) => locale.trim()).filter((locale) => locale !== "");
223
235
  const bad = list.find((locale) => !LOCALE.test(locale));
224
236
  if (list.length === 0 || bad !== void 0) throw new UsageError(`--locales is a comma-separated list of locales, such as en,es; "${bad ?? ""}" is not one.`);
225
- return sitePreset(chosen, list, list[0] ?? "en");
237
+ return {
238
+ ...sitePreset(chosen, list, list[0] ?? "en"),
239
+ audit: {
240
+ ipAddresses: audit.ipAddresses,
241
+ retentionDays: audit.retentionDays
242
+ }
243
+ };
244
+ }
245
+ const DEFAULT_AUDIT_CHOICE = {
246
+ ipAddresses: "off",
247
+ retentionDays: null,
248
+ given: false
249
+ };
250
+ /** `--ip-addresses` and `--audit-retention-days`, checked; the defaults when both are left out. */
251
+ function auditOf(addresses, days) {
252
+ if (addresses !== void 0 && addresses !== "off" && addresses !== "hashed") throw new UsageError(`--ip-addresses is off or hashed, not "${addresses}".`);
253
+ let retentionDays = null;
254
+ if (days !== void 0) {
255
+ retentionDays = /^\d+$/.test(days) ? Number(days) : NaN;
256
+ if (!Number.isInteger(retentionDays) || retentionDays < AUDIT_RETENTION_MIN_DAYS || retentionDays > AUDIT_RETENTION_MAX_DAYS) throw new UsageError(`--audit-retention-days is a whole number from ${AUDIT_RETENTION_MIN_DAYS} to ${AUDIT_RETENTION_MAX_DAYS}, not "${days}". Leave it out to keep every line.`);
257
+ }
258
+ return {
259
+ ipAddresses: addresses === "hashed" ? "hashed" : "off",
260
+ retentionDays,
261
+ given: addresses !== void 0 || days !== void 0
262
+ };
263
+ }
264
+ /** What `init` says of the activity log of the new site, and where its owner changes it. */
265
+ function activityNote(audit) {
266
+ return `The activity log of the site ${audit.ipAddresses === "hashed" ? "keeps a keyed hash of the address of each request" : "keeps no address"} and ${audit.retentionDays === null ? "keeps every line" : `keeps each line ${audit.retentionDays} days`}; an owner changes that in the admin (Site, Activity log).`;
226
267
  }
227
268
  //#endregion
228
269
  export { init };
@@ -1,9 +1,9 @@
1
1
  import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
2
2
  import { t as parseFlags } from "./args-CljWh-kZ.mjs";
3
- import { _ 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-CK4nF7p5.mjs";
4
- import { n as setupLink, t as publicUrlOf } from "./public-url-NLPRzxCN.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-BGChD8_u.mjs";
4
+ import { n as setupLink, t as publicUrlOf } from "./public-url-B2BXf_pj.mjs";
5
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-CLQpD4ed.mjs";
6
+ import { o as WHERE_OPTIONS, s as chooseSite } from "./token-CVPHV1K-.mjs";
7
7
  import { hashPersonalToken } from "@hollware/core";
8
8
  //#region src/commands/invite.ts
9
9
  const INVITE_USAGE = [
@@ -0,0 +1 @@
1
+ export { i as migrate } from "./migrate-BqdgcoRJ.mjs";
@@ -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-CK4nF7p5.mjs";
4
+ import { i as resolveDatabase, n as describeDatabase, t as chooseTarget } from "./d1-BGChD8_u.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
@@ -1,5 +1,5 @@
1
1
  import { i as UsageError } from "./format-BpRYEsGB.mjs";
2
- import { S as parseJsonc } from "./d1-CK4nF7p5.mjs";
2
+ import { S as parseJsonc } from "./d1-BGChD8_u.mjs";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  //#region src/public-url.ts
@@ -26,7 +26,7 @@ function publicUrlOf(cwd, flag) {
26
26
  }
27
27
  /** The link that lets a person set up a passkey with an invitation. */
28
28
  function setupLink(url, secret) {
29
- return `${url ?? "<your Hollware URL>"}/setup?invitation=${secret}`;
29
+ return `${url ?? "<your Hollware URL>"}/setup#invitation=${secret}`;
30
30
  }
31
31
  //#endregion
32
32
  export { setupLink as n, publicUrlOf as t };
@@ -0,0 +1 @@
1
+ export { c as token } from "./token-CVPHV1K-.mjs";
@@ -1,8 +1,8 @@
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 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-CK4nF7p5.mjs";
3
+ import { _ as parseSiteKey, b as sitesAndOwnerQuery, g as parseScopes, i as resolveDatabase, l as insertToken, m as parseLabel, p as parseExpiresInDays, r as query, t as chooseTarget, u as listTokens, v as parseTokenId, x as sitesQuery, y as revokeToken } from "./d1-BGChD8_u.mjs";
4
4
  import { a as newReadToken, i as newPersonalToken, n as newId, t as inDays } from "./entropy-5uxirwFD.mjs";
5
- import { PERSONAL_TOKEN_TTL_DAYS, hashPersonalToken } from "@hollware/core";
5
+ import { hashPersonalToken } from "@hollware/core";
6
6
  //#region src/commands/token.ts
7
7
  const WHERE_FLAGS = [
8
8
  " --site <key> The site (default: the only site of the database)",
@@ -34,7 +34,7 @@ const TOKEN_CREATE_USAGE = [
34
34
  " --read A read token: content:read, published content, the read API only",
35
35
  " --scopes <list> Comma-separated: content:read, content:write, content:publish, schema:admin",
36
36
  " (default: content:read,content:write)",
37
- ` --expires-in-days <n> Days until it expires, 1 to 365 (default: ${PERSONAL_TOKEN_TTL_DAYS})`,
37
+ " --expires-in-days <n> Days until it expires, 1 to 365; without it, the token never expires",
38
38
  ...WHERE_FLAGS
39
39
  ].join("\n");
40
40
  const TOKEN_LIST_USAGE = [
@@ -97,7 +97,7 @@ async function createToken(argv, io) {
97
97
  const label = parseLabel(values.label);
98
98
  const siteFlag = values.site === void 0 ? void 0 : parseSiteKey(values.site);
99
99
  const scopes = read ? ["content:read"] : values.scopes === void 0 ? DEFAULT_SCOPES : parseScopes(values.scopes);
100
- const days = values["expires-in-days"] === void 0 ? PERSONAL_TOKEN_TTL_DAYS : parseExpiresInDays(values["expires-in-days"]);
100
+ const days = values["expires-in-days"] === void 0 ? null : parseExpiresInDays(values["expires-in-days"]);
101
101
  const target = chooseTarget(values);
102
102
  const database = resolveDatabase(io.cwd, values.database);
103
103
  const [siteRows = [], ownerRows = []] = await query(io.wrangler, database, target, sitesAndOwnerQuery());
@@ -107,7 +107,7 @@ async function createToken(argv, io) {
107
107
  const now = io.now();
108
108
  const id = newId(io, "token");
109
109
  const secret = read ? newReadToken(io) : newPersonalToken(io);
110
- const expiresAt = inDays(now, days);
110
+ const expiresAt = days === null ? null : inDays(now, days);
111
111
  await query(io.wrangler, database, target, insertToken({
112
112
  ...read ? { kind: "read" } : {},
113
113
  id,
@@ -124,7 +124,7 @@ async function createToken(argv, io) {
124
124
  "",
125
125
  `Label ${label}`,
126
126
  `Scopes ${scopes.join(" ")}`,
127
- `Expires ${expiresAt} (in ${days} ${days === 1 ? "day" : "days"})`,
127
+ days === null || expiresAt === null ? "Expires never (revoke it with hollware token revoke)" : `Expires ${expiresAt} (in ${days} ${days === 1 ? "day" : "days"})`,
128
128
  "",
129
129
  "The token. It is shown only once: Hollware keeps just its hash.",
130
130
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hollware",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "The Hollware CLI: init, migrate, token, import and export.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -31,17 +31,17 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@hollware/core": "0.9.1",
34
+ "@hollware/core": "0.11.0",
35
35
  "@modelcontextprotocol/client": "2.3.1",
36
- "zod": "4.6.5"
36
+ "zod": "4.6.7"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@modelcontextprotocol/server": "2.3.1",
40
40
  "@types/node": "24.19.2",
41
41
  "publint": "0.3.25",
42
42
  "typescript": "7.0.2",
43
- "vite-plus": "1.1.0",
44
- "wrangler": "4.150.0"
43
+ "vite-plus": "1.2.0",
44
+ "wrangler": "4.151.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.11.0"
@@ -1,2 +0,0 @@
1
- import { i as migrate } from "./migrate-BBfF-mkE.mjs";
2
- export { migrate };
@@ -1,2 +0,0 @@
1
- import { c as token } from "./token-CLQpD4ed.mjs";
2
- export { token };