create-hollware 0.2.0 → 0.4.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 +21 -7
- package/dist/index.d.mts +30 -8
- package/dist/index.mjs +212 -20
- package/package.json +4 -3
- package/template/AGENTS.md +14 -0
- package/template/README.md +65 -0
- package/template/gitignore +5 -0
- package/template/hollware.config.ts +36 -0
- package/template/package.json +34 -0
- package/template/src/worker.ts +15 -0
- package/template/test/apply-migrations.ts +5 -0
- package/template/test/env.d.ts +10 -0
- package/template/test/worker.workerd.test.ts +92 -0
- package/template/tsconfig.json +34 -0
- package/template/vite.config.ts +18 -0
- package/template/vitest.config.ts +38 -0
- package/template/worker-configuration.d.ts +16246 -0
- package/template/wrangler.jsonc +53 -0
package/README.md
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
# create-hollware
|
|
2
2
|
|
|
3
|
-
Creates a new project for [Hollware](https://
|
|
4
|
-
|
|
3
|
+
Creates a new project for [Hollware](https://hollware.com), an open-source headless CMS for the
|
|
4
|
+
agent era, native to Cloudflare Workers.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
```sh
|
|
7
|
+
npm create hollware my-cms
|
|
8
|
+
pnpm create hollware my-cms --pm pnpm
|
|
9
|
+
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
It copies the starter into `my-cms`, pins every Hollware package at its own version, runs
|
|
12
|
+
`git init` and the install, applies the migrations to the local database, and makes your site,
|
|
13
|
+
its owner and a first personal token. Then it prints the token once, a link to set up your passkey
|
|
14
|
+
for the admin, and the `claude mcp add` line that connects Claude Code to it.
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
| Option | What it does |
|
|
17
|
+
| ------------------ | --------------------------------------------------------------- |
|
|
18
|
+
| `--name <name>` | The name of the Worker and of its database (default: the dir) |
|
|
19
|
+
| `--site <key>` | The key of your site in `hollware.config.ts` (default: my-site) |
|
|
20
|
+
| `--pm <npm\|pnpm>` | The package manager that installs (default: npm) |
|
|
21
|
+
| `--yes` | Take the defaults; the directory is `my-hollware` |
|
|
22
|
+
| `--no-install` | Only write the files |
|
|
23
|
+
| `--no-git` | Do not run `git init` |
|
|
24
|
+
|
|
25
|
+
The directory must be new or empty. Node.js 24 or later.
|
|
13
26
|
|
|
14
27
|
## Links
|
|
15
28
|
|
|
29
|
+
- Docs: <https://hollware.com>
|
|
16
30
|
- Repository: <https://github.com/hollware/hollware>
|
|
17
31
|
- Issues: <https://github.com/hollware/hollware/issues>
|
|
18
32
|
- License: MIT
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
|
+
//#region src/scaffold.d.ts
|
|
2
|
+
/** What a run of another program left behind. */
|
|
3
|
+
interface RunResult {
|
|
4
|
+
readonly code: number;
|
|
5
|
+
readonly stdout: string;
|
|
6
|
+
readonly stderr: string;
|
|
7
|
+
}
|
|
8
|
+
/** Everything create-hollware takes from the outside world; a test hands in a fake of each part. */
|
|
9
|
+
interface Io {
|
|
10
|
+
/** One message for stdout. */
|
|
11
|
+
readonly out: (message: string) => void;
|
|
12
|
+
/** One message for stderr. */
|
|
13
|
+
readonly err: (message: string) => void;
|
|
14
|
+
/** Where the new directory is made, unless it is absolute. */
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
/** The project template this package ships, made from templates/starter. */
|
|
17
|
+
readonly templateDir: string;
|
|
18
|
+
/** The version of create-hollware: the version of Hollware the project gets. */
|
|
19
|
+
readonly version: string;
|
|
20
|
+
/** Runs a program in a directory, with its output captured. */
|
|
21
|
+
readonly run: (command: string, args: readonly string[], cwd: string) => Promise<RunResult>;
|
|
22
|
+
}
|
|
23
|
+
export declare const USAGE: string;
|
|
24
|
+
//#endregion
|
|
1
25
|
//#region src/index.d.ts
|
|
2
26
|
/**
|
|
3
|
-
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one
|
|
4
|
-
*
|
|
5
|
-
*
|
|
27
|
+
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one message;
|
|
28
|
+
* whoever owns the streams adds the trailing newline. `--help` and `--version` need nothing but
|
|
29
|
+
* the streams; making a project takes the rest of `io`, which `defaults` fills in.
|
|
6
30
|
*/
|
|
7
|
-
export declare function main(argv: string[], io:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}): number;
|
|
11
|
-
//#endregion
|
|
31
|
+
export declare function main(argv: string[], io: Pick<Io, 'out' | 'err'> & Partial<Omit<Io, 'out' | 'err'>>): Promise<number>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export type { Io };
|
package/dist/index.mjs
CHANGED
|
@@ -1,29 +1,174 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { basename, join, resolve } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
//#region src/scaffold.ts
|
|
5
8
|
const USAGE = [
|
|
6
|
-
"Usage: npm create hollware <dir>",
|
|
7
|
-
"",
|
|
8
|
-
"Create a new Hollware project in <dir> (not implemented yet).",
|
|
9
|
+
"Usage: npm create hollware [<dir>] [options]",
|
|
9
10
|
"",
|
|
10
|
-
"
|
|
11
|
+
"Creates a Hollware project in <dir>: a config, a Worker and the Wrangler config that binds",
|
|
12
|
+
"its database. Then it installs the dependencies, applies the migrations to the local database",
|
|
13
|
+
"and makes your site, its owner and a first token, and says how to connect your agent.",
|
|
11
14
|
"",
|
|
12
15
|
"Options:",
|
|
13
|
-
" --
|
|
14
|
-
" --
|
|
16
|
+
" --name <name> The name of the Worker and of its database (default: the name of <dir>)",
|
|
17
|
+
" --site <key> The key of your site in hollware.config.ts (default: my-site)",
|
|
18
|
+
" --pm <npm|pnpm> The package manager that installs (default: npm)",
|
|
19
|
+
" --yes Take the defaults: <dir> is my-hollware when it is not given",
|
|
20
|
+
" --no-install Do not install, migrate or make the site",
|
|
21
|
+
" --no-git Do not run git init",
|
|
22
|
+
" --help Show this help",
|
|
23
|
+
" --version Print the version"
|
|
15
24
|
].join("\n");
|
|
25
|
+
/** A mistake in the arguments: the message, then the usage. */
|
|
26
|
+
var UsageError = class extends Error {
|
|
27
|
+
name = "UsageError";
|
|
28
|
+
};
|
|
29
|
+
/** Anything else that stops the run: one message, no usage. */
|
|
30
|
+
var CreateError = class extends Error {
|
|
31
|
+
name = "CreateError";
|
|
32
|
+
};
|
|
33
|
+
/** A Worker's name: lowercase letters, digits and hyphens, as Cloudflare takes it. */
|
|
34
|
+
const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
35
|
+
/** A site key, as hollware.config.ts takes it. */
|
|
36
|
+
const SITE_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
37
|
+
/** What `@hollware/*` and `hollware` say in the template's package.json. */
|
|
38
|
+
const VERSION_PLACEHOLDER = "HOLLWARE_VERSION";
|
|
39
|
+
const TOKEN = /hw_pat_[0-9a-hjkmnp-tv-z]{52}/;
|
|
40
|
+
/** The choices of a run, from its arguments. */
|
|
41
|
+
function readChoices(argv) {
|
|
42
|
+
const { values, positionals } = (() => {
|
|
43
|
+
try {
|
|
44
|
+
return parseArgs({
|
|
45
|
+
args: [...argv],
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
options: {
|
|
48
|
+
name: { type: "string" },
|
|
49
|
+
site: { type: "string" },
|
|
50
|
+
pm: { type: "string" },
|
|
51
|
+
yes: { type: "boolean" },
|
|
52
|
+
"no-install": { type: "boolean" },
|
|
53
|
+
"no-git": { type: "boolean" }
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
if (positionals.length > 1) throw new UsageError(`one directory, at most: got ${positionals.join(", ")}`);
|
|
61
|
+
const [given] = positionals;
|
|
62
|
+
if (given === void 0 && values.yes !== true) throw new UsageError("name the directory of the project, or pass --yes for \"my-hollware\"");
|
|
63
|
+
const dir = given ?? "my-hollware";
|
|
64
|
+
const name = values.name ?? basename(resolve(dir)).toLowerCase();
|
|
65
|
+
if (!WORKER_NAME.test(name)) throw new UsageError(`"${name}" is no Worker name: use lowercase letters, digits and hyphens, or pass --name`);
|
|
66
|
+
const site = values.site ?? "my-site";
|
|
67
|
+
if (!SITE_KEY.test(site)) throw new UsageError(`"${site}" is no site key: use lowercase words joined by hyphens`);
|
|
68
|
+
const pm = values.pm ?? "npm";
|
|
69
|
+
if (pm !== "npm" && pm !== "pnpm") throw new UsageError(`--pm is npm or pnpm, not "${pm}"`);
|
|
70
|
+
return {
|
|
71
|
+
dir,
|
|
72
|
+
name,
|
|
73
|
+
site,
|
|
74
|
+
pm,
|
|
75
|
+
install: values["no-install"] !== true,
|
|
76
|
+
git: values["no-git"] !== true
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function replaceIn(path, replacements) {
|
|
80
|
+
let text = readFileSync(path, "utf8");
|
|
81
|
+
for (const [from, to] of replacements) text = text.split(from).join(to);
|
|
82
|
+
writeFileSync(path, text);
|
|
83
|
+
}
|
|
84
|
+
/** Copies the template into `target` and makes it this project's. */
|
|
85
|
+
function writeProject(io, target, choices) {
|
|
86
|
+
cpSync(io.templateDir, target, { recursive: true });
|
|
87
|
+
renameSync(join(target, "gitignore"), join(target, ".gitignore"));
|
|
88
|
+
replaceIn(join(target, "package.json"), [["\"name\": \"my-hollware\"", `"name": ${JSON.stringify(choices.name)}`], [`"${VERSION_PLACEHOLDER}"`, JSON.stringify(io.version)]]);
|
|
89
|
+
replaceIn(join(target, "wrangler.jsonc"), [
|
|
90
|
+
["\"name\": \"my-hollware\"", `"name": "${choices.name}"`],
|
|
91
|
+
["\"database_name\": \"my-hollware\"", `"database_name": "${choices.name}"`],
|
|
92
|
+
["hollware-events", `${choices.name}-events`]
|
|
93
|
+
]);
|
|
94
|
+
for (const file of ["hollware.config.ts", "README.md"]) replaceIn(join(target, file), [["my-site", choices.site]]);
|
|
95
|
+
if (choices.pm === "pnpm") writeFileSync(join(target, "pnpm-workspace.yaml"), PNPM_WORKSPACE);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* pnpm runs no build script it was not told about, and fails the install on one. esbuild ships the
|
|
99
|
+
* binary wrangler uses; workerd's binary arrives through optionalDependencies, so its script is
|
|
100
|
+
* not needed.
|
|
101
|
+
*/
|
|
102
|
+
const PNPM_WORKSPACE = `allowBuilds:
|
|
103
|
+
esbuild: true
|
|
104
|
+
workerd: false
|
|
105
|
+
`;
|
|
106
|
+
async function step(io, what, command, args, cwd) {
|
|
107
|
+
const result = await io.run(command, args, cwd);
|
|
108
|
+
if (result.code !== 0) {
|
|
109
|
+
const detail = (result.stderr.trim() || result.stdout.trim()).split("\n").slice(-30).join("\n");
|
|
110
|
+
throw new CreateError(`${what} failed (${command} ${args.join(" ")}):\n${detail}`);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
/** Makes the project, and everything it needs to run locally. */
|
|
115
|
+
async function create(argv, io) {
|
|
116
|
+
const choices = readChoices(argv);
|
|
117
|
+
const target = resolve(io.cwd, choices.dir);
|
|
118
|
+
if (existsSync(target) && readdirSync(target).length > 0) throw new CreateError(`${target} is not empty: choose a new directory.`);
|
|
119
|
+
writeProject(io, target, choices);
|
|
120
|
+
io.out(`Created a Hollware project in ${target}.`);
|
|
121
|
+
if (choices.git) await step(io, "git init", "git", ["init", "--quiet"], target);
|
|
122
|
+
if (!choices.install) {
|
|
123
|
+
io.out([
|
|
124
|
+
"",
|
|
125
|
+
"Next:",
|
|
126
|
+
` cd ${choices.dir}`,
|
|
127
|
+
` ${choices.pm} install`,
|
|
128
|
+
` ${choices.pm} exec hollware migrate --local`,
|
|
129
|
+
` ${choices.pm} exec hollware init --site ${choices.site} --local`
|
|
130
|
+
].join("\n"));
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
io.out(`Installing with ${choices.pm} ...`);
|
|
134
|
+
await step(io, "The install", choices.pm, ["install"], target);
|
|
135
|
+
const hollware = join(target, "node_modules", ".bin", "hollware");
|
|
136
|
+
await step(io, "The migrations", hollware, ["migrate", "--local"], target);
|
|
137
|
+
const init = await step(io, "Making the site", hollware, [
|
|
138
|
+
"init",
|
|
139
|
+
"--site",
|
|
140
|
+
choices.site,
|
|
141
|
+
"--local"
|
|
142
|
+
], target);
|
|
143
|
+
io.out(init.stdout.trim());
|
|
144
|
+
const token = TOKEN.exec(init.stdout)?.[0] ?? "<the token above>";
|
|
145
|
+
io.out([
|
|
146
|
+
"",
|
|
147
|
+
"Start it, and connect your agent (Claude Code):",
|
|
148
|
+
` cd ${choices.dir}`,
|
|
149
|
+
` ${choices.pm === "npm" ? "npm run dev" : "pnpm dev"}`,
|
|
150
|
+
` claude mcp add --transport http hollware http://localhost:5199/mcp --header "Authorization: Bearer ${token}"`,
|
|
151
|
+
"",
|
|
152
|
+
"The admin is at http://localhost:5199/admin/. To deploy, log in to Cloudflare and run:",
|
|
153
|
+
` ${choices.pm} exec wrangler login`,
|
|
154
|
+
` ${choices.pm} run deploy`,
|
|
155
|
+
"The README says the rest."
|
|
156
|
+
].join("\n"));
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/index.ts
|
|
16
161
|
function readVersion() {
|
|
17
162
|
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
18
163
|
if (typeof manifest.version !== "string") throw new Error("package.json has no version");
|
|
19
164
|
return manifest.version;
|
|
20
165
|
}
|
|
21
166
|
/**
|
|
22
|
-
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one
|
|
23
|
-
*
|
|
24
|
-
*
|
|
167
|
+
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one message;
|
|
168
|
+
* whoever owns the streams adds the trailing newline. `--help` and `--version` need nothing but
|
|
169
|
+
* the streams; making a project takes the rest of `io`, which `defaults` fills in.
|
|
25
170
|
*/
|
|
26
|
-
function main(argv, io) {
|
|
171
|
+
async function main(argv, io) {
|
|
27
172
|
const [first] = argv;
|
|
28
173
|
if (first === void 0 || first === "--help") {
|
|
29
174
|
io.out(USAGE);
|
|
@@ -33,12 +178,59 @@ function main(argv, io) {
|
|
|
33
178
|
io.out(`create-hollware ${readVersion()}`);
|
|
34
179
|
return 0;
|
|
35
180
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
181
|
+
try {
|
|
182
|
+
return await create(argv, {
|
|
183
|
+
cwd: io.cwd ?? process.cwd(),
|
|
184
|
+
templateDir: io.templateDir ?? fileURLToPath(new URL("../template", import.meta.url)),
|
|
185
|
+
version: io.version ?? readVersion(),
|
|
186
|
+
run: io.run ?? runProgram,
|
|
187
|
+
out: io.out,
|
|
188
|
+
err: io.err
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof UsageError) {
|
|
192
|
+
io.err(`create-hollware: ${error.message}\n\n${USAGE}`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
if (error instanceof CreateError) {
|
|
196
|
+
io.err(`create-hollware: ${error.message}`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
39
200
|
}
|
|
40
|
-
|
|
41
|
-
|
|
201
|
+
}
|
|
202
|
+
/** Runs a program with its output captured, and its stdin closed: nothing waits for an answer. */
|
|
203
|
+
function runProgram(command, args, cwd) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const child = spawn(command, [...args], {
|
|
206
|
+
cwd,
|
|
207
|
+
stdio: [
|
|
208
|
+
"ignore",
|
|
209
|
+
"pipe",
|
|
210
|
+
"pipe"
|
|
211
|
+
]
|
|
212
|
+
});
|
|
213
|
+
let stdout = "";
|
|
214
|
+
let stderr = "";
|
|
215
|
+
child.stdout.setEncoding("utf8");
|
|
216
|
+
child.stderr.setEncoding("utf8");
|
|
217
|
+
child.stdout.on("data", (chunk) => {
|
|
218
|
+
stdout += chunk;
|
|
219
|
+
});
|
|
220
|
+
child.stderr.on("data", (chunk) => {
|
|
221
|
+
stderr += chunk;
|
|
222
|
+
});
|
|
223
|
+
child.on("error", (error) => {
|
|
224
|
+
reject(new CreateError(`could not run ${command}: ${error.message}`));
|
|
225
|
+
});
|
|
226
|
+
child.on("close", (code) => {
|
|
227
|
+
resolve({
|
|
228
|
+
code: code ?? 1,
|
|
229
|
+
stdout,
|
|
230
|
+
stderr
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
42
234
|
}
|
|
43
235
|
function isEntryPoint() {
|
|
44
236
|
const entry = process.argv[1];
|
|
@@ -49,9 +241,9 @@ function isEntryPoint() {
|
|
|
49
241
|
return false;
|
|
50
242
|
}
|
|
51
243
|
}
|
|
52
|
-
if (isEntryPoint()) process.exitCode = main(process.argv.slice(2), {
|
|
244
|
+
if (isEntryPoint()) process.exitCode = await main(process.argv.slice(2), {
|
|
53
245
|
out: (s) => process.stdout.write(`${s}\n`),
|
|
54
246
|
err: (s) => process.stderr.write(`${s}\n`)
|
|
55
247
|
});
|
|
56
248
|
//#endregion
|
|
57
|
-
export { main };
|
|
249
|
+
export { USAGE, main };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-hollware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Create a new Hollware project.",
|
|
5
5
|
"homepage": "https://hollware.com",
|
|
6
6
|
"bugs": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"create-hollware": "./dist/index.mjs"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"dist"
|
|
19
|
+
"dist",
|
|
20
|
+
"template"
|
|
20
21
|
],
|
|
21
22
|
"type": "module",
|
|
22
23
|
"exports": {
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"node": ">=24.11.0"
|
|
41
42
|
},
|
|
42
43
|
"scripts": {
|
|
43
|
-
"build": "vp pack",
|
|
44
|
+
"build": "node ../../scripts/sync-starter.ts && vp pack",
|
|
44
45
|
"test": "vp test",
|
|
45
46
|
"publint": "publint --strict"
|
|
46
47
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This is a Hollware project: a headless CMS on Cloudflare Workers. The content lives in D1 and is
|
|
4
|
+
written over MCP; this repository holds only the config and the Worker.
|
|
5
|
+
|
|
6
|
+
- `hollware.config.ts` is the source of truth for what content is valid. A change of a schema, a
|
|
7
|
+
route or a style guide changes what every agent may write; `describe_site` over MCP returns it.
|
|
8
|
+
- `src/worker.ts` stays as it is: `createHollware(config)` is the whole Worker.
|
|
9
|
+
- Content is never edited in files here. Write it through the MCP tools (`create_draft`,
|
|
10
|
+
`update_draft`, `validate`, `publish`) of the Hollware at `HOLLWARE_PUBLIC_URL` + `/mcp`.
|
|
11
|
+
- Run `pnpm test` and `pnpm exec wrangler types --check` after a change of the config or of
|
|
12
|
+
`wrangler.jsonc`, and `pnpm exec hollware doctor` when something does not work.
|
|
13
|
+
- Never commit a token. `hollware init` and `hollware token create` print one once.
|
|
14
|
+
- Docs: https://hollware.com
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# My Hollware
|
|
2
|
+
|
|
3
|
+
A [Hollware](https://hollware.com) project: a headless CMS on Cloudflare Workers that your agents
|
|
4
|
+
write to over MCP. The config says what your content is; every write is checked against it.
|
|
5
|
+
|
|
6
|
+
## What is inside
|
|
7
|
+
|
|
8
|
+
| Path | What it is |
|
|
9
|
+
| --------------------------- | ---------------------------------------------------------------------------------- |
|
|
10
|
+
| `hollware.config.ts` | Your sites and their collections: the schema of the frontmatter, routes, guidance |
|
|
11
|
+
| `src/worker.ts` | The Worker: `createHollware(config)` builds it |
|
|
12
|
+
| `wrangler.jsonc` | The Worker, its D1 database, its KV namespace, its queues and its public URL |
|
|
13
|
+
| `vite.config.ts` | Vite with the Cloudflare plugin, and the admin at `/admin/` |
|
|
14
|
+
| `vitest.config.ts`, `test/` | Tests that run inside workerd |
|
|
15
|
+
| `worker-configuration.d.ts` | The types of the bindings, from `wrangler types`. Refresh it when wrangler changes |
|
|
16
|
+
| `AGENTS.md` | What an agent that works on this project needs to know |
|
|
17
|
+
|
|
18
|
+
## Run it locally
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm dev # http://localhost:5199: MCP at /mcp, the admin at /admin/
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`npm create hollware` already applied the migrations to the local database and made your site,
|
|
25
|
+
its owner and a first token. To do it again on a new local database:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pnpm exec hollware migrate --local
|
|
29
|
+
pnpm exec hollware init --site my-site --local
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`init` prints a personal token once, and a link to set up your passkey for the admin. Give the
|
|
33
|
+
token to your agent:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
claude mcp add --transport http hollware http://localhost:5199/mcp --header "Authorization: Bearer <token>"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Deploy it
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pnpm exec wrangler login
|
|
43
|
+
pnpm run deploy # creates the database, KV and queues, and migrates
|
|
44
|
+
pnpm exec hollware init --site my-site --remote
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The first deploy writes the ids of what it created, and the public URL of the Worker, into
|
|
48
|
+
`wrangler.jsonc`: commit it. `pnpm run doctor` checks the project and says what to fix.
|
|
49
|
+
|
|
50
|
+
## Read it from a site
|
|
51
|
+
|
|
52
|
+
A Worker on the same account reads what you publish over a service binding, with no network and
|
|
53
|
+
no token. Any other site reads it over HTTP with a read token: `hollware token create --read`.
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
| Command | What it does |
|
|
58
|
+
| ------------------ | ---------------------------------------------------- |
|
|
59
|
+
| `pnpm dev` | Serve the Worker locally |
|
|
60
|
+
| `pnpm build` | Build the Worker into `dist/` |
|
|
61
|
+
| `pnpm test` | Run the tests in workerd |
|
|
62
|
+
| `pnpm run deploy` | Deploy to Cloudflare and migrate the remote database |
|
|
63
|
+
| `pnpm run doctor` | Check the project, its database and its owner |
|
|
64
|
+
| `pnpm types` | Regenerate `worker-configuration.d.ts` |
|
|
65
|
+
| `pnpm types:check` | Fail when `worker-configuration.d.ts` is out of date |
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineCollection, defineConfig } from '@hollware/core'
|
|
2
|
+
import * as z from 'zod'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The config of your Hollware: one site with a small bilingual blog. Agents read it with
|
|
6
|
+
* `describe_site` before they write, so everything it says is what they follow: the shape of the
|
|
7
|
+
* frontmatter, the routes of an entry per locale, and the style guide.
|
|
8
|
+
*
|
|
9
|
+
* `my-site` is the key of the site. Create it in your database with `hollware init --site my-site`.
|
|
10
|
+
*/
|
|
11
|
+
const blog = defineCollection({
|
|
12
|
+
schema: z.object({
|
|
13
|
+
title: z.string().min(1),
|
|
14
|
+
description: z.string().min(1).max(160),
|
|
15
|
+
date: z.iso.date(),
|
|
16
|
+
tags: z.array(z.string().min(1)).min(1),
|
|
17
|
+
// The entries of one post in each language share this value.
|
|
18
|
+
translationGroup: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
19
|
+
}),
|
|
20
|
+
routes: { en: '/blog/:slug/', es: '/es/blog/:slug/' },
|
|
21
|
+
translations: { groupBy: 'translationGroup' },
|
|
22
|
+
tags: 'tags',
|
|
23
|
+
sortBy: 'date',
|
|
24
|
+
styleGuide: 'Short paragraphs, one idea per section, and an example for every claim.',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export default defineConfig({
|
|
28
|
+
sites: {
|
|
29
|
+
'my-site': {
|
|
30
|
+
locales: ['en', 'es'],
|
|
31
|
+
defaultLocale: 'en',
|
|
32
|
+
collections: { blog },
|
|
33
|
+
styleGuide: 'Plain language, with the reader in the second person.',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-hollware",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vp dev",
|
|
8
|
+
"build": "vp build",
|
|
9
|
+
"test": "vp test",
|
|
10
|
+
"types": "wrangler types",
|
|
11
|
+
"types:check": "wrangler types --check",
|
|
12
|
+
"deploy": "hollware deploy",
|
|
13
|
+
"doctor": "hollware doctor"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@hollware/core": "HOLLWARE_VERSION",
|
|
17
|
+
"@hollware/server": "HOLLWARE_VERSION",
|
|
18
|
+
"zod": "4.6.5"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@cloudflare/vite-plugin": "1.63.2",
|
|
22
|
+
"@cloudflare/vitest-plugin": "1.4.1",
|
|
23
|
+
"@hollware/admin": "HOLLWARE_VERSION",
|
|
24
|
+
"hollware": "HOLLWARE_VERSION",
|
|
25
|
+
"typescript": "7.0.2",
|
|
26
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
27
|
+
"vite-plus": "1.1.0",
|
|
28
|
+
"vitest": "5.0.3",
|
|
29
|
+
"wrangler": "4.150.0"
|
|
30
|
+
},
|
|
31
|
+
"overrides": {
|
|
32
|
+
"vite": "$vite"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHollware } from '@hollware/server'
|
|
2
|
+
import config from '../hollware.config.ts'
|
|
3
|
+
|
|
4
|
+
// This file is the whole Worker: the config goes in, the Worker comes out.
|
|
5
|
+
const hollware = createHollware(config)
|
|
6
|
+
|
|
7
|
+
// MCP for your agents at /mcp, behind a personal token.
|
|
8
|
+
export default {
|
|
9
|
+
fetch: hollware.fetch,
|
|
10
|
+
scheduled: hollware.scheduled,
|
|
11
|
+
queue: hollware.queue,
|
|
12
|
+
} satisfies ExportedHandler<Env>
|
|
13
|
+
|
|
14
|
+
// What your sites read over a service binding: four read-only methods, no network, no token.
|
|
15
|
+
export class Hollware extends hollware.Hollware {}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { HollwareRpc } from '@hollware/server'
|
|
2
|
+
import { env, exports } from 'cloudflare:workers'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import worker from '../src/worker.ts'
|
|
6
|
+
|
|
7
|
+
// Fails when: src/worker.ts stops exporting `{ fetch: hollware.fetch }` of createHollware(config)
|
|
8
|
+
// as the Worker, so that GET /health for the public URL of wrangler.jsonc (localhost:5199) stops
|
|
9
|
+
// answering 200, or another Host stops being a 403; when it stops exporting the class `Hollware`
|
|
10
|
+
// that sites read over a service binding, or builds it from another config than hollware.config.ts
|
|
11
|
+
// (describeSite must answer the site `my-site`); or when the D1 binding of wrangler.jsonc stops
|
|
12
|
+
// pointing `migrations_dir` at the migrations of @hollware/server, so that its tables are missing.
|
|
13
|
+
|
|
14
|
+
// A request made inside workerd has no Host header of its own: a browser or curl would send one.
|
|
15
|
+
const PUBLIC_HOST = 'localhost:5199'
|
|
16
|
+
const asHost = (host: string) => ({ headers: { host } })
|
|
17
|
+
|
|
18
|
+
// Cloudflare's `Service<...>` types every answer that holds `unknown` as `never`, so the stub is
|
|
19
|
+
// read through the interface it implements.
|
|
20
|
+
const rpc: HollwareRpc = exports.Hollware
|
|
21
|
+
|
|
22
|
+
describe('the module', () => {
|
|
23
|
+
it('exports the fetch, queue and scheduled handlers of Hollware', () => {
|
|
24
|
+
expect(Object.keys(worker).sort()).toEqual(['fetch', 'queue', 'scheduled'])
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('the Worker over HTTP', () => {
|
|
29
|
+
it('answers GET /health at its public URL', async () => {
|
|
30
|
+
const response = await exports.default.fetch(
|
|
31
|
+
`http://${PUBLIC_HOST}/health`,
|
|
32
|
+
asHost(PUBLIC_HOST),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(response.status).toBe(200)
|
|
36
|
+
expect(await response.text()).toBe('{"ok":true}')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('refuses a request for another Host with 403', async () => {
|
|
40
|
+
const response = await exports.default.fetch('http://example.com/health', asHost('example.com'))
|
|
41
|
+
|
|
42
|
+
expect(response.status).toBe(403)
|
|
43
|
+
expect(await response.json()).toMatchObject({ code: 'forbidden_host' })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('the Hollware class', () => {
|
|
48
|
+
it('serves the site of hollware.config.ts over RPC', async () => {
|
|
49
|
+
const answer = await rpc.describeSite('my-site')
|
|
50
|
+
|
|
51
|
+
expect(answer).toMatchObject({
|
|
52
|
+
outcome: 'ok',
|
|
53
|
+
site: {
|
|
54
|
+
key: 'my-site',
|
|
55
|
+
locales: ['en', 'es'],
|
|
56
|
+
defaultLocale: 'en',
|
|
57
|
+
collections: [{ name: 'blog', routes: { en: '/blog/:slug/', es: '/es/blog/:slug/' } }],
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('the database', () => {
|
|
64
|
+
it('has the tables of the @hollware/server migrations, and no others', async () => {
|
|
65
|
+
const { results } = await env.DB.prepare(
|
|
66
|
+
`SELECT name FROM sqlite_master
|
|
67
|
+
WHERE type = 'table' AND name NOT LIKE '\\_cf\\_%' ESCAPE '\\'
|
|
68
|
+
AND name NOT IN ('d1_migrations', 'sqlite_sequence')
|
|
69
|
+
ORDER BY name`,
|
|
70
|
+
).all<{ name: string }>()
|
|
71
|
+
|
|
72
|
+
expect(results.map((row) => row.name)).toEqual([
|
|
73
|
+
'_hollware_meta',
|
|
74
|
+
'auth_challenges',
|
|
75
|
+
'deliveries',
|
|
76
|
+
'entries',
|
|
77
|
+
'invitations',
|
|
78
|
+
'members',
|
|
79
|
+
'outbox',
|
|
80
|
+
'passkeys',
|
|
81
|
+
'publications',
|
|
82
|
+
'reviews',
|
|
83
|
+
'revision_tags',
|
|
84
|
+
'revisions',
|
|
85
|
+
'schedules',
|
|
86
|
+
'sessions',
|
|
87
|
+
'sites',
|
|
88
|
+
'tokens',
|
|
89
|
+
'users',
|
|
90
|
+
])
|
|
91
|
+
})
|
|
92
|
+
})
|