hollware 0.0.1 → 0.1.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 +50 -11
- package/dist/args-CljWh-kZ.mjs +20 -0
- package/dist/d1-Bd-sdoDd.mjs +281 -0
- package/dist/entropy-CIYbTHkj.mjs +17 -0
- package/dist/format-BpRYEsGB.mjs +38 -0
- package/dist/import-Dlpn_w3s.mjs +506 -0
- package/dist/index.d.mts +49 -8
- package/dist/index.mjs +75 -28
- package/dist/init-Cl9Epsbp.mjs +108 -0
- package/dist/migrate-CLHWuUje.mjs +118 -0
- package/dist/token-BLkrPLio.mjs +228 -0
- package/dist/version-BMhdZaNo.mjs +9 -0
- package/dist/wrangler-BLIfwbcu.mjs +106 -0
- package/package.json +9 -2
|
@@ -0,0 +1,506 @@
|
|
|
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 { readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { REFUSAL_CONTRACT_IDS, createDraft, getEntry, isPersonalToken, publish, schemas, updateDraft, validate } from "@hollware/core";
|
|
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
|
+
//#region src/import/session.ts
|
|
86
|
+
/** A tool failed and said so in words, instead of answering with one of its outcomes. */
|
|
87
|
+
var ToolFailure = class extends CliError {
|
|
88
|
+
name = "ToolFailure";
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Connects to `endpoint` over MCP's Streamable HTTP, with `token` as a bearer token. A refusal
|
|
92
|
+
* of the connection or a failure of the transport is thrown as a `CliError` that says what to
|
|
93
|
+
* do. Nothing is sent to any other address: a redirect to another origin is not followed.
|
|
94
|
+
*/
|
|
95
|
+
async function openSession(endpoint, token, version) {
|
|
96
|
+
const client = new Client({
|
|
97
|
+
name: "hollware-cli",
|
|
98
|
+
version
|
|
99
|
+
});
|
|
100
|
+
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
101
|
+
authProvider: { token: () => Promise.resolve(token) },
|
|
102
|
+
onInsufficientScope: "throw",
|
|
103
|
+
redirectPolicy: "same-origin"
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
await client.connect(transport);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await client.close().catch(() => void 0);
|
|
109
|
+
throw explain(error, endpoint) ?? error;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
call: async (spec, input) => {
|
|
113
|
+
let result;
|
|
114
|
+
try {
|
|
115
|
+
result = await client.callTool({
|
|
116
|
+
name: spec.name,
|
|
117
|
+
arguments: spec.input.parse(input)
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw explain(error, endpoint) ?? error;
|
|
121
|
+
}
|
|
122
|
+
const answer = spec.output.safeParse(payloadOf(result));
|
|
123
|
+
if (answer.success) return answer.data;
|
|
124
|
+
throw new ToolFailure(`${spec.name} failed: ${wordsOf(result)}`);
|
|
125
|
+
},
|
|
126
|
+
close: () => client.close().catch(() => void 0)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function payloadOf(result) {
|
|
130
|
+
if (result.structuredContent !== void 0) return result.structuredContent;
|
|
131
|
+
for (const block of result.content) {
|
|
132
|
+
if (block.type !== "text") continue;
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(block.text);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function wordsOf(result) {
|
|
139
|
+
const words = result.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ").replace(/\s+/g, " ").trim();
|
|
140
|
+
return printable(words === "" ? "it gave neither an outcome nor a reason" : words.slice(0, 500));
|
|
141
|
+
}
|
|
142
|
+
/** What a failure of the transport means for the person who ran the command. */
|
|
143
|
+
function explain(error, endpoint) {
|
|
144
|
+
if (error instanceof CliError) return error;
|
|
145
|
+
if (error instanceof UnauthorizedError) return rejectedToken();
|
|
146
|
+
if (error instanceof InsufficientScopeError) {
|
|
147
|
+
const needed = error.requiredScope === void 0 ? "" : `: ${printable(error.requiredScope)}`;
|
|
148
|
+
return new CliError(`the token lacks a scope this call needs (403 insufficient_scope${needed}). "hollware token create --scopes <list>" makes one that has it.`);
|
|
149
|
+
}
|
|
150
|
+
if (error instanceof SdkHttpError) return explainStatus(error, endpoint);
|
|
151
|
+
if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) return new CliError(`the server at ${endpoint.origin} did not answer in time.`);
|
|
152
|
+
if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpUnexpectedContent) return new CliError(`the server at ${endpoint.href} does not answer like an MCP server. Check --url.`);
|
|
153
|
+
if (error instanceof ProtocolError) return new CliError(`the server refused the request: ${printable(error.message)}`);
|
|
154
|
+
if (error instanceof TypeError && error.message === "fetch failed") {
|
|
155
|
+
const { cause } = error;
|
|
156
|
+
const why = cause instanceof Error ? ` (${"code" in cause ? String(cause.code) : cause.message})` : "";
|
|
157
|
+
return new CliError(`could not reach ${endpoint.origin}${why}. Check --url and that Hollware is running.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function rejectedToken() {
|
|
161
|
+
return new CliError("the server rejected the token (401). It may be wrong, expired or revoked: \"hollware token create\" makes a new one.");
|
|
162
|
+
}
|
|
163
|
+
function explainStatus(error, endpoint) {
|
|
164
|
+
if (error.status >= 300 && error.status < 400) {
|
|
165
|
+
const reason = error.message.replace(/^Error POSTing to endpoint: /, "").replace(/ \(redirectPolicy: [^)]*\)$/, "");
|
|
166
|
+
return new CliError(`the server redirected the request, and hollware does not follow a redirect with the token: ${printable(reason)}.`);
|
|
167
|
+
}
|
|
168
|
+
switch (error.status) {
|
|
169
|
+
case 401: return rejectedToken();
|
|
170
|
+
case 403: return new CliError("the server refused the request (403). The token may lack a scope, or the host in --url is not the one the server is set up for (its HOLLWARE_PUBLIC_URL).");
|
|
171
|
+
case 404: return new CliError(`there is no MCP endpoint at ${endpoint.href} (404). Check --url.`);
|
|
172
|
+
default: {
|
|
173
|
+
const status = [error.status, error.statusText].filter((part) => part !== void 0 && part !== "").join(" ");
|
|
174
|
+
const { text } = error.data;
|
|
175
|
+
const detail = typeof text === "string" && text.trim() !== "" ? `: ${printable(text.trim().slice(0, 300))}` : "";
|
|
176
|
+
return new CliError(`the server answered ${status}${detail}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/commands/import.ts
|
|
182
|
+
const IMPORT_USAGE = [
|
|
183
|
+
"Usage: hollware import <directory> --url <hollware url> (--token-file <file> | token on stdin)",
|
|
184
|
+
" [--publish] [--dry-run]",
|
|
185
|
+
"",
|
|
186
|
+
"Sends the Markdown files of a directory to a running Hollware over MCP, as drafts. The files",
|
|
187
|
+
"are <directory>/<collection>/<locale>/<slug>.md. Every file is validated first: if one would be",
|
|
188
|
+
"refused, nothing is written. Running it again changes nothing: an entry that is already",
|
|
189
|
+
"there with the same Markdown is \"unchanged\".",
|
|
190
|
+
"",
|
|
191
|
+
"Options:",
|
|
192
|
+
" --url <url> The address of your Hollware, such as https://cms.example.com (MCP is at <url>/mcp)",
|
|
193
|
+
" --token-file <file> A file with a personal token; without it, the token is read from stdin",
|
|
194
|
+
" --publish Then publish the entries that have unpublished changes, acknowledging no warnings",
|
|
195
|
+
" --dry-run Only validate: write nothing, publish nothing",
|
|
196
|
+
" --help Show this help",
|
|
197
|
+
"",
|
|
198
|
+
"The token needs content:read and content:write, and content:publish for --publish."
|
|
199
|
+
].join("\n");
|
|
200
|
+
async function importContent(argv, io) {
|
|
201
|
+
const { values, positionals } = parseFlags({
|
|
202
|
+
args: [...argv],
|
|
203
|
+
allowPositionals: true,
|
|
204
|
+
options: {
|
|
205
|
+
url: { type: "string" },
|
|
206
|
+
"token-file": { type: "string" },
|
|
207
|
+
publish: { type: "boolean" },
|
|
208
|
+
"dry-run": { type: "boolean" },
|
|
209
|
+
help: { type: "boolean" }
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
if (values.help === true) {
|
|
213
|
+
io.out(IMPORT_USAGE);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
const [directory, ...extra] = positionals;
|
|
217
|
+
if (directory === void 0 || extra.length > 0) throw new UsageError("import takes one directory: hollware import <directory> --url <url>.");
|
|
218
|
+
if (values.url === void 0) throw new UsageError("--url is required: the address of your Hollware, such as https://cms.example.com.");
|
|
219
|
+
const endpoint = mcpEndpoint(values.url);
|
|
220
|
+
const root = resolve(io.cwd, directory);
|
|
221
|
+
if (!isDirectory(root)) throw new CliError(`${directory} is not a directory.`);
|
|
222
|
+
const token = await readToken(io, values["token-file"]);
|
|
223
|
+
try {
|
|
224
|
+
return await importFiles({
|
|
225
|
+
io,
|
|
226
|
+
token,
|
|
227
|
+
endpoint,
|
|
228
|
+
directory,
|
|
229
|
+
root,
|
|
230
|
+
publish: values.publish === true,
|
|
231
|
+
dryRun: values["dry-run"] === true
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error instanceof CliError) throw new CliError(redact(error.message, token));
|
|
235
|
+
const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ");
|
|
236
|
+
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.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function importFiles({ io, token, endpoint, directory, root, publish: publishToo, dryRun }) {
|
|
240
|
+
const out = (message) => io.out(redact(message, token));
|
|
241
|
+
const corpus = readCorpus(root);
|
|
242
|
+
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.`);
|
|
243
|
+
if (corpus.files.length > 0) out(`Found ${count(corpus.files.length, "entry", "entries")} in ${directory} (${collectionSummary(corpus.files)}).`);
|
|
244
|
+
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)}.`);
|
|
245
|
+
if (corpus.problems.length > 0) throw new CliError([
|
|
246
|
+
`${count(corpus.problems.length, "file")} cannot be imported, so nothing was sent:`,
|
|
247
|
+
...corpus.problems.map((problem) => ` ${printable(problem.path)}: ${problem.reason}`),
|
|
248
|
+
"Fix or remove them and run the import again."
|
|
249
|
+
].join("\n"));
|
|
250
|
+
const session = await openSession(endpoint, token, readVersion());
|
|
251
|
+
try {
|
|
252
|
+
const checks = await validateAll(session, corpus.files);
|
|
253
|
+
if (dryRun) out(dryRunReport(checks));
|
|
254
|
+
const refused = checks.filter((check) => check.refusals.length > 0 || check.failure !== void 0);
|
|
255
|
+
if (refused.length > 0) throw new CliError(refusalReport(refused, checks.length, dryRun));
|
|
256
|
+
if (dryRun) {
|
|
257
|
+
out(`Dry run: ${count(checks.length, "file")} validated, none would be refused. Nothing was written.`);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
out(`Validated ${count(checks.length, "file")}: none would be refused.`);
|
|
261
|
+
const drafts = await writeDrafts(session, corpus.files, out);
|
|
262
|
+
out(`Drafts: ${drafts.created} created, ${drafts.updated} updated, ${drafts.unchanged} unchanged${drafts.failed === 0 ? "" : `, ${drafts.failed} failed`}.`);
|
|
263
|
+
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
|
+
if (!publishToo) return 0;
|
|
265
|
+
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
|
+
const problems = published.needsAcknowledgement + published.blocked + published.other;
|
|
268
|
+
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
|
+
return 0;
|
|
270
|
+
} finally {
|
|
271
|
+
await session.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** The MCP endpoint of a Hollware, from the address a person gives: `<url>/mcp`. */
|
|
275
|
+
function mcpEndpoint(address) {
|
|
276
|
+
let url;
|
|
277
|
+
try {
|
|
278
|
+
url = new URL(address);
|
|
279
|
+
} catch {
|
|
280
|
+
throw new UsageError(`invalid --url ${printable(JSON.stringify(address))}: use the address of your Hollware, such as https://cms.example.com.`);
|
|
281
|
+
}
|
|
282
|
+
if (url.username !== "" || url.password !== "") throw new UsageError("--url must not hold a user name or a password: the token comes from --token-file or stdin.");
|
|
283
|
+
if (url.search !== "" || url.hash !== "") throw new UsageError("--url takes the address of Hollware only, without a query or a fragment.");
|
|
284
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) throw new UsageError("--url must start with https://, or the token would travel unencrypted. Plain http is accepted only for localhost.");
|
|
285
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
286
|
+
url.pathname = path.endsWith("/mcp") ? path : `${path}/mcp`;
|
|
287
|
+
return url;
|
|
288
|
+
}
|
|
289
|
+
function isLoopback(hostname) {
|
|
290
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
291
|
+
}
|
|
292
|
+
function isDirectory(path) {
|
|
293
|
+
try {
|
|
294
|
+
return statSync(path).isDirectory();
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function readToken(io, tokenFile) {
|
|
300
|
+
let raw;
|
|
301
|
+
let source;
|
|
302
|
+
if (tokenFile === void 0) {
|
|
303
|
+
raw = await io.readStdin();
|
|
304
|
+
source = "on stdin";
|
|
305
|
+
if (raw === void 0) throw new UsageError("give the token with --token-file <file>, or pipe it into stdin.");
|
|
306
|
+
} else {
|
|
307
|
+
source = `in ${tokenFile}`;
|
|
308
|
+
try {
|
|
309
|
+
raw = readFileSync(resolve(io.cwd, tokenFile), "utf8");
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw new CliError(`could not read the token file ${tokenFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const token = raw.trim();
|
|
315
|
+
if (!isPersonalToken(token)) throw new CliError(`the token ${source} is not a Hollware personal token: it is hw_pat_ followed by 52 characters. "hollware token create" makes one.`);
|
|
316
|
+
return token;
|
|
317
|
+
}
|
|
318
|
+
const TOKEN_SHAPE = /hw_pat_[0-9a-hjkmnp-tv-z]{52}/g;
|
|
319
|
+
/** `text` without `secret`, and without anything else that has the shape of a personal token. */
|
|
320
|
+
function redact(text, secret) {
|
|
321
|
+
return text.split(secret).join("hw_pat_[redacted]").replace(TOKEN_SHAPE, "hw_pat_[redacted]");
|
|
322
|
+
}
|
|
323
|
+
async function validateAll(session, files) {
|
|
324
|
+
const checks = [];
|
|
325
|
+
for (const file of files) try {
|
|
326
|
+
const answer = await session.call(validate, {
|
|
327
|
+
collection: file.collection,
|
|
328
|
+
locale: file.locale,
|
|
329
|
+
slug: file.slug,
|
|
330
|
+
markdown: file.markdown
|
|
331
|
+
});
|
|
332
|
+
checks.push({
|
|
333
|
+
file,
|
|
334
|
+
verdict: answer.verdict,
|
|
335
|
+
refusals: answer.verdict.errors.filter((violation) => REFUSAL_CONTRACT_IDS.includes(violation.contract)),
|
|
336
|
+
failure: void 0
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (!(error instanceof ToolFailure)) throw error;
|
|
340
|
+
checks.push({
|
|
341
|
+
file,
|
|
342
|
+
verdict: void 0,
|
|
343
|
+
refusals: [],
|
|
344
|
+
failure: error.message
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return checks;
|
|
348
|
+
}
|
|
349
|
+
function dryRunReport(checks) {
|
|
350
|
+
const lines = [];
|
|
351
|
+
for (const { file, verdict, refusals, failure } of checks) {
|
|
352
|
+
const state = failure !== void 0 || refusals.length > 0 ? "refused" : "ok";
|
|
353
|
+
lines.push(`${state.padEnd(7)} ${printable(file.path)}`);
|
|
354
|
+
for (const violation of verdict?.errors ?? []) lines.push(` ${violationLine(violation)}`);
|
|
355
|
+
for (const violation of verdict?.warnings ?? []) lines.push(` ${violationLine(violation)}`);
|
|
356
|
+
}
|
|
357
|
+
return lines.join("\n");
|
|
358
|
+
}
|
|
359
|
+
function refusalReport(refused, total, dryRun) {
|
|
360
|
+
const lines = refused.flatMap(({ file, refusals, failure }) => failure !== void 0 ? [` ${printable(file.path)}: ${failure}`] : refusals.map((violation) => ` ${printable(file.path)}: ${violationLine(violation)}`));
|
|
361
|
+
return [
|
|
362
|
+
`${refused.length} of ${count(total, "file")} would be refused, so ${dryRun ? "a real import would write nothing" : "nothing was written"}:`,
|
|
363
|
+
...lines,
|
|
364
|
+
"Fix them and run the import again."
|
|
365
|
+
].join("\n");
|
|
366
|
+
}
|
|
367
|
+
function violationLine(violation) {
|
|
368
|
+
const line = violation.line === void 0 ? "" : ` (line ${violation.line})`;
|
|
369
|
+
return printable(`${violation.severity} [${violation.contract}] ${violation.path}${line}: ${violation.message} Fix: ${violation.fix}`);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Phase one: every file becomes a draft. An entry that does not exist is created; one that
|
|
373
|
+
* does is updated at the version just read, which Hollware answers with `unchanged` when the
|
|
374
|
+
* Markdown is the same. A failure of one file does not stop the others.
|
|
375
|
+
*/
|
|
376
|
+
async function writeDrafts(session, files, out) {
|
|
377
|
+
const drafts = {
|
|
378
|
+
created: 0,
|
|
379
|
+
updated: 0,
|
|
380
|
+
unchanged: 0,
|
|
381
|
+
failed: 0,
|
|
382
|
+
entries: []
|
|
383
|
+
};
|
|
384
|
+
for (const file of files) {
|
|
385
|
+
const label = printable(file.path.replace(/\.md$/, ""));
|
|
386
|
+
try {
|
|
387
|
+
const found = await session.call(getEntry, {
|
|
388
|
+
collection: file.collection,
|
|
389
|
+
locale: file.locale,
|
|
390
|
+
slug: file.slug,
|
|
391
|
+
formats: ["markdown"]
|
|
392
|
+
});
|
|
393
|
+
if (found.outcome === "not_found" || found.entry === void 0) {
|
|
394
|
+
const created = await session.call(createDraft, {
|
|
395
|
+
collection: file.collection,
|
|
396
|
+
locale: file.locale,
|
|
397
|
+
slug: file.slug,
|
|
398
|
+
markdown: file.markdown
|
|
399
|
+
});
|
|
400
|
+
if (created.outcome !== "saved" || created.entry === void 0) throw new ToolFailure(`create_draft ${created.outcome}${refusalReasons(created.verdict)}`);
|
|
401
|
+
drafts.created += 1;
|
|
402
|
+
drafts.entries.push({
|
|
403
|
+
file,
|
|
404
|
+
summary: created.entry
|
|
405
|
+
});
|
|
406
|
+
out(draftLine("created", label, created.entry, created.verdict));
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const updated = await session.call(updateDraft, {
|
|
410
|
+
id: found.entry.id,
|
|
411
|
+
version: found.entry.version,
|
|
412
|
+
markdown: file.markdown
|
|
413
|
+
});
|
|
414
|
+
if (updated.outcome === "conflict") throw new ToolFailure(`update_draft conflict: someone changed the entry while it was imported${updated.conflict === void 0 ? "" : ` (it is at version ${updated.conflict.currentVersion} now)`}`);
|
|
415
|
+
if (updated.outcome === "saved" || updated.outcome === "unchanged") {
|
|
416
|
+
const entry = updated.entry ?? found.entry;
|
|
417
|
+
const kind = updated.outcome === "saved" ? "updated" : "unchanged";
|
|
418
|
+
drafts[kind] += 1;
|
|
419
|
+
drafts.entries.push({
|
|
420
|
+
file,
|
|
421
|
+
summary: entry
|
|
422
|
+
});
|
|
423
|
+
out(draftLine(kind, label, entry, updated.verdict));
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
throw new ToolFailure(`update_draft ${updated.outcome}${refusalReasons(updated.verdict)}`);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!(error instanceof ToolFailure)) throw error;
|
|
429
|
+
drafts.failed += 1;
|
|
430
|
+
out(`${"failed".padEnd(9)} ${label} ${error.message}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return drafts;
|
|
434
|
+
}
|
|
435
|
+
function refusalReasons(verdict) {
|
|
436
|
+
const reasons = (verdict?.errors ?? []).map((violation) => violationLine(violation));
|
|
437
|
+
return reasons.length === 0 ? "" : `: ${reasons.join("; ")}`;
|
|
438
|
+
}
|
|
439
|
+
function draftLine(kind, label, entry, verdict) {
|
|
440
|
+
const errors = verdict?.errors.length ?? 0;
|
|
441
|
+
const warnings = verdict?.warnings.length ?? 0;
|
|
442
|
+
const notes = [errors > 0 ? count(errors, "error") : void 0, warnings > 0 ? count(warnings, "warning") : void 0].filter((note) => note !== void 0);
|
|
443
|
+
return `${kind.padEnd(9)} ${label} ${entry.id} v${entry.version}${notes.length === 0 ? "" : ` (${notes.join(", ")})`}`;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Phase two: publishes each imported entry that has unpublished changes, at the version phase
|
|
447
|
+
* one left it. It acknowledges no warning: an entry with warnings is reported, not published.
|
|
448
|
+
*/
|
|
449
|
+
async function publishAll(session, entries, out) {
|
|
450
|
+
const result = {
|
|
451
|
+
published: 0,
|
|
452
|
+
needsAcknowledgement: 0,
|
|
453
|
+
blocked: 0,
|
|
454
|
+
other: 0
|
|
455
|
+
};
|
|
456
|
+
for (const { file, summary } of entries) {
|
|
457
|
+
const label = printable(file.path.replace(/\.md$/, ""));
|
|
458
|
+
const line = (outcome, details = []) => [`${outcome.padEnd(21)} ${label} ${summary.id}`, ...details.map((detail) => ` ${detail}`)].join("\n");
|
|
459
|
+
try {
|
|
460
|
+
const answer = await session.call(publish, {
|
|
461
|
+
id: summary.id,
|
|
462
|
+
version: summary.version
|
|
463
|
+
});
|
|
464
|
+
switch (answer.outcome) {
|
|
465
|
+
case "published":
|
|
466
|
+
result.published += 1;
|
|
467
|
+
out(line("published"));
|
|
468
|
+
break;
|
|
469
|
+
case "needs_acknowledgement":
|
|
470
|
+
result.needsAcknowledgement += 1;
|
|
471
|
+
out(line("needs_acknowledgement", (answer.verdict?.warnings ?? []).map(violationLine)));
|
|
472
|
+
break;
|
|
473
|
+
case "blocked":
|
|
474
|
+
result.blocked += 1;
|
|
475
|
+
out(line("blocked", (answer.verdict?.errors ?? []).map(violationLine)));
|
|
476
|
+
break;
|
|
477
|
+
case "conflict":
|
|
478
|
+
result.other += 1;
|
|
479
|
+
out(line("conflict", ["someone changed the entry after it was imported"]));
|
|
480
|
+
break;
|
|
481
|
+
case "not_found":
|
|
482
|
+
result.other += 1;
|
|
483
|
+
out(line("not_found", ["the entry is gone"]));
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (!(error instanceof ToolFailure)) throw error;
|
|
487
|
+
result.other += 1;
|
|
488
|
+
out(line("failed", [error.message]));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
function count(n, singular, plural = `${singular}s`) {
|
|
494
|
+
return `${n} ${n === 1 ? singular : plural}`;
|
|
495
|
+
}
|
|
496
|
+
function collectionSummary(files) {
|
|
497
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
498
|
+
for (const file of files) sizes.set(file.collection, (sizes.get(file.collection) ?? 0) + 1);
|
|
499
|
+
return [...sizes].map(([collection, size]) => `${printable(collection)}: ${size}`).join(", ");
|
|
500
|
+
}
|
|
501
|
+
function listSome(paths) {
|
|
502
|
+
const shown = paths.slice(0, 5).map(printable);
|
|
503
|
+
return paths.length > shown.length ? `${shown.join(", ")} and ${paths.length - shown.length} more` : shown.join(", ");
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
export { importContent };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,52 @@
|
|
|
1
|
+
//#region src/wrangler.d.ts
|
|
2
|
+
/** What a run of wrangler left behind. */
|
|
3
|
+
interface WranglerResult {
|
|
4
|
+
/** The exit code; 1 when wrangler was killed by a signal. */
|
|
5
|
+
code: number;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
}
|
|
9
|
+
/** Runs the project's wrangler with these arguments, from the project directory. */
|
|
10
|
+
type RunWrangler = (args: readonly string[]) => Promise<WranglerResult>;
|
|
11
|
+
/**
|
|
12
|
+
* A runner that starts the project's wrangler as a child process in `cwd`. Its stdin is closed
|
|
13
|
+
* and its output is captured, so wrangler runs without a terminal and never asks anything. It
|
|
14
|
+
* inherits the environment, which is where wrangler finds its Cloudflare credentials.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createWranglerRunner(cwd: string): RunWrangler;
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/io.d.ts
|
|
19
|
+
/**
|
|
20
|
+
* Everything a command takes from the outside world. `main` gets it as an argument and nothing
|
|
21
|
+
* in this package reads `process`, so a test hands in a fake of each part. The entry point of
|
|
22
|
+
* `index.ts` builds the real one.
|
|
23
|
+
*/
|
|
24
|
+
interface Io {
|
|
25
|
+
/** One message for stdout. Whoever owns the stream adds the trailing newline. */
|
|
26
|
+
out: (message: string) => void;
|
|
27
|
+
/** One message for stderr. */
|
|
28
|
+
err: (message: string) => void;
|
|
29
|
+
/** The directory a command works in: the project of `init`, `migrate` and `token`. Relative paths resolve against it. */
|
|
30
|
+
cwd: string;
|
|
31
|
+
/** The current time. */
|
|
32
|
+
now: () => Date;
|
|
33
|
+
/** Bytes from a cryptographically secure source, for ids and tokens. */
|
|
34
|
+
randomBytes: (length: number) => Uint8Array;
|
|
35
|
+
/** Everything piped into stdin, as text; `undefined` when stdin is a terminal and nothing was piped. */
|
|
36
|
+
readStdin: () => Promise<string | undefined>;
|
|
37
|
+
/** Runs the project's wrangler with these arguments. */
|
|
38
|
+
wrangler: RunWrangler;
|
|
39
|
+
/** A new empty directory for temporary files. Whoever asks for it removes it. */
|
|
40
|
+
tempDir: () => string;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
1
43
|
//#region src/index.d.ts
|
|
2
44
|
/**
|
|
3
|
-
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one
|
|
4
|
-
*
|
|
5
|
-
*
|
|
45
|
+
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one message;
|
|
46
|
+
* whoever owns the streams adds the trailing newline. The command is the first argument; the
|
|
47
|
+
* rest are its own. A `CliError` becomes one message on stderr and the exit code 1, and
|
|
48
|
+
* anything else is a bug, which escapes.
|
|
6
49
|
*/
|
|
7
|
-
export declare function main(argv: string[], io:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}): number;
|
|
11
|
-
//#endregion
|
|
50
|
+
export declare function main(argv: string[], io: Io): Promise<number>;
|
|
51
|
+
//#endregion
|
|
52
|
+
export type { Io, RunWrangler, WranglerResult };
|