hollware 1.0.0-rc.1 → 1.0.0-rc.2

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.
@@ -1,6 +1,6 @@
1
1
  import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
2
  import { t as printable } from "./format-Dvk_h5ys.mjs";
3
- import { n as failureReason } from "./wrangler-DO3h-eNH.mjs";
3
+ import { n as failureReason } from "./wrangler-C0QYL8dC.mjs";
4
4
  import { t as parseJsonc } from "./jsonc-CA2ezIB6.mjs";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
@@ -1,9 +1,9 @@
1
1
  import { t as CliError } from "./errors-DJX_jiSP.mjs";
2
- import { i as stripAnsi, n as failureReason } from "./wrangler-DO3h-eNH.mjs";
2
+ import { i as stripAnsi, n as failureReason } from "./wrangler-C0QYL8dC.mjs";
3
3
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
4
- import { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-BvAz-iyW.mjs";
4
+ import { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-B6dc_ugC.mjs";
5
5
  import { t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
6
- import { i as migrate } from "./migrate-D2QcagMk.mjs";
6
+ import { i as migrate } from "./migrate-Dx-imcel.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
@@ -16,13 +16,30 @@ const DEPLOY_USAGE = [
16
16
  "",
17
17
  "HOLLWARE_PUBLIC_URL must be the public URL of the Worker. While it is a localhost URL, the",
18
18
  "workers.dev URL that wrangler prints is written into wrangler.jsonc, and the Worker is deployed",
19
- "again with it. Run \"wrangler login\" first.",
19
+ "again with it. A URL on a domain of your own (not workers.dev) also gets a route in",
20
+ "wrangler.jsonc that serves the Worker there as a custom domain: Cloudflare makes its DNS record",
21
+ "and its certificate, in a zone of the same account. Run \"wrangler login\" first.",
20
22
  "",
21
23
  "Options:",
22
24
  " --url <url> The public URL of the Worker, such as https://cms.example.com (default: the",
23
25
  " HOLLWARE_PUBLIC_URL of wrangler.jsonc, or the workers.dev URL)",
24
26
  " --help Show this help"
25
27
  ].join("\n");
28
+ /** "Run wrangler login" only when wrangler says so: another failure has nothing to do with it. */
29
+ function loginHint(result) {
30
+ return /not logged in|log ?in|authenticat|oauth|api token/i.test(`${result.stdout}\n${result.stderr}`) ? " Is wrangler logged in (\"wrangler login\")?" : "";
31
+ }
32
+ /**
33
+ * `wrangler types` after deploy rewrote wrangler.jsonc: the types of the Worker hold the value of
34
+ * HOLLWARE_PUBLIC_URL, and `wrangler types --check` fails on the old one. A project without the
35
+ * generated file has nothing to keep current.
36
+ */
37
+ async function regenerateTypes(io) {
38
+ if (!existsSync(join(io.cwd, "worker-configuration.d.ts"))) return;
39
+ const typed = await io.wrangler(["types"]);
40
+ if (typed.code !== 0) throw new CliError(`wrangler could not write the types of the Worker: ${failureReason(typed)}`);
41
+ io.out("Wrote worker-configuration.d.ts again, for the new wrangler.jsonc.");
42
+ }
26
43
  /** The workers.dev URL that `wrangler deploy` prints, or `undefined`. */
27
44
  function workersDevUrlOf(stdout) {
28
45
  const match = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev(?![\w.-])/i.exec(stripAnsi(stdout));
@@ -33,6 +50,24 @@ function isLoopback(url) {
33
50
  const { hostname } = new URL(url);
34
51
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
35
52
  }
53
+ /** The host of a public URL on a domain of its own: neither this machine nor workers.dev. */
54
+ function customDomainOf(url) {
55
+ if (isLoopback(url)) return void 0;
56
+ const { hostname } = new URL(url);
57
+ return /(?:^|\.)workers\.dev$/i.test(hostname) ? void 0 : hostname;
58
+ }
59
+ const ROUTES_KEY = /^\s*"routes"\s*:/m;
60
+ /** The first key of the starter's wrangler.jsonc, at the top level of the object. */
61
+ const TOP_LEVEL_NAME = /^ {2}"name"\s*:\s*"[^"\n]*",[^\n]*\n/m;
62
+ /**
63
+ * `wrangler.jsonc` with a route that serves the Worker at `host` as a custom domain, after its
64
+ * `name`, and everything else as it was. A config with routes of its own is left as it is.
65
+ */
66
+ function withCustomDomain(text, host) {
67
+ if (ROUTES_KEY.test(text)) return text;
68
+ if (!TOP_LEVEL_NAME.test(text)) throw new CliError(`wrangler.jsonc has no "name" at its top. Add "routes": [{ "pattern": "${host}", "custom_domain": true }] to it.`);
69
+ return text.replace(TOP_LEVEL_NAME, (line) => `${line} // The custom domain of the Worker: Cloudflare makes its DNS record and its certificate.\n "routes": [{ "pattern": ${JSON.stringify(host)}, "custom_domain": true }],\n`);
70
+ }
36
71
  const PUBLIC_URL_VALUE = /("HOLLWARE_PUBLIC_URL"\s*:\s*)"[^"\n]*"/;
37
72
  /**
38
73
  * `wrangler.jsonc` with `HOLLWARE_PUBLIC_URL` set to `url`, and everything else as it was: its
@@ -44,7 +79,7 @@ function withPublicUrl(text, url) {
44
79
  }
45
80
  async function wranglerDeploy(io) {
46
81
  const deployed = await io.wrangler(["deploy"]);
47
- if (deployed.code !== 0) throw new CliError(`wrangler could not deploy: ${failureReason(deployed)}. Is wrangler logged in ("wrangler login")?`);
82
+ if (deployed.code !== 0) throw new CliError(`wrangler could not deploy: ${failureReason(deployed).replace(/\.$/, "")}.${loginHint(deployed)}`);
48
83
  return deployed.stdout;
49
84
  }
50
85
  async function deploy(argv, io) {
@@ -65,14 +100,29 @@ async function deploy(argv, io) {
65
100
  const configured = publicUrlOf(io.cwd, void 0);
66
101
  const asked = values.url === void 0 ? void 0 : publicUrlOf(io.cwd, values.url);
67
102
  const database = resolveDatabase(io.cwd, void 0);
103
+ const known = asked ?? (configured !== void 0 && !isLoopback(configured) ? configured : void 0);
104
+ const domain = known === void 0 ? void 0 : customDomainOf(known);
105
+ let written = configured;
106
+ if (known !== void 0 && domain !== void 0) {
107
+ const before = readFileSync(file, "utf8");
108
+ const withUrl = known === configured ? before : withPublicUrl(before, known);
109
+ const after = withCustomDomain(withUrl, domain);
110
+ if (after === withUrl && !withUrl.includes(JSON.stringify(domain))) io.out(`wrangler.jsonc has routes of its own and none for ${domain}: add { "pattern": "${domain}", "custom_domain": true } to them.`);
111
+ if (after !== before) {
112
+ writeFileSync(file, after);
113
+ io.out(`Serving the Worker at ${domain}: set HOLLWARE_PUBLIC_URL and its route in wrangler.jsonc.`);
114
+ await regenerateTypes(io);
115
+ }
116
+ written = known;
117
+ }
68
118
  io.out("Deploying with wrangler ...");
69
119
  const printed = await wranglerDeploy(io);
70
- let url = asked ?? (configured !== void 0 && !isLoopback(configured) ? configured : void 0);
71
- url ??= workersDevUrlOf(printed);
120
+ const url = known ?? workersDevUrlOf(printed);
72
121
  if (url === void 0) throw new CliError("wrangler deployed the Worker but printed no workers.dev URL. Run \"hollware deploy --url <the public URL of the Worker>\".");
73
- if (url !== configured) {
122
+ if (url !== written) {
74
123
  writeFileSync(file, withPublicUrl(readFileSync(file, "utf8"), url));
75
124
  io.out(`Set HOLLWARE_PUBLIC_URL to ${url} in wrangler.jsonc. Deploying again with it ...`);
125
+ await regenerateTypes(io);
76
126
  await wranglerDeploy(io);
77
127
  }
78
128
  await ensureMasterSecret(io);
@@ -88,7 +138,7 @@ async function deploy(argv, io) {
88
138
  ` MCP ${url}/mcp`,
89
139
  ` Admin ${url}/admin/`,
90
140
  "",
91
- "Commit wrangler.jsonc: it holds the ids of what the deploy created, and the public URL.",
141
+ "Commit wrangler.jsonc: it holds the public URL, and any id wrangler wrote into it.",
92
142
  ...owners.length === 0 ? [
93
143
  "",
94
144
  "Next: create your site and its owner in the remote database. It prints a token for your",
@@ -1,5 +1,5 @@
1
1
  import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
- import { r as findBin } from "./wrangler-DO3h-eNH.mjs";
2
+ import { r as findBin } from "./wrangler-C0QYL8dC.mjs";
3
3
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
4
4
  import { t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
5
5
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -16,10 +16,13 @@ const DEV_USAGE = [
16
16
  "With --detach it starts the server in the background, writes its process id and URL to",
17
17
  `.wrangler/hollware-dev.json, prints the ready line and exits 0. "hollware dev --stop" stops what`,
18
18
  "that file names.",
19
+ "Once \"hollware deploy\" has written the public URL into wrangler.jsonc, the local server still",
20
+ "answers at localhost: the vite.config.ts of the project sets HOLLWARE_PUBLIC_URL to it in",
21
+ "development, and hollware dev waits for it there.",
19
22
  "",
20
23
  "Options:",
21
- " --url <url> Where the server listens (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc,",
22
- " else http://localhost:5199)",
24
+ " --url <url> Where the server listens (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc",
25
+ " when it is a localhost URL, else http://localhost:5199)",
23
26
  " --timeout <seconds> How long to wait for /health, 1 to 600 (default: 60)",
24
27
  " --detach Start it in the background and exit",
25
28
  " --stop Stop the server that --detach started",
@@ -56,7 +59,8 @@ async function dev(argv, io) {
56
59
  return stop(io, host, json);
57
60
  }
58
61
  const timeout = timeoutOf(values.timeout);
59
- const url = (publicUrlOf(io.cwd, values.url) ?? DEFAULT_URL).replace(/\/$/, "");
62
+ const configured = publicUrlOf(io.cwd, void 0);
63
+ const url = values.url !== void 0 ? publicUrlOf(io.cwd, values.url) ?? DEFAULT_URL : configured !== void 0 && isLoopback(configured) ? configured : DEFAULT_URL;
60
64
  const bin = findBin(io.cwd, "vp");
61
65
  if (bin === void 0) throw new CliError(`vp is not installed in ${io.cwd} or any directory above it. Install the dependencies of the project (for example "pnpm install") and run hollware dev from the project directory.`);
62
66
  const detach = values.detach === true;
@@ -103,6 +107,11 @@ async function dev(argv, io) {
103
107
  off();
104
108
  }
105
109
  }
110
+ /** Whether a URL is this machine's. */
111
+ function isLoopback(url) {
112
+ const { hostname } = new URL(url);
113
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
114
+ }
106
115
  async function stop(io, host, json) {
107
116
  const state = readState(io.cwd);
108
117
  if (state === void 0) {
@@ -1,9 +1,9 @@
1
1
  import { t as CliError } from "./errors-DJX_jiSP.mjs";
2
- import { n as failureReason } from "./wrangler-DO3h-eNH.mjs";
2
+ import { n as failureReason } from "./wrangler-C0QYL8dC.mjs";
3
3
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
4
4
  import { t as parseJsonc } from "./jsonc-CA2ezIB6.mjs";
5
- import { f as ownerQuery, r as query, t as chooseTarget } from "./d1-BvAz-iyW.mjs";
6
- import { a as parsePendingMigrations } from "./migrate-D2QcagMk.mjs";
5
+ import { f as ownerQuery, r as query, t as chooseTarget } from "./d1-B6dc_ugC.mjs";
6
+ import { a as parsePendingMigrations } from "./migrate-Dx-imcel.mjs";
7
7
  import { existsSync, readFileSync, readdirSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  //#region src/commands/doctor.ts
@@ -59,6 +59,10 @@ function checkConfig(cwd, config) {
59
59
  findings.push(db === void 0 ? fail("d1-binding", `there is no D1 binding "DB"`, "Add it to d1_databases in wrangler.jsonc, as the starter has it.") : migrations.length === 0 ? fail("d1-binding", `the D1 binding "DB" has no migrations in its migrations_dir`, "Set migrations_dir to \"node_modules/@hollware/server/migrations\", and install the dependencies.") : pass("d1-binding", `the D1 binding "DB" has ${migrations.length} migrations`));
60
60
  const kv = records(config["kv_namespaces"]).some((entry) => entry["binding"] === "OAUTH_KV");
61
61
  findings.push(kv ? pass("kv-binding", "the KV binding \"OAUTH_KV\" is there") : fail("kv-binding", "there is no KV binding \"OAUTH_KV\", which the OAuth server needs", "Add { \"binding\": \"OAUTH_KV\" } to kv_namespaces in wrangler.jsonc."));
62
+ const media = records(config["r2_buckets"]).some((entry) => entry["binding"] === "MEDIA");
63
+ findings.push(media ? pass("media-bucket", "the R2 bucket \"MEDIA\" keeps the images of the sites") : fail("media-bucket", "there is no R2 bucket \"MEDIA\", so upload_asset answers unavailable", "Add { \"binding\": \"MEDIA\", \"bucket_name\": \"<name>-media\" } to r2_buckets in wrangler.jsonc; hollware deploy creates the bucket."));
64
+ const images = isRecord(config["images"]) && config["images"]["binding"] === "IMAGES";
65
+ findings.push(pass("images-binding", images ? "the Images binding \"IMAGES\" converts each image once, by the media setting of its site" : "there is no Images binding \"IMAGES\": images are kept as they come, unconverted (add \"images\": { \"binding\": \"IMAGES\" } to convert them)"));
62
66
  const assets = isRecord(config["assets"]) ? config["assets"] : void 0;
63
67
  const directory = typeof assets?.["directory"] === "string" ? assets["directory"] : void 0;
64
68
  const admin = directory !== void 0 && existsSync(join(cwd, directory, "admin"));
@@ -2,15 +2,15 @@ import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
2
  import { t as printable } from "./format-Dvk_h5ys.mjs";
3
3
  import { t as readVersion } from "./version-BMhdZaNo.mjs";
4
4
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
5
- import { a as redact, c as openSession, i as readToken, o as withoutToken, r as mcpEndpoint, s as ToolFailure } from "./import-CR4G8q5W.mjs";
5
+ import { a as redact, c as openSession, i as readToken, o as withoutToken, r as mcpEndpoint, s as ToolFailure } from "./import-BpIvnM-l.mjs";
6
6
  import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join, resolve } from "node:path";
8
- import { describeSite, getEntry, listEntries, schemas } from "@hollware/core";
8
+ import { EXTENSIONS, describeSite, getAsset, getEntry, listEntries, schemas } from "@hollware/core";
9
9
  //#region src/commands/export.ts
10
10
  const EXPORT_USAGE = [
11
11
  "Usage: hollware export <directory> --url <hollware url> (--token-file <file> | token on stdin)",
12
12
  " [--site <key>] [--collection <name>]... [--locale <code>]...",
13
- " [--published] [--dry-run]",
13
+ " [--published] [--assets <dir> --assets-url <path>] [--dry-run]",
14
14
  "",
15
15
  "Writes the entries of a running Hollware, read over MCP, as Markdown files at",
16
16
  "<directory>/<collection>/<locale>/<slug>.md: the layout \"hollware import\" reads. Each file is",
@@ -26,6 +26,11 @@ const EXPORT_USAGE = [
26
26
  " --locale <code> Export only this locale; repeat it for several",
27
27
  " --published Export only the published entries, as they are published; by default the",
28
28
  " latest revision of every entry, drafts included",
29
+ " --assets <dir> Write the images the entries show to <dir>, as the site serves them,",
30
+ " and point each asset: reference at --assets-url: for a site that",
31
+ " serves its images itself. Without it, the images go to <directory>/_assets",
32
+ " as they were uploaded, with assets.json, which \"hollware import\" uploads again",
33
+ " --assets-url <path> With --assets: the path the site serves <dir> at, such as /images/cms/",
29
34
  " --dry-run List the files it would write, and write nothing",
30
35
  " --help Show this help",
31
36
  "",
@@ -55,6 +60,8 @@ async function exportContent(argv, io) {
55
60
  multiple: true
56
61
  },
57
62
  published: { type: "boolean" },
63
+ assets: { type: "string" },
64
+ "assets-url": { type: "string" },
58
65
  "dry-run": { type: "boolean" },
59
66
  help: { type: "boolean" }
60
67
  }
@@ -66,9 +73,16 @@ async function exportContent(argv, io) {
66
73
  const [directory, ...extra] = positionals;
67
74
  if (directory === void 0 || extra.length > 0) throw new UsageError("export takes one directory: hollware export <directory> --url <url>.");
68
75
  if (values.url === void 0) throw new UsageError("--url is required: the address of your Hollware, such as https://cms.example.com.");
76
+ if (values.assets === void 0 !== (values["assets-url"] === void 0)) throw new UsageError("--assets and --assets-url go together: where to write the images, and the path the site serves them at.");
69
77
  const endpoint = mcpEndpoint(values.url);
70
78
  const root = resolve(io.cwd, directory);
71
79
  checkTarget(root, directory);
80
+ const assets = values.assets === void 0 || values["assets-url"] === void 0 ? void 0 : {
81
+ root: resolve(io.cwd, values.assets),
82
+ directory: values.assets,
83
+ url: values["assets-url"]
84
+ };
85
+ if (assets !== void 0) checkTarget(assets.root, assets.directory);
72
86
  const token = await readToken(io, values["token-file"]);
73
87
  try {
74
88
  return await exportEntries({
@@ -81,6 +95,7 @@ async function exportContent(argv, io) {
81
95
  collections: values.collection,
82
96
  locales: values.locale,
83
97
  published: values.published === true,
98
+ assets,
84
99
  dryRun: values["dry-run"] === true
85
100
  });
86
101
  } catch (error) {
@@ -100,6 +115,8 @@ function checkTarget(root, directory) {
100
115
  }
101
116
  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.`);
102
117
  }
118
+ /** Every `asset:` reference in Markdown, by id (ADR-038). */
119
+ const ASSET_REFERENCE = /asset:(ast_[0-9a-f]{26})/g;
103
120
  /**
104
121
  * Reads everything first, then writes: a call that fails stops the export before any file is
105
122
  * written.
@@ -109,12 +126,14 @@ async function exportEntries(plan) {
109
126
  const out = (message) => io.out(redact(message, token));
110
127
  const session = await openSession(plan.endpoint, token, readVersion());
111
128
  let files;
129
+ let images;
112
130
  let siteKey;
113
131
  try {
114
132
  const { site } = await session.call(describeSite, {});
115
133
  siteKey = site.key;
116
134
  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)}.`);
117
135
  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);
136
+ images = await readImages(session, plan, files);
118
137
  } finally {
119
138
  await session.close();
120
139
  }
@@ -123,8 +142,10 @@ async function exportEntries(plan) {
123
142
  out(`No entries to export from the site ${printable(siteKey)} (${view}). Nothing was written.`);
124
143
  return 0;
125
144
  }
145
+ const imagesRoot = plan.assets?.root ?? join(root, "_assets");
146
+ const imagesShown = plan.assets?.directory ?? join(directory, "_assets");
126
147
  if (dryRun) {
127
- out(files.map((file) => `would write ${printable(file.path)}`).join("\n"));
148
+ out([...files.map((file) => `would write ${printable(file.path)}`), ...images.map((image) => `would write ${printable(join(imagesShown, image.path))}`)].join("\n"));
128
149
  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.`);
129
150
  return 0;
130
151
  }
@@ -139,6 +160,16 @@ async function exportEntries(plan) {
139
160
  }
140
161
  written += 1;
141
162
  }
163
+ for (const image of images) {
164
+ const target = join(imagesRoot, image.path);
165
+ try {
166
+ mkdirSync(dirname(target), { recursive: true });
167
+ writeFileSync(target, image.bytes, { flag: "wx" });
168
+ } catch (error) {
169
+ throw new CliError(`could not write ${printable(join(imagesShown, image.path))}: ${error instanceof Error ? error.message : String(error)}. Remove ${printable(directory)} and run the export again.`);
170
+ }
171
+ }
172
+ if (images.length > 0) out(`Wrote ${count(images.filter((image) => image.path !== "assets.json").length, "image")} to ${printable(imagesShown)}.`);
142
173
  out(`Exported ${count(files.length, "entry", "entries")} of the site ${printable(siteKey)} to ${printable(directory)} (${view}):\n${groupSummary(files)}`);
143
174
  return 0;
144
175
  }
@@ -213,6 +244,55 @@ async function readAll(session, listed, published) {
213
244
  }
214
245
  return files;
215
246
  }
247
+ /**
248
+ * The images the entries show (ADR-038). With --assets: each as the site serves it, at
249
+ * `<id>.<ext>`, and every `asset:` reference of the files pointed at `<assets-url><id>.<ext>`.
250
+ * Without it: each as it was uploaded, at `_assets/<id>.<ext>`, and `assets.json` with what
251
+ * `hollware import` needs to upload it again under the same id. Reads everything before it
252
+ * writes, as the entries are read.
253
+ */
254
+ async function readImages(session, plan, files) {
255
+ const ids = [...new Set(files.flatMap((file) => [...file.markdown.matchAll(ASSET_REFERENCE)].map((match) => match[1] ?? "")))].sort();
256
+ if (ids.length === 0) return [];
257
+ const images = [];
258
+ const manifest = {};
259
+ const urls = /* @__PURE__ */ new Map();
260
+ for (const id of ids) {
261
+ const answer = await session.call(getAsset, { id });
262
+ const asset = answer.asset;
263
+ if (answer.outcome !== "ok" || asset === void 0) throw new CliError(`an entry shows the image ${id}, which the site does not have. Nothing was written.`);
264
+ if (plan.assets !== void 0) {
265
+ const name = `${id}.${EXTENSIONS[asset.format]}`;
266
+ images.push({
267
+ path: name,
268
+ bytes: await download(new URL(asset.url), null, id)
269
+ });
270
+ urls.set(id, `${plan.assets.url}${name}`);
271
+ } else {
272
+ const original = new URL(`/v1/assets/${id}/original`, plan.endpoint);
273
+ const name = `${id}.${EXTENSIONS[asset.originalFormat]}`;
274
+ images.push({
275
+ path: name,
276
+ bytes: await download(original, plan.token, id)
277
+ });
278
+ manifest[name] = {
279
+ alt: asset.alt,
280
+ filename: asset.filename
281
+ };
282
+ }
283
+ }
284
+ if (plan.assets !== void 0) for (const file of files) file.markdown = file.markdown.replace(ASSET_REFERENCE, (reference, id) => urls.get(id) ?? reference);
285
+ else images.push({
286
+ path: "assets.json",
287
+ bytes: new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`)
288
+ });
289
+ return images;
290
+ }
291
+ async function download(url, token, id) {
292
+ const response = await fetch(url, token === null ? {} : { headers: { authorization: `Bearer ${token}` } });
293
+ if (!response.ok) throw new CliError(`could not download the image ${id} (${response.status} from ${url.pathname}). Nothing was written.`);
294
+ return new Uint8Array(await response.arrayBuffer());
295
+ }
216
296
  function sentence(message) {
217
297
  return message.replace(/[\s.]+$/, "");
218
298
  }
@@ -2,9 +2,9 @@ import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
2
  import { t as printable } from "./format-Dvk_h5ys.mjs";
3
3
  import { t as readVersion } from "./version-BMhdZaNo.mjs";
4
4
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
5
- import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
- import { REFUSAL_CONTRACT_IDS, createDraft, getEntry, isPersonalToken, publish, schemas, updateDraft, validate } from "@hollware/core";
7
+ import { REFUSAL_CONTRACT_IDS, createDraft, getEntry, isPersonalToken, publish, schemas, updateDraft, uploadAsset, validate } from "@hollware/core";
8
8
  import { Client, InsufficientScopeError, ProtocolError, SdkError, SdkErrorCode, SdkHttpError, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client";
9
9
  //#region src/import/session.ts
10
10
  /** A tool failed and said so in words, instead of answering with one of its outcomes. */
@@ -338,6 +338,7 @@ async function importContent(argv, io) {
338
338
  token,
339
339
  endpoint,
340
340
  directory,
341
+ root,
341
342
  corpus,
342
343
  publish: values.publish === true,
343
344
  dryRun: values["dry-run"] === true
@@ -359,7 +360,7 @@ function withoutToken(error, token) {
359
360
  function sourceOf(from, root, options) {
360
361
  return from === "markdown" ? readCorpus(root) : corpusFromNuxtContent(root, options);
361
362
  }
362
- async function importFiles({ io, token, endpoint, directory, corpus, publish: publishToo, dryRun }) {
363
+ async function importFiles({ io, token, endpoint, directory, root, corpus, publish: publishToo, dryRun }) {
363
364
  const out = (message) => io.out(redact(message, token));
364
365
  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.`);
365
366
  if (corpus.files.length > 0) out(`Found ${count(corpus.files.length, "entry", "entries")} in ${directory} (${collectionSummary(corpus.files)}).`);
@@ -371,6 +372,7 @@ async function importFiles({ io, token, endpoint, directory, corpus, publish: pu
371
372
  ].join("\n"));
372
373
  const session = await openSession(endpoint, token, readVersion());
373
374
  try {
375
+ await uploadImages(session, root, directory, dryRun, out);
374
376
  const checks = await validateAll(session, corpus.files);
375
377
  if (dryRun) out(dryRunReport(checks));
376
378
  const refused = checks.filter((check) => check.refusals.length > 0 || check.failure !== void 0);
@@ -668,5 +670,42 @@ function listSome(paths) {
668
670
  const shown = paths.slice(0, 5).map(printable);
669
671
  return paths.length > shown.length ? `${shown.join(", ")} and ${paths.length - shown.length} more` : shown.join(", ");
670
672
  }
673
+ /**
674
+ * The images an export kept in `_assets/` (ADR-038), uploaded before the entries that show them:
675
+ * each is the file as it was uploaded, so it gets its id again and every `asset:` reference of the
676
+ * entries still names it. An image the site has already is `exists`, and nothing changes.
677
+ */
678
+ async function uploadImages(session, root, directory, dryRun, out) {
679
+ const manifestFile = join(root, "_assets", "assets.json");
680
+ if (!existsSync(manifestFile)) return;
681
+ let manifest;
682
+ try {
683
+ manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
684
+ } catch {
685
+ throw new CliError(`${directory}/_assets/assets.json is not JSON. Nothing was sent.`);
686
+ }
687
+ const names = Object.keys(manifest).sort();
688
+ if (dryRun) {
689
+ out(`Would upload ${count(names.length, "image")} of ${directory}/_assets.`);
690
+ return;
691
+ }
692
+ let created = 0;
693
+ let existing = 0;
694
+ for (const name of names) {
695
+ const { alt, filename } = manifest[name] ?? {};
696
+ if (!/^ast_[0-9a-f]{26}\.[a-z]+$/.test(name) || typeof alt !== "string") throw new CliError(`${directory}/_assets/assets.json names ${printable(name)} without its alt, or not as an export writes it. Nothing more was sent.`);
697
+ const data = readFileSync(join(root, "_assets", name)).toString("base64");
698
+ const answer = await session.call(uploadAsset, {
699
+ filename: typeof filename === "string" && filename !== "" ? filename : name,
700
+ alt,
701
+ data
702
+ });
703
+ if (answer.outcome === "created") created += 1;
704
+ else if (answer.outcome === "exists") existing += 1;
705
+ else throw new CliError(`could not upload ${printable(name)}: ${answer.problem?.message ?? answer.outcome} ${answer.problem?.fix ?? ""}`.trim());
706
+ if (answer.asset !== void 0 && `${answer.asset.id}.` !== name.slice(0, answer.asset.id.length + 1)) throw new CliError(`${printable(name)} came back as ${answer.asset.id}: the file is not the one the export wrote.`);
707
+ }
708
+ out(`Images: ${created} uploaded, ${existing} already there.`);
709
+ }
671
710
  //#endregion
672
711
  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-BpIvnM-l.mjs";
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
3
3
  import { t as printable } from "./format-Dvk_h5ys.mjs";
4
4
  import { t as readVersion } from "./version-BMhdZaNo.mjs";
5
- import { t as createWranglerRunner } from "./wrangler-DO3h-eNH.mjs";
5
+ import { t as createWranglerRunner } from "./wrangler-C0QYL8dC.mjs";
6
6
  import { spawn } from "node:child_process";
7
7
  import { mkdtempSync, realpathSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
@@ -114,16 +114,16 @@ async function main(argv, io) {
114
114
  }
115
115
  async function run(command, args, io) {
116
116
  switch (command) {
117
- case "init": return (await import("./init-BozoqmFw.mjs")).init(args, io);
118
- case "dev": return (await import("./dev--aG_y4gx.mjs")).dev(args, io);
117
+ case "init": return (await import("./init-A7Un-Ng_.mjs")).init(args, io);
118
+ case "dev": return (await import("./dev-B2gk-YHQ.mjs")).dev(args, io);
119
119
  case "mcp-config": return (await import("./mcp-config-CDOUrEpU.mjs")).mcpConfig(args, io);
120
- case "migrate": return (await import("./migrate-B2GrwRh4.mjs")).migrate(args, io);
121
- case "deploy": return (await import("./deploy-DsHNryRG.mjs")).deploy(args, io);
122
- case "doctor": return (await import("./doctor-fpXiQgnU.mjs")).doctor(args, io);
123
- case "token": return (await import("./token-BG3q6bWZ.mjs")).token(args, io);
124
- case "invite": return (await import("./invite-tg1_MWv-.mjs")).invite(args, io);
125
- case "export": return (await import("./export-Ims-m9Rl.mjs")).exportContent(args, io);
126
- default: return (await import("./import-D4_IhUC7.mjs")).importContent(args, io);
120
+ case "migrate": return (await import("./migrate-C15IVa7w.mjs")).migrate(args, io);
121
+ case "deploy": return (await import("./deploy-sYjaZ0d4.mjs")).deploy(args, io);
122
+ case "doctor": return (await import("./doctor-DjRkQKrh.mjs")).doctor(args, io);
123
+ case "token": return (await import("./token-DG5H7VPC.mjs")).token(args, io);
124
+ case "invite": return (await import("./invite-CsGI2Gut.mjs")).invite(args, io);
125
+ case "export": return (await import("./export-HsextQcU.mjs")).exportContent(args, io);
126
+ default: return (await import("./import-CsbL7X6d.mjs")).importContent(args, io);
127
127
  }
128
128
  }
129
129
  function isEntryPoint() {
@@ -1,6 +1,6 @@
1
1
  import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
2
  import { t as parseFlags } from "./args-D42wi6n2.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-BvAz-iyW.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-B6dc_ugC.mjs";
4
4
  import { n as setupLink, t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
5
5
  import { a as newInvitationSecret, i as newId, n as tokenFileOf, o as newPersonalToken, r as inDays, t as deliverToken } from "./token-output-DTn4X40H.mjs";
6
6
  import { a as mcpJson, i as claudeAddLine, o as setVariableLine } from "./mcp-config-BU2YmmGE.mjs";
@@ -1,9 +1,9 @@
1
1
  import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
2
  import { t as parseFlags } from "./args-D42wi6n2.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-BvAz-iyW.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-B6dc_ugC.mjs";
4
4
  import { n as setupLink, t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
5
5
  import { a as newInvitationSecret, i as newId, r as inDays } from "./token-output-DTn4X40H.mjs";
6
- import { o as WHERE_OPTIONS, s as chooseSite } from "./token-B0UKrZBf.mjs";
6
+ import { o as WHERE_OPTIONS, s as chooseSite } from "./token-BxAQXSQO.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-Dx-imcel.mjs";
@@ -1,7 +1,7 @@
1
1
  import { t as CliError } from "./errors-DJX_jiSP.mjs";
2
- import { i as stripAnsi, n as failureReason } from "./wrangler-DO3h-eNH.mjs";
2
+ import { i as stripAnsi, n as failureReason } from "./wrangler-C0QYL8dC.mjs";
3
3
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
4
- import { i as resolveDatabase, n as describeDatabase, t as chooseTarget } from "./d1-BvAz-iyW.mjs";
4
+ import { i as resolveDatabase, n as describeDatabase, t as chooseTarget } from "./d1-B6dc_ugC.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,7 +1,7 @@
1
1
  import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
2
2
  import { n as table, t as printable } from "./format-Dvk_h5ys.mjs";
3
3
  import { t as parseFlags } from "./args-D42wi6n2.mjs";
4
- 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-BvAz-iyW.mjs";
4
+ 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-B6dc_ugC.mjs";
5
5
  import { t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
6
6
  import { i as newId, n as tokenFileOf, o as newPersonalToken, r as inDays, s as newReadToken, t as deliverToken } from "./token-output-DTn4X40H.mjs";
7
7
  import { hashPersonalToken } from "@hollware/core";
@@ -0,0 +1 @@
1
+ export { c as token } from "./token-BxAQXSQO.mjs";
@@ -80,12 +80,18 @@ function failureReason(result) {
80
80
  const stderr = stripAnsi(result.stderr);
81
81
  const fromJson = jsonError(stdout.trim());
82
82
  if (fromJson !== void 0) return oneLine(fromJson);
83
- const stderrLines = linesOf(stderr);
84
- for (const line of stderrLines) {
83
+ const raw = stderr.split("\n").map((line) => line.trim());
84
+ for (const [index, line] of raw.entries()) {
85
85
  const message = /^\W*\[ERROR\]\s*(.+)$/.exec(line)?.[1];
86
- if (message !== void 0) return oneLine(message);
86
+ if (message === void 0) continue;
87
+ const parts = [message];
88
+ for (const next of raw.slice(index + 1)) {
89
+ if (next === "" || /^\W*\[(?:ERROR|WARNING|INFO)\]/.test(next)) break;
90
+ parts.push(next);
91
+ }
92
+ return oneLine(parts.join(" "));
87
93
  }
88
- return oneLine(stderrLines.at(-1) ?? linesOf(stdout).at(-1) ?? `wrangler exited with code ${result.code} and printed nothing`);
94
+ return oneLine(linesOf(stderr).at(-1) ?? linesOf(stdout).at(-1) ?? `wrangler exited with code ${result.code} and printed nothing`);
89
95
  }
90
96
  function linesOf(text) {
91
97
  return text.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.includes("Logs were written to"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hollware",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "The Hollware CLI: init, migrate, token, import and export.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -31,7 +31,7 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@hollware/core": "1.0.0-rc.1",
34
+ "@hollware/core": "1.0.0-rc.2",
35
35
  "@modelcontextprotocol/client": "2.3.1",
36
36
  "zod": "4.6.7"
37
37
  },
@@ -1 +0,0 @@
1
- export { n as importContent } from "./import-CR4G8q5W.mjs";
@@ -1 +0,0 @@
1
- export { i as migrate } from "./migrate-D2QcagMk.mjs";
@@ -1 +0,0 @@
1
- export { c as token } from "./token-B0UKrZBf.mjs";