create-hollware 1.0.0-rc.0 → 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.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
- import { basename, join, resolve } from "node:path";
5
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
7
  //#region src/scaffold.ts
8
8
  const USAGE = [
@@ -21,6 +21,12 @@ const USAGE = [
21
21
  " Whether the activity log keeps a keyed hash of each address (default: off)",
22
22
  " --audit-retention-days <n>",
23
23
  " Days each line of the activity log is kept, 30 to 3650 (default: forever)",
24
+ " --token-file <path>",
25
+ " Write the first token to this file (mode 0600) and print its path, not the token;",
26
+ " a file that exists is refused. Inside the project, it goes into .gitignore",
27
+ " --json One JSON object on stdout and nothing else: dir, then what \"hollware init\" says",
28
+ " (site, ownerId, tokenId, token or tokenFile, expiresAt, setupUrl, mcpUrl,",
29
+ " readApiUrl), the commands to start it and the MCP configuration",
24
30
  " --pm <npm|pnpm> The package manager that installs (default: npm)",
25
31
  " --yes Take the defaults: <dir> is my-hollware when it is not given",
26
32
  " --no-install Do not install, migrate or make the site",
@@ -42,7 +48,6 @@ const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
42
48
  const SITE_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
43
49
  /** What `@hollware/*` and `hollware` say in the template's package.json. */
44
50
  const VERSION_PLACEHOLDER = "HOLLWARE_VERSION";
45
- const TOKEN = /hw_pat_[0-9a-hjkmnp-tv-z]{52}/;
46
51
  /** The choices of a run, from its arguments. */
47
52
  function readChoices(argv) {
48
53
  const { values, positionals } = (() => {
@@ -60,7 +65,9 @@ function readChoices(argv) {
60
65
  pm: { type: "string" },
61
66
  yes: { type: "boolean" },
62
67
  "no-install": { type: "boolean" },
63
- "no-git": { type: "boolean" }
68
+ "no-git": { type: "boolean" },
69
+ "token-file": { type: "string" },
70
+ json: { type: "boolean" }
64
71
  }
65
72
  });
66
73
  } catch (error) {
@@ -84,6 +91,8 @@ function readChoices(argv) {
84
91
  pm,
85
92
  install: values["no-install"] !== true,
86
93
  git: values["no-git"] !== true,
94
+ tokenFile: values["token-file"],
95
+ json: values.json === true,
87
96
  initFlags: [
88
97
  ...values.preset === void 0 ? [] : ["--preset", values.preset],
89
98
  ...values.locales === void 0 ? [] : ["--locales", values.locales],
@@ -105,6 +114,7 @@ function writeProject(io, target, choices) {
105
114
  replaceIn(join(target, "wrangler.jsonc"), [
106
115
  ["\"name\": \"my-hollware\"", `"name": "${choices.name}"`],
107
116
  ["\"database_name\": \"my-hollware\"", `"database_name": "${choices.name}"`],
117
+ ["\"bucket_name\": \"my-hollware-media\"", `"bucket_name": "${choices.name}-media"`],
108
118
  ["hollware-events", `${choices.name}-events`]
109
119
  ]);
110
120
  for (const file of ["README.md"]) replaceIn(join(target, file), [["my-site", choices.site]]);
@@ -127,44 +137,109 @@ async function step(io, what, command, args, cwd) {
127
137
  }
128
138
  return result;
129
139
  }
140
+ /** The environment variable that holds the token in an MCP client's configuration. */
141
+ const TOKEN_VARIABLE = "HOLLWARE_TOKEN";
142
+ /** The `.mcp.json` block of a Hollware: the URL of /mcp, and a header that names HOLLWARE_TOKEN. */
143
+ function mcpJson(url) {
144
+ return JSON.stringify({ mcpServers: { hollware: {
145
+ type: "http",
146
+ url: `${url}/mcp`,
147
+ headers: { Authorization: `Bearer \${${TOKEN_VARIABLE}}` }
148
+ } } }, null, 2);
149
+ }
150
+ /** The `claude mcp add` line that names HOLLWARE_TOKEN instead of the token. */
151
+ function claudeAddLine(url) {
152
+ return `claude mcp add --transport http hollware ${url}/mcp --header 'Authorization: Bearer \${${TOKEN_VARIABLE}}'`;
153
+ }
154
+ /** What `hollware init --json` prints, as far as this package reads it. */
155
+ function readInitJson(stdout) {
156
+ try {
157
+ const parsed = JSON.parse(stdout);
158
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return Object.fromEntries(Object.entries(parsed));
159
+ } catch {}
160
+ throw new CreateError(`"hollware init --json" did not answer with a JSON object:\n${stdout}`);
161
+ }
130
162
  /** Makes the project, and everything it needs to run locally. */
131
163
  async function create(argv, io) {
132
164
  const choices = readChoices(argv);
133
165
  const target = resolve(io.cwd, choices.dir);
134
166
  if (existsSync(target) && readdirSync(target).length > 0) throw new CreateError(`${target} is not empty: choose a new directory.`);
167
+ const tokenFile = choices.tokenFile === void 0 ? void 0 : resolve(io.cwd, choices.tokenFile);
168
+ if (tokenFile !== void 0 && existsSync(tokenFile)) throw new CreateError(`${tokenFile} exists already, and a token is never written over a file: choose another --token-file.`);
169
+ const say = (message) => {
170
+ if (!choices.json) io.out(message);
171
+ };
135
172
  writeProject(io, target, choices);
136
- io.out(`Created a Hollware project in ${target}.`);
173
+ say(`Created a Hollware project in ${target}.`);
174
+ if (tokenFile !== void 0) {
175
+ const inside = relative(target, tokenFile);
176
+ if (inside !== "" && !inside.startsWith("..") && !isAbsolute(inside)) appendFileSync(join(target, ".gitignore"), `\n# The token of "hollware init": never commit it.\n/${inside}\n`);
177
+ }
137
178
  if (choices.git) await step(io, "git init", "git", ["init", "--quiet"], target);
179
+ const initArgs = [
180
+ "init",
181
+ "--site",
182
+ choices.site,
183
+ ...choices.initFlags,
184
+ ...tokenFile === void 0 ? [] : ["--token-file", tokenFile]
185
+ ];
138
186
  if (!choices.install) {
187
+ const next = [
188
+ `cd ${choices.dir}`,
189
+ `${choices.pm} install`,
190
+ `${choices.pm} exec vp fmt`,
191
+ `${choices.pm} exec hollware migrate --local`,
192
+ `${choices.pm} exec hollware ${initArgs.join(" ")} --local`
193
+ ];
194
+ if (choices.json) {
195
+ io.out(JSON.stringify({
196
+ dir: target,
197
+ installed: false,
198
+ next
199
+ }));
200
+ return 0;
201
+ }
139
202
  io.out([
140
203
  "",
141
204
  "Next:",
142
- ` cd ${choices.dir}`,
143
- ` ${choices.pm} install`,
144
- ` ${choices.pm} exec hollware migrate --local`,
145
- ` ${choices.pm} exec hollware init --site ${[choices.site, ...choices.initFlags].join(" ")} --local`
205
+ ...next.map((line) => ` ${line}`)
146
206
  ].join("\n"));
147
207
  return 0;
148
208
  }
149
- io.out(`Installing with ${choices.pm} ...`);
209
+ say(`Installing with ${choices.pm} ...`);
150
210
  await step(io, "The install", choices.pm, ["install"], target);
211
+ await step(io, "The formatting", join(target, "node_modules", ".bin", "vp"), ["fmt"], target);
151
212
  const hollware = join(target, "node_modules", ".bin", "hollware");
152
213
  await step(io, "The migrations", hollware, ["migrate", "--local"], target);
153
214
  const init = await step(io, "Making the site", hollware, [
154
- "init",
155
- "--site",
156
- choices.site,
157
- ...choices.initFlags,
158
- "--local"
215
+ ...initArgs,
216
+ "--local",
217
+ ...choices.json ? ["--json"] : []
159
218
  ], target);
219
+ const dev = `${choices.pm} exec hollware dev`;
220
+ if (choices.json) {
221
+ const made = readInitJson(init.stdout.trim());
222
+ const url = typeof made["mcpUrl"] === "string" ? made["mcpUrl"].replace(/\/mcp$/, "") : void 0;
223
+ io.out(JSON.stringify({
224
+ dir: target,
225
+ installed: true,
226
+ ...made,
227
+ devCommand: dev,
228
+ adminUrl: url === void 0 ? null : `${url}/admin/`,
229
+ mcpConfig: url === void 0 ? null : {
230
+ variable: TOKEN_VARIABLE,
231
+ claudeCode: claudeAddLine(url),
232
+ json: JSON.parse(mcpJson(url))
233
+ }
234
+ }));
235
+ return 0;
236
+ }
160
237
  io.out(init.stdout.trim());
161
- const token = TOKEN.exec(init.stdout)?.[0] ?? "<the token above>";
162
238
  io.out([
163
239
  "",
164
- "Start it, and connect your agent (Claude Code):",
240
+ "Start it:",
165
241
  ` cd ${choices.dir}`,
166
- ` ${choices.pm === "npm" ? "npm run dev" : "pnpm dev"}`,
167
- ` claude mcp add --transport http hollware http://localhost:5199/mcp --header "Authorization: Bearer ${token}"`,
242
+ ` ${dev}`,
168
243
  "",
169
244
  "The admin is at http://localhost:5199/admin/. To deploy, log in to Cloudflare and run:",
170
245
  ` ${choices.pm} exec wrangler login`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-hollware",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "Create a new Hollware project.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -11,5 +11,10 @@ of the sites live in D1; this repository holds only the Worker.
11
11
  - Content is never edited in files here. Write it through the MCP tools (`create_draft`,
12
12
  `update_draft`, `validate`, `publish`) of the Hollware at `HOLLWARE_PUBLIC_URL` + `/mcp`.
13
13
  - Run `pnpm test` and `pnpm exec wrangler types --check` after a change of `wrangler.jsonc`, and `pnpm exec hollware doctor` when something does not work.
14
+ - To update Hollware, move `hollware`, `@hollware/core`, `@hollware/server` and `@hollware/admin`
15
+ to one new version together, as exact versions
16
+ (`pnpm add -E hollware@X @hollware/core@X @hollware/server@X @hollware/admin@X`), read its
17
+ release notes, run `pnpm test`, then `pnpm exec hollware deploy`: it deploys the Worker and applies
18
+ the new migrations to the remote database. Migrations only move forward; content stays as it is.
14
19
  - Never commit a token. `hollware init` and `hollware token create` print one once.
15
20
  - Docs: https://hollware.com
@@ -7,6 +7,7 @@
7
7
  "dev": "vp dev",
8
8
  "build": "vp build",
9
9
  "test": "vp test",
10
+ "check": "vp check",
10
11
  "types": "wrangler types",
11
12
  "types:check": "wrangler types --check",
12
13
  "deploy": "hollware deploy",
@@ -79,6 +79,7 @@ describe('the database', () => {
79
79
 
80
80
  expect(results.map((row) => row.name)).toEqual([
81
81
  '_hollware_meta',
82
+ 'assets',
82
83
  'audit_log',
83
84
  'auth_challenges',
84
85
  'deliveries',
@@ -2,8 +2,20 @@ import { cloudflare } from '@cloudflare/vite-plugin'
2
2
  import { adminAssets } from '@hollware/admin'
3
3
  import { defineConfig } from 'vite-plus'
4
4
 
5
- export default defineConfig({
6
- plugins: [cloudflare()],
5
+ /** Where `vp dev` serves Hollware: the URL of HOLLWARE_PUBLIC_URL before the first deploy. */
6
+ const LOCAL_URL = 'http://localhost:5199'
7
+
8
+ export default defineConfig(({ command }) => ({
9
+ plugins: [
10
+ cloudflare({
11
+ // `hollware deploy` writes the public URL into wrangler.jsonc, and the Worker answers only
12
+ // the Host of HOLLWARE_PUBLIC_URL: in development it is this machine's, whatever is deployed.
13
+ config:
14
+ command === 'serve'
15
+ ? (config) => ({ vars: { ...config.vars, HOLLWARE_PUBLIC_URL: LOCAL_URL } })
16
+ : undefined,
17
+ }),
18
+ ],
7
19
  // The admin's built files: served at /admin/ in development, and copied into the build, where
8
20
  // the Worker's static assets hold them.
9
21
  publicDir: adminAssets(),
@@ -15,4 +27,14 @@ export default defineConfig({
15
27
  // What you deploy is what the size budget of Hollware measures: minified.
16
28
  minify: true,
17
29
  },
18
- })
30
+ // `vp check` formats and lints the project as Hollware writes it. wrangler writes
31
+ // worker-configuration.d.ts, so neither touches it.
32
+ fmt: {
33
+ semi: false,
34
+ singleQuote: true,
35
+ ignorePatterns: ['dist/**', '.wrangler/**', 'worker-configuration.d.ts'],
36
+ },
37
+ lint: {
38
+ ignorePatterns: ['dist/**', '.wrangler/**', 'worker-configuration.d.ts'],
39
+ },
40
+ }))
@@ -17,8 +17,11 @@ export default defineConfig({
17
17
  cloudflareTest(async () => ({
18
18
  wrangler: { configPath: './wrangler.jsonc' },
19
19
  miniflare: {
20
- // Read here in Node and applied inside workerd by the setup file.
21
20
  bindings: {
21
+ // The tests reach the Worker at localhost, whatever public URL `hollware deploy` wrote
22
+ // into wrangler.jsonc: it answers only the Host of HOLLWARE_PUBLIC_URL.
23
+ HOLLWARE_PUBLIC_URL: 'http://localhost:5199',
24
+ // Read here in Node and applied inside workerd by the setup file.
22
25
  TEST_MIGRATIONS: await readD1Migrations({
23
26
  projectPath: import.meta.dirname,
24
27
  migrationsDir,
@@ -1,11 +1,13 @@
1
1
  /* eslint-disable */
2
- // Generated by Wrangler by running `wrangler types` (hash: 8cce72e181db11068cbc639f31cd25a2)
2
+ // Generated by Wrangler by running `wrangler types` (hash: f943445b38678ab7934813fd9b1a910b)
3
3
  // Runtime types generated with workerd@1.20261012.1 2026-10-06 global_fetch_strictly_public,no_nodejs_compat,no_nodejs_compat_v2
4
4
  interface __BaseEnv_Env {
5
5
  OAUTH_KV: KVNamespace;
6
+ MEDIA: R2Bucket;
6
7
  DB: D1Database;
7
8
  EVENTS: Queue;
8
9
  HOLLWARE_RATE_LIMIT: RateLimit;
10
+ IMAGES: ImagesBinding;
9
11
  ASSETS: Fetcher;
10
12
  HOLLWARE_PUBLIC_URL: "http://localhost:5199";
11
13
  }
@@ -59,4 +59,9 @@
59
59
  "migrations_dir": "node_modules/@hollware/server/migrations",
60
60
  },
61
61
  ],
62
+ // The images of the sites (ADR-038): the bucket that keeps them, which the first deploy creates,
63
+ // and the Images binding that converts each once when it arrives (to WebP by default). Without
64
+ // the binding, images are kept as they come; without the bucket, upload_asset is unavailable.
65
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "my-hollware-media" }],
66
+ "images": { "binding": "IMAGES" },
62
67
  }