hollware 0.9.1 → 0.10.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 +32 -5
- package/dist/export-DprcsjU_.mjs +234 -0
- package/dist/{import-DG8AABZQ.mjs → import-D9LRrYtb.mjs} +209 -85
- package/dist/import-JNLtBmyD.mjs +1 -0
- package/dist/index.mjs +7 -9
- package/dist/migrate-CRFTlOVG.mjs +1 -0
- package/dist/token-BasKoAEC.mjs +1 -0
- package/package.json +5 -5
- package/dist/migrate-Ka2Mm06e.mjs +0 -2
- package/dist/token-BND4Rsxj.mjs +0 -2
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
|
|
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
|
|
21
|
-
| `export` | Export entries as Markdown files
|
|
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`
|
|
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
|
|
|
@@ -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
|
-
|
|
340
|
+
corpus,
|
|
231
341
|
publish: values.publish === true,
|
|
232
342
|
dryRun: values["dry-run"] === true
|
|
233
343
|
});
|
|
234
344
|
} catch (error) {
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -106,12 +103,13 @@ async function main(argv, io) {
|
|
|
106
103
|
async function run(command, args, io) {
|
|
107
104
|
switch (command) {
|
|
108
105
|
case "init": return (await import("./init-CBZPzLBs.mjs")).init(args, io);
|
|
109
|
-
case "migrate": return (await import("./migrate-
|
|
106
|
+
case "migrate": return (await import("./migrate-CRFTlOVG.mjs")).migrate(args, io);
|
|
110
107
|
case "deploy": return (await import("./deploy-4tusYOMV.mjs")).deploy(args, io);
|
|
111
108
|
case "doctor": return (await import("./doctor-DiKAktVd.mjs")).doctor(args, io);
|
|
112
|
-
case "token": return (await import("./token-
|
|
109
|
+
case "token": return (await import("./token-BasKoAEC.mjs")).token(args, io);
|
|
113
110
|
case "invite": return (await import("./invite-Ck-aqpN8.mjs")).invite(args, io);
|
|
114
|
-
|
|
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() {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { i as migrate } from "./migrate-BBfF-mkE.mjs";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { c as token } from "./token-CLQpD4ed.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hollware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.
|
|
34
|
+
"@hollware/core": "0.10.0",
|
|
35
35
|
"@modelcontextprotocol/client": "2.3.1",
|
|
36
|
-
"zod": "4.6.
|
|
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.
|
|
44
|
-
"wrangler": "4.
|
|
43
|
+
"vite-plus": "1.2.0",
|
|
44
|
+
"wrangler": "4.151.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=24.11.0"
|
package/dist/token-BND4Rsxj.mjs
DELETED