@rubytech/create-maxy-lite 0.1.4 → 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 +30 -10
- package/lib/healthcheck.mjs +60 -19
- package/lib/orchestrate.mjs +32 -11
- package/lib/paths.mjs +38 -0
- package/package.json +1 -1
- package/payload/skills/calendar-site/SKILL.md +71 -0
- package/payload/skills/calendar-site/template/availability.json +14 -0
- package/payload/skills/calendar-site/template/functions/api/book.ts +112 -0
- package/payload/skills/calendar-site/template/public/booking.css +100 -0
- package/payload/skills/calendar-site/template/public/booking.js +202 -0
- package/payload/skills/calendar-site/template/public/index.html +44 -0
- package/payload/skills/calendar-site/template/schema.sql +19 -0
- package/payload/skills/calendar-site/template/wrangler.toml +14 -0
- package/payload/skills/contacts/SKILL.md +57 -0
- package/payload/skills/memory/SKILL.md +48 -0
- package/payload/skills/projects/SKILL.md +47 -0
- package/payload/skills/publish-site/SKILL.md +21 -0
- package/payload/skills/scheduling/SKILL.md +74 -0
- package/payload/skills/site-deploy/SKILL.md +52 -0
- package/payload/skills/slides/SKILL.md +45 -0
- package/payload/skills/slides/deck.html +1359 -0
- package/payload/skills/work/SKILL.md +49 -0
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, vaultBindLogin, webchatRunEnv } 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,6 +107,13 @@ 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.
|
|
@@ -128,8 +135,9 @@ const installDeps = async () => {
|
|
|
128
135
|
// Prove the built module actually loads under the guest's Node — this is exactly
|
|
129
136
|
// what the relay does at launch, so a pass here means no `Failed to load native
|
|
130
137
|
// module: pty.node` crash. A bare file-presence test would miss an unloadable
|
|
131
|
-
// binary.
|
|
132
|
-
|
|
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)
|
|
133
141
|
throw new Error(`node-pty native module did not load after build: ${PTY_NODE}`)
|
|
134
142
|
}
|
|
135
143
|
|
|
@@ -160,7 +168,8 @@ const runHealthcheck = () =>
|
|
|
160
168
|
// bound in and the launcher env set — then poll its port for up to 10s, record
|
|
161
169
|
// whether it bound, and kill it. Run from bare Termux (`run`) because
|
|
162
170
|
// vaultBindLogin already enters the distro; `runIn` would nest a second login
|
|
163
|
-
// with no bind and test the wrong process.
|
|
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=.
|
|
164
173
|
checkWebchat: () =>
|
|
165
174
|
webchatOk(async () => {
|
|
166
175
|
const inner =
|
|
@@ -169,18 +178,28 @@ const runHealthcheck = () =>
|
|
|
169
178
|
`curl -sf -o /dev/null http://localhost:${PATHS.webchatPort} && break; done; ` +
|
|
170
179
|
`curl -sf -o /dev/null http://localhost:${PATHS.webchatPort}; RC=$?; ` +
|
|
171
180
|
`kill $SVPID 2>/dev/null; exit $RC`
|
|
172
|
-
return run(vaultBindLogin(inner))
|
|
181
|
+
return run(vaultBindLogin(inner))
|
|
173
182
|
}),
|
|
174
|
-
// 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=.
|
|
175
185
|
checkValidator: () => {
|
|
176
186
|
const empty = `${PATHS.appDir}/.hc-empty-vault`
|
|
177
187
|
runIn(`mkdir -p ${empty}`)
|
|
178
|
-
return validatorOk((vault) => runIn(`node ${VALIDATOR_CLI} ${vault}`)
|
|
188
|
+
return validatorOk((vault) => runIn(`node ${VALIDATOR_CLI} ${vault}`), empty)
|
|
179
189
|
},
|
|
180
190
|
// The vault path only exists inside the distro when the login carries the
|
|
181
191
|
// launcher's `--bind`. Reproduce that bind, then check the guest path — exactly
|
|
182
|
-
// the launcher condition, not a session where the vault was never mounted.
|
|
183
|
-
|
|
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`)),
|
|
184
203
|
})
|
|
185
204
|
|
|
186
205
|
// ---- dry run ----------------------------------------------------------------
|
|
@@ -237,6 +256,7 @@ async function main() {
|
|
|
237
256
|
interactive,
|
|
238
257
|
ensureStorageGrant,
|
|
239
258
|
layPayload,
|
|
259
|
+
linkSkills,
|
|
240
260
|
installDeps,
|
|
241
261
|
writeLauncher,
|
|
242
262
|
readVersions,
|
package/lib/healthcheck.mjs
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
/**
|
|
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)` →
|
|
45
|
+
/** True when the validator exits 0 on the given (empty) vault. `runValidator(vault)` → {code, stderr}. */
|
|
19
46
|
export function validatorOk(runValidator, vaultPath) {
|
|
20
|
-
|
|
47
|
+
const r = runValidator(vaultPath)
|
|
48
|
+
return verdict(r.code === 0, r)
|
|
21
49
|
}
|
|
22
50
|
|
|
23
|
-
/** True when the bind-mount path exists. `
|
|
24
|
-
export function vaultOk(
|
|
25
|
-
|
|
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<
|
|
57
|
+
/** True when the relay port responded. `getStatus()` → Promise<{code, stderr}>. */
|
|
29
58
|
export async function webchatOk(getStatus) {
|
|
30
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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()
|
|
37
|
-
webchat:
|
|
38
|
-
validator: checkValidator()
|
|
39
|
-
vault: checkVault()
|
|
76
|
+
claude: checkClaude(),
|
|
77
|
+
webchat: await checkWebchat(),
|
|
78
|
+
validator: checkValidator(),
|
|
79
|
+
vault: checkVault(),
|
|
80
|
+
skills: checkSkills(),
|
|
40
81
|
}
|
|
41
82
|
}
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -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,
|
|
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,13 +109,16 @@ const STEPS = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
name: 'npm-app',
|
|
112
|
-
// Converged only when node-pty's native module
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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.
|
|
117
120
|
done: (c) =>
|
|
118
|
-
c.runIn(
|
|
121
|
+
c.runIn(ptyLoadCommand()).code === 0 &&
|
|
119
122
|
c.runIn('command -v claude').code === 0 &&
|
|
120
123
|
c.existsHost(launcherPath()),
|
|
121
124
|
run: async (c) => {
|
|
@@ -127,6 +130,9 @@ const STEPS = [
|
|
|
127
130
|
),
|
|
128
131
|
)
|
|
129
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()
|
|
130
136
|
await c.installDeps()
|
|
131
137
|
c.writeLauncher()
|
|
132
138
|
},
|
|
@@ -168,10 +174,25 @@ async function runStep(step, ctx) {
|
|
|
168
174
|
return { name: step.name, ok, skipped, ms }
|
|
169
175
|
}
|
|
170
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
|
+
|
|
171
191
|
/**
|
|
172
192
|
* Run the full install. Stops at the first failed step (logs `done ok=false`,
|
|
173
|
-
* no healthcheck). On success: emits the resolved versions, the
|
|
174
|
-
* healthcheck
|
|
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>`.
|
|
175
196
|
*/
|
|
176
197
|
export async function orchestrate(ctx) {
|
|
177
198
|
const t0 = ctx.now()
|
|
@@ -187,8 +208,8 @@ export async function orchestrate(ctx) {
|
|
|
187
208
|
const v = ctx.readVersions()
|
|
188
209
|
ctx.log('versions', { node: v.node, claude: v.claude, ttyd: v.ttyd })
|
|
189
210
|
const hc = await ctx.runHealthcheck()
|
|
190
|
-
ctx.log('healthcheck', hc)
|
|
191
|
-
const ok = Object.values(hc).every(
|
|
211
|
+
ctx.log('healthcheck', healthcheckFields(hc))
|
|
212
|
+
const ok = Object.values(hc).every((p) => p.ok)
|
|
192
213
|
ctx.log('done', { ok, ranMs: ctx.now() - t0 })
|
|
193
214
|
return { ok, results, healthcheck: hc }
|
|
194
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'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubytech/create-maxy-lite",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* Public booking page (calendar-site skill). Clean single-column Calendly-style
|
|
2
|
+
layout. Self-contained — no shared tokens, since this ships to Cloudflare
|
|
3
|
+
Pages, not the admin shell. The skill may overlay brand colors. */
|
|
4
|
+
:root {
|
|
5
|
+
--bk-bg: #f6f6f4;
|
|
6
|
+
--bk-card: #ffffff;
|
|
7
|
+
--bk-ink: #1d1d1f;
|
|
8
|
+
--bk-muted: #6b6b70;
|
|
9
|
+
--bk-line: #e3e3e0;
|
|
10
|
+
--bk-accent: #4b6358;
|
|
11
|
+
--bk-accent-ink: #ffffff;
|
|
12
|
+
--bk-radius: 12px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
background: var(--bk-bg);
|
|
20
|
+
color: var(--bk-ink);
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
22
|
+
display: flex;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
padding: 32px 16px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.bk-card {
|
|
28
|
+
width: 100%;
|
|
29
|
+
max-width: 520px;
|
|
30
|
+
background: var(--bk-card);
|
|
31
|
+
border: 1px solid var(--bk-line);
|
|
32
|
+
border-radius: var(--bk-radius);
|
|
33
|
+
padding: 28px;
|
|
34
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.bk-title { margin: 0 0 4px; font-size: 1.6rem; }
|
|
38
|
+
.bk-sub { margin: 0 0 20px; color: var(--bk-muted); }
|
|
39
|
+
.bk-status { margin: 8px 0; color: var(--bk-muted); }
|
|
40
|
+
|
|
41
|
+
.bk-day { margin-bottom: 20px; }
|
|
42
|
+
.bk-day-title { font-size: 0.95rem; margin: 0 0 8px; color: var(--bk-ink); }
|
|
43
|
+
.bk-day-slots { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
44
|
+
|
|
45
|
+
.bk-slot {
|
|
46
|
+
border: 1px solid var(--bk-accent);
|
|
47
|
+
background: transparent;
|
|
48
|
+
color: var(--bk-accent);
|
|
49
|
+
border-radius: 8px;
|
|
50
|
+
padding: 8px 14px;
|
|
51
|
+
font-size: 0.9rem;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
}
|
|
54
|
+
.bk-slot:hover { background: var(--bk-accent); color: var(--bk-accent-ink); }
|
|
55
|
+
|
|
56
|
+
.bk-form { display: flex; flex-direction: column; gap: 14px; }
|
|
57
|
+
.bk-chosen { font-weight: 600; margin: 0 0 4px; }
|
|
58
|
+
.bk-label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; color: var(--bk-muted); }
|
|
59
|
+
.bk-input {
|
|
60
|
+
border: 1px solid var(--bk-line);
|
|
61
|
+
border-radius: 8px;
|
|
62
|
+
padding: 10px 12px;
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
color: var(--bk-ink);
|
|
65
|
+
font-family: inherit;
|
|
66
|
+
}
|
|
67
|
+
.bk-input:focus { outline: 2px solid var(--bk-accent); border-color: var(--bk-accent); }
|
|
68
|
+
|
|
69
|
+
/* honeypot — visually and from the layout removed, still in the DOM for bots */
|
|
70
|
+
.bk-hp {
|
|
71
|
+
position: absolute;
|
|
72
|
+
left: -9999px;
|
|
73
|
+
width: 1px;
|
|
74
|
+
height: 1px;
|
|
75
|
+
opacity: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.bk-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; }
|
|
79
|
+
.bk-back {
|
|
80
|
+
background: transparent;
|
|
81
|
+
border: 1px solid var(--bk-line);
|
|
82
|
+
border-radius: 8px;
|
|
83
|
+
padding: 10px 16px;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
color: var(--bk-muted);
|
|
86
|
+
}
|
|
87
|
+
.bk-submit {
|
|
88
|
+
background: var(--bk-accent);
|
|
89
|
+
color: var(--bk-accent-ink);
|
|
90
|
+
border: none;
|
|
91
|
+
border-radius: 8px;
|
|
92
|
+
padding: 10px 20px;
|
|
93
|
+
font-size: 1rem;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
.bk-submit:disabled { opacity: 0.6; cursor: default; }
|
|
97
|
+
|
|
98
|
+
.bk-done { text-align: center; padding: 16px 0; }
|
|
99
|
+
.bk-done-title { margin: 0 0 8px; font-size: 1.3rem; }
|
|
100
|
+
.bk-done-msg { color: var(--bk-muted); margin: 0; }
|