@rubytech/create-maxy-lite 0.1.3 → 0.1.5

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/index.mjs CHANGED
@@ -23,9 +23,9 @@ import { fileURLToPath } from 'node:url'
23
23
 
24
24
  import { makeLog } from './lib/log.mjs'
25
25
  import { readPins } from './lib/pins.mjs'
26
- import { PATHS, WEBCHAT_DIR, VALIDATOR_CLI, launcherPath, launcherScript, payloadLayCommand } from './lib/paths.mjs'
26
+ import { PATHS, WEBCHAT_DIR, VALIDATOR_CLI, SKILLS_HOME, launcherPath, launcherScript, payloadLayCommand, ptyLoadCommand, skillsLinkCommand, vaultBindLogin, webchatRunEnv } from './lib/paths.mjs'
27
27
  import { orchestrate, STEP_NAMES } from './lib/orchestrate.mjs'
28
- import { claudeOk, validatorOk, vaultOk, webchatOk, healthcheck } from './lib/healthcheck.mjs'
28
+ import { claudeOk, validatorOk, vaultOk, webchatOk, skillsOk, healthcheck } from './lib/healthcheck.mjs'
29
29
 
30
30
  const HERE = path.dirname(fileURLToPath(import.meta.url))
31
31
  const PAYLOAD = path.join(HERE, 'payload')
@@ -107,14 +107,38 @@ const layPayload = () => {
107
107
  if (r.code !== 0) throw new Error(`payload lay failed: ${r.stderr.replace(/\s+/g, ' ').trim()}`)
108
108
  }
109
109
 
110
+ /** Symlink the bundled skills into the guest's personal-skills source so `claude` discovers them. */
111
+ // Runs guest-side (the paths are inside the rootfs); idempotent (`ln -sfn`).
112
+ const linkSkills = () => {
113
+ const r = runIn(skillsLinkCommand())
114
+ if (r.code !== 0) throw new Error(`skills link failed: ${r.stderr.replace(/\s+/g, ' ').trim()}`)
115
+ }
116
+
110
117
  /** Install the app deps (validator's js-yaml) and the webchat deps (node-pty, ws). */
111
118
  // Streamed: the webchat deps build node-pty natively, which is slow and verbose;
112
119
  // a failure carries the child's stderr so the cause is in the log.
120
+ const PTY_NODE = `${WEBCHAT_DIR}/node_modules/node-pty/build/Release/pty.node`
113
121
  const installDeps = async () => {
114
122
  const app = await runInStream(`cd ${PATHS.appDir} && npm install --omit=dev`)
115
123
  if (app.code !== 0) throw new Error(`app deps install failed: ${app.stderr.replace(/\s+/g, ' ').trim()}`)
116
124
  const wc = await runInStream(`cd ${WEBCHAT_DIR} && npm install --omit=dev`)
117
125
  if (wc.code !== 0) throw new Error(`webchat deps install failed: ${wc.stderr.replace(/\s+/g, ' ').trim()}`)
126
+ // node-pty ships no linux-arm64 + Node 20 prebuild, so the install above can
127
+ // leave pty.node unbuilt when npm has ignore-scripts set. `npm rebuild` re-runs
128
+ // node-pty's node-gyp build; the env var forces scripts on regardless of ambient
129
+ // config, and --build-from-source keeps it a source compile should a future
130
+ // node-pty gain a prebuild fetcher. The toolchain step guarantees python3/make/g++.
131
+ const rb = await runInStream(
132
+ `cd ${WEBCHAT_DIR} && npm_config_ignore_scripts=false npm rebuild node-pty --build-from-source --foreground-scripts`,
133
+ )
134
+ if (rb.code !== 0) throw new Error(`node-pty native build failed: ${rb.stderr.replace(/\s+/g, ' ').trim()}`)
135
+ // Prove the built module actually loads under the guest's Node — this is exactly
136
+ // what the relay does at launch, so a pass here means no `Failed to load native
137
+ // module: pty.node` crash. A bare file-presence test would miss an unloadable
138
+ // binary. The same ptyLoadCommand backs the npm-app skip-guard, so the guard
139
+ // admits exactly what this asserts. Fail at install time, not at relay launch.
140
+ if (runIn(ptyLoadCommand()).code !== 0)
141
+ throw new Error(`node-pty native module did not load after build: ${PTY_NODE}`)
118
142
  }
119
143
 
120
144
  /** Write the `maxy-lite` launcher into the Termux bin dir. */
@@ -140,24 +164,42 @@ const readVersions = () => ({
140
164
  const runHealthcheck = () =>
141
165
  healthcheck({
142
166
  checkClaude: () => claudeOk(() => runIn('claude --version')),
143
- // Start the relay, poll its port for up to 10s, record whether it bound, kill it.
167
+ // Start the relay under the launcher's exact runtime condition the vault
168
+ // bound in and the launcher env set — then poll its port for up to 10s, record
169
+ // whether it bound, and kill it. Run from bare Termux (`run`) because
170
+ // vaultBindLogin already enters the distro; `runIn` would nest a second login
171
+ // with no bind and test the wrong process. Return the full run result so a
172
+ // failed bind carries the relay's stderr (e.g. node-pty load crash) into err=.
144
173
  checkWebchat: () =>
145
174
  webchatOk(async () => {
146
- const probe =
147
- `cd ${WEBCHAT_DIR} && node server.mjs & SVPID=$!; ` +
175
+ const inner =
176
+ `cd ${WEBCHAT_DIR} && ${webchatRunEnv()} node server.mjs & SVPID=$!; ` +
148
177
  `for i in $(seq 1 10); do sleep 1; ` +
149
178
  `curl -sf -o /dev/null http://localhost:${PATHS.webchatPort} && break; done; ` +
150
179
  `curl -sf -o /dev/null http://localhost:${PATHS.webchatPort}; RC=$?; ` +
151
180
  `kill $SVPID 2>/dev/null; exit $RC`
152
- return runIn(probe).code === 0
181
+ return run(vaultBindLogin(inner))
153
182
  }),
154
- // Validator must exit 0 on an empty vault.
183
+ // Validator must exit 0 on an empty vault. Pass the full result so a non-zero
184
+ // exit carries the validator's stderr into err=.
155
185
  checkValidator: () => {
156
186
  const empty = `${PATHS.appDir}/.hc-empty-vault`
157
187
  runIn(`mkdir -p ${empty}`)
158
- return validatorOk((vault) => runIn(`node ${VALIDATOR_CLI} ${vault}`).code, empty)
188
+ return validatorOk((vault) => runIn(`node ${VALIDATOR_CLI} ${vault}`), empty)
159
189
  },
160
- checkVault: () => vaultOk(() => runIn(`test -d ${PATHS.vaultGuest}`).code === 0),
190
+ // The vault path only exists inside the distro when the login carries the
191
+ // launcher's `--bind`. Reproduce that bind, then check the guest path — exactly
192
+ // the launcher condition, not a session where the vault was never mounted. The
193
+ // full result carries the bind/login stderr into err= when the path is absent.
194
+ checkVault: () => vaultOk(() => run(vaultBindLogin(`test -d ${PATHS.vaultGuest}`))),
195
+ // The shipped skills are discoverable only when the personal-skills source
196
+ // carries their SKILL.md files. With nullglob off (the default here), a glob
197
+ // that matches nothing is passed to `ls` literally and `ls` exits non-zero
198
+ // because that path does not exist; a match lists real files and exits 0. So
199
+ // a 0 exit asserts at least one SKILL.md is linked (resolving through the
200
+ // symlinks) — a dangling or empty link dir fails. The full result carries the
201
+ // `ls` stderr into err= when nothing matched.
202
+ checkSkills: () => skillsOk(() => runIn(`ls ${SKILLS_HOME}/*/SKILL.md`)),
161
203
  })
162
204
 
163
205
  // ---- dry run ----------------------------------------------------------------
@@ -214,6 +256,7 @@ async function main() {
214
256
  interactive,
215
257
  ensureStorageGrant,
216
258
  layPayload,
259
+ linkSkills,
217
260
  installDeps,
218
261
  writeLauncher,
219
262
  readVersions,
@@ -1,41 +1,82 @@
1
1
  // Post-install health check — the measured post-conditions, not "ran the step".
2
- // Each probe answers a binary question about the running system:
2
+ // Each probe answers a binary question about the running system AND carries the
3
+ // cause when it fails, so a red probe is diagnosable from the install log alone:
3
4
  // claude — does `claude --version` answer with a version?
4
- // webchat — does the relay port listen?
5
+ // webchat — does the relay port listen? (its crash output is the relay stderr)
5
6
  // validator — does the validator exit 0 on an empty vault?
6
7
  // vault — does the bind-mount path exist inside Ubuntu?
7
- // The probes take injected primitives so the decision rules are unit-testable;
8
- // index.mjs wires the real ones (run-in-Ubuntu, transient port probe, fs stat).
8
+ // skills — does the personal-skills source carry the shipped SKILL.md files?
9
+ // Each probe returns `{ ok, err }`: `ok` is the binary verdict, `err` is the tail
10
+ // of the probe's stderr when it failed (empty when it passed). The probes take
11
+ // injected primitives so the decision rules are unit-testable; index.mjs wires the
12
+ // real ones (run-in-Ubuntu, transient port probe, fs stat), each yielding a
13
+ // `{ code, stdout, stderr }` result so the cause survives into the log.
9
14
 
10
15
  const VERSION_RE = /\d+\.\d+\.\d+/
11
16
 
12
- /** True when `claude --version` exits 0 and prints a version. `runIn(cmd)` → {code, stdout}. */
17
+ /**
18
+ * The diagnostic tail of a probe's output: the last few non-empty lines, with
19
+ * whitespace collapsed and length bounded. The actual failure (e.g. node-pty's
20
+ * `Failed to load native module`) is at the END of a crash's stderr, so the tail
21
+ * carries the cause; the cap keeps one `op=healthcheck` line readable.
22
+ */
23
+ function tail(text, lines = 5, cap = 300) {
24
+ return String(text || '')
25
+ .split('\n')
26
+ .filter((l) => l.trim())
27
+ .slice(-lines)
28
+ .join(' ')
29
+ .replace(/\s+/g, ' ')
30
+ .trim()
31
+ .slice(0, cap)
32
+ }
33
+
34
+ /** `{ ok, err }` from a `{ code, stdout, stderr }` result: err is the stderr tail on failure. */
35
+ function verdict(ok, r) {
36
+ return { ok, err: ok ? '' : tail(r && (r.stderr || r.stdout)) }
37
+ }
38
+
39
+ /** True when `claude --version` exits 0 and prints a version. `runVersion()` → {code, stdout, stderr}. */
13
40
  export function claudeOk(runVersion) {
14
41
  const r = runVersion()
15
- return r.code === 0 && VERSION_RE.test(r.stdout || '')
42
+ return verdict(r.code === 0 && VERSION_RE.test(r.stdout || ''), r)
16
43
  }
17
44
 
18
- /** True when the validator exits 0 on the given (empty) vault. `runValidator(vault)` → exit code. */
45
+ /** True when the validator exits 0 on the given (empty) vault. `runValidator(vault)` → {code, stderr}. */
19
46
  export function validatorOk(runValidator, vaultPath) {
20
- return runValidator(vaultPath) === 0
47
+ const r = runValidator(vaultPath)
48
+ return verdict(r.code === 0, r)
21
49
  }
22
50
 
23
- /** True when the bind-mount path exists. `exists()` → bool. */
24
- export function vaultOk(exists) {
25
- return exists() === true
51
+ /** True when the bind-mount path exists. `check()` → {code, stderr}. */
52
+ export function vaultOk(check) {
53
+ const r = check()
54
+ return verdict(r.code === 0, r)
26
55
  }
27
56
 
28
- /** True when the relay port responded. `getStatus()` → Promise<bool>. */
57
+ /** True when the relay port responded. `getStatus()` → Promise<{code, stderr}>. */
29
58
  export async function webchatOk(getStatus) {
30
- return (await getStatus()) === true
59
+ const r = await getStatus()
60
+ return verdict(r.code === 0, r)
61
+ }
62
+
63
+ /** True when the shipped skills are present in the personal-skills source. `listSkills()` → {code, stderr} of an `ls` of the per-skill SKILL.md glob under SKILLS_HOME; err carries the ls stderr (e.g. "No such file") when nothing matched. */
64
+ export function skillsOk(listSkills) {
65
+ const r = listSkills()
66
+ return verdict(r.code === 0, r)
31
67
  }
32
68
 
33
- /** Run all four probes and return `{ claude, webchat, validator, vault }`. */
34
- export async function healthcheck({ checkClaude, checkWebchat, checkValidator, checkVault }) {
69
+ /**
70
+ * Run all five probes and return `{ claude, webchat, validator, vault, skills }`,
71
+ * each a `{ ok, err }` record. orchestrate flattens this into the `op=healthcheck`
72
+ * line: `<probe>=<ok>` for every probe, plus `<probe>Err=<cause>` for each failure.
73
+ */
74
+ export async function healthcheck({ checkClaude, checkWebchat, checkValidator, checkVault, checkSkills }) {
35
75
  return {
36
- claude: checkClaude() === true,
37
- webchat: (await checkWebchat()) === true,
38
- validator: checkValidator() === true,
39
- vault: checkVault() === true,
76
+ claude: checkClaude(),
77
+ webchat: await checkWebchat(),
78
+ validator: checkValidator(),
79
+ vault: checkVault(),
80
+ skills: checkSkills(),
40
81
  }
41
82
  }
@@ -15,7 +15,7 @@
15
15
  // All external effects go through injected ctx primitives so the sequence,
16
16
  // guards and idempotency are unit-testable; index.mjs wires the real ones.
17
17
 
18
- import { PATHS, WEBCHAT_DIR, launcherPath } from './paths.mjs'
18
+ import { PATHS, launcherPath, ptyLoadCommand } from './paths.mjs'
19
19
 
20
20
  export const STEP_NAMES = ['termux-deps', 'proot', 'ubuntu', 'node', 'toolchain', 'vault-bind', 'npm-app']
21
21
 
@@ -109,11 +109,16 @@ const STEPS = [
109
109
  },
110
110
  {
111
111
  name: 'npm-app',
112
- // Converged only when the deps are built, claude is present, AND the launcher
113
- // is written — the launcher is the last mutation, so guarding on the first two
114
- // alone would skip a half-finished install and report ok=true with no launcher.
112
+ // Converged only when node-pty's native module LOADS, claude is present, AND
113
+ // the launcher is written. The deps probe is `require("node-pty")`, not a
114
+ // file-presence test: a present-but-unloadable `pty.node` (stale/wrong-ABI
115
+ // from a prior build) would pass `test -f` yet crash the relay at launch, so
116
+ // the guard would skip the very rebuild that repairs it. The load-check is the
117
+ // same condition `installDeps` asserts post-build (shared ptyLoadCommand), so
118
+ // the guard admits exactly what the build proves; a non-loadable module fails
119
+ // the guard and the step rebuilds.
115
120
  done: (c) =>
116
- c.runIn(`test -d ${WEBCHAT_DIR}/node_modules`).code === 0 &&
121
+ c.runIn(ptyLoadCommand()).code === 0 &&
117
122
  c.runIn('command -v claude').code === 0 &&
118
123
  c.existsHost(launcherPath()),
119
124
  run: async (c) => {
@@ -125,6 +130,9 @@ const STEPS = [
125
130
  ),
126
131
  )
127
132
  c.layPayload()
133
+ // The payload now carries the skills tree into the app dir; link each skill
134
+ // into the on-device personal-skills source so `claude` discovers them.
135
+ c.linkSkills()
128
136
  await c.installDeps()
129
137
  c.writeLauncher()
130
138
  },
@@ -166,10 +174,25 @@ async function runStep(step, ctx) {
166
174
  return { name: step.name, ok, skipped, ms }
167
175
  }
168
176
 
177
+ /**
178
+ * Flatten the healthcheck record (`{ probe: { ok, err } }`) into one line's
179
+ * fields: `<probe>=<ok>` for every probe, in order, plus `<probe>Err=<cause>`
180
+ * immediately after any probe that failed. A healthy run carries no err fields.
181
+ */
182
+ function healthcheckFields(hc) {
183
+ const fields = {}
184
+ for (const [name, p] of Object.entries(hc)) {
185
+ fields[name] = p.ok
186
+ if (!p.ok && p.err) fields[`${name}Err`] = p.err
187
+ }
188
+ return fields
189
+ }
190
+
169
191
  /**
170
192
  * Run the full install. Stops at the first failed step (logs `done ok=false`,
171
- * no healthcheck). On success: emits the resolved versions, the four-probe
172
- * healthcheck, and `done ok=<all probes true>`.
193
+ * no healthcheck). On success: emits the resolved versions, the five-probe
194
+ * healthcheck (each probe's ok plus its captured err when false), and
195
+ * `done ok=<all probes true>`.
173
196
  */
174
197
  export async function orchestrate(ctx) {
175
198
  const t0 = ctx.now()
@@ -185,8 +208,8 @@ export async function orchestrate(ctx) {
185
208
  const v = ctx.readVersions()
186
209
  ctx.log('versions', { node: v.node, claude: v.claude, ttyd: v.ttyd })
187
210
  const hc = await ctx.runHealthcheck()
188
- ctx.log('healthcheck', hc)
189
- const ok = Object.values(hc).every(Boolean)
211
+ ctx.log('healthcheck', healthcheckFields(hc))
212
+ const ok = Object.values(hc).every((p) => p.ok)
190
213
  ctx.log('done', { ok, ranMs: ctx.now() - t0 })
191
214
  return { ok, results, healthcheck: hc }
192
215
  }
package/lib/paths.mjs CHANGED
@@ -21,6 +21,44 @@ export const PATHS = {
21
21
  export const WEBCHAT_DIR = `${PATHS.appDir}/webchat`
22
22
  export const VALIDATOR_CLI = `${PATHS.appDir}/validator/cli.mjs`
23
23
 
24
+ /**
25
+ * The guest-side command that proves node-pty's native module actually loads
26
+ * under the guest's Node — `require("node-pty")` exits 0 only when `pty.node` is
27
+ * both present and loadable (right ABI). This is the single construction of the
28
+ * load-check, shared by the `npm-app` skip-guard and `installDeps`' post-build
29
+ * assertion so the two can never drift: the guard admits exactly the modules the
30
+ * build asserts. A bare `test -f pty.node` would pass on a present-but-unloadable
31
+ * binary (stale/wrong-ABI from a prior build) and wrongly skip the rebuild.
32
+ */
33
+ export function ptyLoadCommand({ webchatDir = WEBCHAT_DIR } = {}) {
34
+ return `cd ${webchatDir} && node -e 'require("node-pty")'`
35
+ }
36
+
37
+ // The shipped skills, as bundled into the app dir, and the path the on-device
38
+ // `claude` reads as its personal-skills source. `claude` runs with HOME=/root in
39
+ // the proot Ubuntu layer, so `~/.claude/skills/*/SKILL.md` resolves under /root —
40
+ // discovered regardless of `claude`'s working directory (the vault). Skills are
41
+ // shipped app content, so they live in the rootfs app dir, not in the shared
42
+ // Obsidian vault; the install links them into SKILLS_HOME from this one source.
43
+ export const SKILLS_SRC = `${PATHS.appDir}/skills`
44
+ export const SKILLS_HOME = '/root/.claude/skills'
45
+
46
+ /**
47
+ * The guest-side command that makes the bundled skills discoverable: ensure the
48
+ * personal-skills dir exists, then symlink each shipped skill dir into it. One
49
+ * source of truth (the app dir); `ln -sfn` is idempotent and leaves any sibling
50
+ * personal skills untouched. `shopt -s nullglob` makes an empty or absent skills
51
+ * source expand to zero iterations instead of bash's default unmatched-glob
52
+ * behaviour, which would otherwise pass the literal pattern through, create a
53
+ * dangling symlink named `*`, and still exit 0. So the empty case is a clean
54
+ * no-op that the `skills` health probe then reports false, rather than a silent
55
+ * false success. Run inside the distro (the paths are guest paths). `src` and
56
+ * `home` are pinned constants, never user input, so there is no injection surface.
57
+ */
58
+ export function skillsLinkCommand({ src = SKILLS_SRC, home = SKILLS_HOME } = {}) {
59
+ return `shopt -s nullglob; mkdir -p ${home} && for d in ${src}/*/; do ln -sfn "$d" "${home}/$(basename "$d")"; done`
60
+ }
61
+
24
62
  // Where the host payload is bind-mounted inside the guest while it is copied into
25
63
  // place. A throwaway path under the rootfs's own /tmp, distinct from the app dir.
26
64
  export const PAYLOAD_STAGE = '/tmp/maxy-lite-payload'
@@ -45,10 +83,37 @@ export function launcherPath(prefix = process.env.PREFIX || '/data/data/com.term
45
83
  return `${prefix}/bin/maxy-lite`
46
84
  }
47
85
 
86
+ /**
87
+ * The env prefix the webchat relay needs to serve the bound vault on the pinned
88
+ * port. Shared by the launcher and the webchat health-check probe so the two
89
+ * cannot drift: the probe must start the relay with exactly the env production
90
+ * uses, or it tests a different process than the one the launcher runs.
91
+ */
92
+ export function webchatRunEnv({ vaultGuest = PATHS.vaultGuest, port = PATHS.webchatPort } = {}) {
93
+ return `LITE_AGENT_HOME=${vaultGuest} LITE_PORT=${port}`
94
+ }
95
+
96
+ /**
97
+ * A proot-distro login into the distro with the shared-storage vault bound in,
98
+ * running `inner` under `bash -lc`. This is the single construction of the
99
+ * launcher's runtime condition (the `--bind` that makes `${vaultGuest}` exist);
100
+ * the launcher and the vault/webchat probes all build on it so a probe can never
101
+ * assert a post-condition under different conditions than production.
102
+ * Run from bare Termux (not nested inside another `proot-distro login`).
103
+ */
104
+ export function vaultBindLogin(
105
+ inner,
106
+ { distro = PATHS.distro, vaultHost = PATHS.vaultHost, vaultGuest = PATHS.vaultGuest } = {},
107
+ ) {
108
+ return `proot-distro login ${distro} --bind ${vaultHost}:${vaultGuest} -- bash -lc '${inner}'`
109
+ }
110
+
48
111
  /**
49
112
  * The `maxy-lite` launcher script (written into the Termux bin dir). Run from the
50
113
  * Termux shell, it enters Ubuntu with the vault bind-mount and starts the webchat
51
- * relay on localhost. Documented Termux:Boot opt-in re-runs this on boot.
114
+ * relay on localhost. Built from the shared bind + env helpers so the launcher
115
+ * and the health-check probes stay in lockstep. Documented Termux:Boot opt-in
116
+ * re-runs this on boot.
52
117
  */
53
118
  export function launcherScript({
54
119
  prefix = process.env.PREFIX || '/data/data/com.termux/files/usr',
@@ -58,12 +123,12 @@ export function launcherScript({
58
123
  port = PATHS.webchatPort,
59
124
  distro = PATHS.distro,
60
125
  } = {}) {
126
+ const inner = `cd ${webchatDir} && ${webchatRunEnv({ vaultGuest, port })} exec node server.mjs`
61
127
  return `#!${prefix}/bin/bash
62
128
  # maxy-lite launcher — starts the on-device web chat relay.
63
129
  # Enters the glibc Ubuntu layer with the shared-storage vault bound in, then runs
64
130
  # the relay on http://localhost:${port}. Open that URL in the phone browser.
65
131
  set -e
66
- exec proot-distro login ${distro} --bind ${vaultHost}:${vaultGuest} -- \\
67
- bash -lc 'cd ${webchatDir} && LITE_AGENT_HOME=${vaultGuest} LITE_PORT=${port} exec node server.mjs'
132
+ exec ${vaultBindLogin(inner, { distro, vaultHost, vaultGuest })}
68
133
  `
69
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-lite",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Install maxy-lite on an Android phone: orchestrates proot-distro Ubuntu, glibc Node, claude, the web-chat relay, the vault and its bind-mount — run via npx in bare Termux.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: calendar-site
3
+ description: Stand up a public "book time with me" page on a custom domain, and pull the bookings it captures into the vault as events. This is the skill behind "give me a booking link", "put up a Calendly-style page", "let people book a call with me", "deploy the booking site", and "reconcile new bookings". It writes a static availability config, assembles the booking page plus its D1 capture, deploys via the site-deploy skill, and on demand reads new submissions from D1 and writes each as a vault Event (then pushes it to Google Calendar). The vault Event is the source of truth.
4
+ ---
5
+
6
+ # Stand up a booking site, and reconcile its bookings into the vault
7
+
8
+ Two halves. The first deploys a public page that captures bookings to Cloudflare D1, so a booking is captured even while the phone is offline or asleep. The second is a pull-on-demand reconcile: when run, it reads new submissions from D1 and turns each into a vault Event. There is no always-on process. The vault is authoritative, so a captured row only becomes real once it is written as an Event and validated.
9
+
10
+ The deploy mechanics (build, custom-domain attach, token discipline, the live done-gate) belong to the `site-deploy` skill. Read it before deploying. This skill adds the two things a booking site needs on top of a plain page: the static availability config the page reads to compute open slots, and the reconcile that brings captured rows into the vault.
11
+
12
+ ## The template
13
+
14
+ The booking page ships in `template/`:
15
+
16
+ - `availability.json` is the static slot source: `timezone`, `durationMins`, `bufferMins`, and a `weekly` window per day (`{ "mon": [["09:00","17:00"]], ..., "sat": [], "sun": [] }`, with an empty array for closed days).
17
+ - `public/` is the page. It fetches `availability.json` and computes open slots client-side as the weekly window minus the buffer for the next two weeks. There is no live free/busy merge, so the page never reads any event detail.
18
+ - `functions/api/book.ts` captures a submission to D1. `schema.sql` is the `bookings` table. `wrangler.toml` carries the Pages and D1 config with placeholders.
19
+
20
+ ## Deploy the page
21
+
22
+ 1. Collect the availability from the operator (timezone, meeting duration, buffer, weekly window) and write `availability.json`. Re-running with new numbers is how availability changes later; there is no separate editor.
23
+ 2. Assemble the template into the canonical Pages source path the `site-deploy` skill expects, and fill `wrangler.toml`'s `__PROJECT_NAME__`, `__D1_DATABASE_NAME__`, and `__D1_DATABASE_ID__`. Leave no `__...__` placeholder in the assembled tree.
24
+ 3. Provision the D1 `bookings` table by applying `schema.sql` to the remote database, using the Cloudflare token discipline the `site-deploy` skill and the Cloudflare connector own. Never write or echo a token.
25
+ 4. Hand the assembled tree to the `site-deploy` skill for the Pages deploy and custom-domain attach. Everything in its auth, domain, and DNS guidance applies unchanged.
26
+
27
+ Done for this half is the `site-deploy` live gate plus a test submission that lands in D1:
28
+
29
+ ```bash
30
+ curl -sS -X POST -H 'content-type: application/json' \
31
+ -d '{"slotStart":"2026-01-01T09:00:00","slotEnd":"2026-01-01T09:30:00","name":"verify","email":"verify@example.com"}' \
32
+ "https://<booking-domain>/api/book" -i | head -1
33
+ ```
34
+
35
+ ## Reconcile bookings into the vault
36
+
37
+ This is the bridge from a captured D1 row to a vault Event. Run it when checking for new bookings (next session, or when the operator asks). It has no background process; nothing happens to a booking until this runs.
38
+
39
+ 1. Read the accepted, unswept rows:
40
+
41
+ ```bash
42
+ wrangler d1 execute <db-name> --remote --command \
43
+ "SELECT bookingId, slotStart, slotEnd, name, email, note FROM bookings WHERE status = 'accepted' AND swept = 0;"
44
+ ```
45
+
46
+ 2. For each row, before writing anything, run the **overlap guard**: scan `calendar/` for an existing Event whose time range overlaps `[slotStart, slotEnd)`. If one overlaps, do not write the Event and do not mark the row swept. Tell the operator there is a clash for that slot so they can decline or rebook it; leaving the row unswept lets a later run retry it once the clash is resolved. This guard is why a static page is safe: the page can offer a slot that filled after the page loaded, and the clash is caught here rather than silently double-booking.
47
+
48
+ 3. For a non-overlapping row, write the Event exactly as the `scheduling` skill prescribes: a `calendar/<summary>.md` file with `type: event`, `summary` (for example `Booking: <name>`), `start` = `slotStart`, `end` = `slotEnd`, and the booker's name, email, and note in the markdown body. Then run the validator to exit 0:
49
+
50
+ ```bash
51
+ maxy-lite-validate <vault>
52
+ ```
53
+
54
+ A non-zero exit means the Event drifted from the SCHEMA; fix the file before going on. Do not mark the row swept while its Event is non-conformant.
55
+
56
+ 4. Once the Event validates, push it to Google Calendar through the connector (the same step the `scheduling` skill uses), then mark the row swept so it is not reconciled again:
57
+
58
+ ```bash
59
+ wrangler d1 execute <db-name> --remote --command \
60
+ "UPDATE bookings SET swept = 1 WHERE bookingId = '<bookingId>';"
61
+ ```
62
+
63
+ Done for this half is a test submission that, after a reconcile run, exists as a validator-passing vault Event.
64
+
65
+ ## Known limitation
66
+
67
+ The page computes open slots from the static availability window with no live busy-merge, so it can offer a slot that has already filled. The reconcile overlap guard catches the clash when the booking is pulled into the vault, but there is a window where a visitor can submit a slot that turns out to be taken. That clash surfaces at reconcile time, not at booking time. Live busy-merge and two-way sync are not part of this skill; the static window plus the reconcile guard are the whole mechanism here.
68
+
69
+ ## Tool discipline
70
+
71
+ The permitted surface is `wrangler`, `curl`, the Cloudflare API via the Cloudflare connector, and native vault file writes. Follow the Cloudflare references for token discipline: reuse one stable per-scope token, never mint one per deploy, never drive the dashboard with a browser, never write or echo a token. When a step fails, report the exact output with secrets redacted, name the failing URL or command, and stop.
@@ -0,0 +1,14 @@
1
+ {
2
+ "timezone": "Europe/London",
3
+ "durationMins": 30,
4
+ "bufferMins": 15,
5
+ "weekly": {
6
+ "mon": [["09:00", "17:00"]],
7
+ "tue": [["09:00", "17:00"]],
8
+ "wed": [["09:00", "17:00"]],
9
+ "thu": [["09:00", "17:00"]],
10
+ "fri": [["09:00", "17:00"]],
11
+ "sat": [],
12
+ "sun": []
13
+ }
14
+ }
@@ -0,0 +1,112 @@
1
+ // Booking capture Pages Function (calendar-site skill).
2
+ //
3
+ // Served at POST /api/book on the booking domain. Writes the submission
4
+ // straight to D1 so capture survives while the device is offline or asleep —
5
+ // the calendar-site reconcile pass turns accepted rows into vault Events later.
6
+ // Emits the [calendar-booking] submit + d1-write lifeline (Cloudflare Pages
7
+ // captures console output).
8
+ //
9
+ // The handler is split so its logic is verifiable without the Pages runtime:
10
+ // processBooking takes an injected DB + logger and returns the HTTP shape.
11
+
12
+ interface D1Result {
13
+ meta?: { changes?: number }
14
+ }
15
+ interface D1PreparedStatement {
16
+ bind: (...values: unknown[]) => D1PreparedStatement
17
+ run: () => Promise<D1Result>
18
+ }
19
+ interface D1Database {
20
+ prepare: (query: string) => D1PreparedStatement
21
+ }
22
+ interface BookingEnv {
23
+ DB: D1Database
24
+ }
25
+
26
+ interface BookingBody {
27
+ slotStart?: unknown
28
+ slotEnd?: unknown
29
+ name?: unknown
30
+ email?: unknown
31
+ note?: unknown
32
+ company?: unknown // honeypot — real users never fill this
33
+ }
34
+
35
+ type Logger = (line: string) => void
36
+
37
+ function isNonEmptyString(v: unknown): v is string {
38
+ return typeof v === 'string' && v.trim().length > 0
39
+ }
40
+
41
+ // Field caps so a malicious POST cannot store an unbounded blob in D1.
42
+ const MAX = { name: 200, email: 320, note: 2000, slot: 40 }
43
+
44
+ function isIsoTimestamp(v: unknown): v is string {
45
+ return typeof v === 'string' && v.length <= MAX.slot && !Number.isNaN(Date.parse(v))
46
+ }
47
+
48
+ export async function processBooking(
49
+ body: BookingBody,
50
+ env: BookingEnv,
51
+ log: Logger,
52
+ newId: () => string,
53
+ ): Promise<{ status: number; payload: Record<string, unknown> }> {
54
+ // Honeypot: a populated `company` field means a bot. Return ok (so the bot
55
+ // sees success) but write nothing.
56
+ if (isNonEmptyString(body.company)) {
57
+ return { status: 200, payload: { ok: true } }
58
+ }
59
+
60
+ if (
61
+ !isIsoTimestamp(body.slotStart) ||
62
+ !isIsoTimestamp(body.slotEnd) ||
63
+ !isNonEmptyString(body.name) ||
64
+ !isNonEmptyString(body.email)
65
+ ) {
66
+ return { status: 400, payload: { ok: false, error: 'name, email, and ISO slotStart/slotEnd are required' } }
67
+ }
68
+ if (body.name.length > MAX.name || body.email.length > MAX.email) {
69
+ return { status: 400, payload: { ok: false, error: 'name or email too long' } }
70
+ }
71
+
72
+ const bookingId = newId()
73
+ const note = isNonEmptyString(body.note) ? body.note.slice(0, MAX.note) : ''
74
+ const createdAt = new Date().toISOString()
75
+ log(`[calendar-booking] op=submit bookingId=${bookingId} slotStart=${body.slotStart} email=${body.email}`)
76
+
77
+ const result = await env.DB.prepare(
78
+ `INSERT INTO bookings (bookingId, slotStart, slotEnd, name, email, note, status, createdAt, swept)
79
+ VALUES (?, ?, ?, ?, ?, ?, 'accepted', ?, 0)`,
80
+ )
81
+ .bind(bookingId, body.slotStart, body.slotEnd, body.name, body.email, note, createdAt)
82
+ .run()
83
+
84
+ const rowsWritten = result.meta?.changes ?? 0
85
+ log(`[calendar-booking] op=d1-write bookingId=${bookingId} rowsWritten=${rowsWritten}`)
86
+
87
+ if (rowsWritten < 1) {
88
+ return { status: 500, payload: { ok: false, error: 'capture failed' } }
89
+ }
90
+ return { status: 200, payload: { ok: true, bookingId } }
91
+ }
92
+
93
+ interface PagesContext {
94
+ request: Request
95
+ env: BookingEnv
96
+ }
97
+
98
+ export async function onRequestPost(context: PagesContext): Promise<Response> {
99
+ let body: BookingBody
100
+ try {
101
+ body = (await context.request.json()) as BookingBody
102
+ } catch {
103
+ return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 })
104
+ }
105
+ const { status, payload } = await processBooking(
106
+ body,
107
+ context.env,
108
+ (line) => console.log(line),
109
+ () => crypto.randomUUID(),
110
+ )
111
+ return Response.json(payload, { status })
112
+ }