makersclaw 0.6.0 → 0.7.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.
Files changed (141) hide show
  1. package/README.md +48 -8
  2. package/package.json +1 -1
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +31 -4
  117. package/src/install.mjs +263 -21
  118. package/src/registry.mjs +78 -1
  119. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  120. package/templates/next/browser/app/layout.jsx +94 -0
  121. package/templates/next/browser/app/page.jsx +60 -0
  122. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  123. package/templates/next/browser/lib/actions.js +16 -0
  124. package/templates/next/browser/lib/data.js +43 -0
  125. package/templates/next/files/README.md +810 -79
  126. package/templates/next/files/app/frame.js +128 -4
  127. package/templates/next/files/check/run.mjs +1438 -41
  128. package/templates/next/files/components/ui/item.jsx +2 -2
  129. package/templates/next/files/components/ui/notice.jsx +1 -1
  130. package/templates/next/files/components/ui/separator.jsx +1 -1
  131. package/templates/next/files/lib/actions.js +170 -51
  132. package/templates/next/files/lib/brain-server.js +96 -0
  133. package/templates/next/files/lib/brain.js +413 -0
  134. package/templates/next/files/lib/claw.js +11 -0
  135. package/templates/next/files/lib/data.js +119 -29
  136. package/templates/next/files/lib/db.js +97 -17
  137. package/templates/next/files/lib/door.js +680 -0
  138. package/templates/next/files/next.config.mjs +36 -2
  139. package/templates/next/files/surfaces.js +3 -2
  140. package/templates/next/template.json +9 -3
  141. package/templates/next/files/schema.sql +0 -22
package/src/install.mjs CHANGED
@@ -2,8 +2,15 @@
2
2
  // result and prints nothing: src/cli.mjs says it, as text or as --json.
3
3
  //
4
4
  // Two files record what an app holds:
5
- // makersclaw.json the template and version it was laid from, and each
6
- // block with the version it came from
5
+ // makersclaw.json the template and version it was laid from, each
6
+ // block with the version it came from, where its
7
+ // tables live (`tables`: "own" or "public") and which
8
+ // fixed files it runs on (`mode`: "server" or
9
+ // "browser"). The rest of its keys are the app's
10
+ // (`brain`: the platform's declaration; `moves`: a
11
+ // store package's move map); every command keeps
12
+ // them. `tables` is never "moved": that is the
13
+ // platform's mode, and no command writes it
7
14
  // check/pins.json sha256 of every fixed file, the template's and the
8
15
  // blocks' — `npm run check` refuses one that differs
9
16
  // and a third says which package versions it installs:
@@ -21,7 +28,7 @@ import { join } from "node:path";
21
28
  import { hashFile, readJson, removeFile, sha256, walk, writeFile, writeJson } from "./fsutil.mjs";
22
29
  import { LOCKFILE, LockError, deriveLock, parseLock } from "./lockfile.mjs";
23
30
  import { NPM_LOCKFILE, PNPM_VERSION, installDeps } from "./pm.mjs";
24
- import { BLOCK_DIR, loadBlocks, loadItems, loadTemplate, packageInfo, readmeOf, suggest, templateNames } from "./registry.mjs";
31
+ import { BASE_MODE, BLOCK_DIR, loadBlocks, loadItems, loadTemplate, modeFiles, packageInfo, readmeOf, suggest, templateNames } from "./registry.mjs";
25
32
 
26
33
  export const MANIFEST = "makersclaw.json";
27
34
  export const PINS = "check/pins.json";
@@ -68,6 +75,7 @@ function readState(dir) {
68
75
 
69
76
  function requireManifest(dir) {
70
77
  const state = readState(dir);
78
+ if (state.manifest?.tables === MOVED) throw movedManifest();
71
79
  if (!state.manifest) {
72
80
  const hasApp = existsSync(join(dir, "package.json"));
73
81
  throw new CliError(
@@ -80,6 +88,142 @@ function requireManifest(dir) {
80
88
  return state;
81
89
  }
82
90
 
91
+ /** Where an app's tables live: "own", its own schema (app_<name>), or
92
+ * "public", the workspace schema every app shared before 0.7.0. */
93
+ const TABLES = ["own", "public"];
94
+
95
+ /**
96
+ * The platform's third mode (claw's MIGRATE-SPEC M17 and §10): a legacy
97
+ * app whose tables the workspace's move put in its own schema, while its
98
+ * code still names public. The platform holds it (the app's blueprint,
99
+ * claw.app_roles); the app's makersclaw.json keeps "public" (or no key)
100
+ * until its rebuild writes "own". So no command writes "moved": `init
101
+ * --tables moved` is refused, and so is every command over a manifest
102
+ * that says it, rather than write it back.
103
+ */
104
+ const MOVED = "moved";
105
+ const MOVED_WHY = "moved is the platform's mode, never a manifest's";
106
+ function movedManifest() {
107
+ return new CliError(
108
+ `${MANIFEST} says tables "moved": ${MOVED_WHY}`,
109
+ `set "tables" to "public" (or take the key out, for an app laid before 0.7.0): a moved app's code still names public, and its rebuild sets "own"`,
110
+ );
111
+ }
112
+
113
+ /**
114
+ * A makersclaw.json a lay wrote. Every lay since 0.2.0 records the
115
+ * `version` it laid, so a manifest without one was written by someone
116
+ * else first: the platform writes its `brain` declaration into a
117
+ * work-first app's new manifest before that app's first `init`
118
+ * (APPS-SPEC 4.2). Such a manifest's keys are kept like any other's, but
119
+ * it is no record of an app from before 0.7.0.
120
+ */
121
+ function writtenByLay(manifest) {
122
+ return Boolean(manifest) && Object.hasOwn(manifest, "version");
123
+ }
124
+
125
+ /**
126
+ * The `tables` init writes. It never flips an app: an app's tables move
127
+ * from public to their own schema only with its workspace (the platform's
128
+ * move), never because a refresh ran `init --force`. So an app's
129
+ * makersclaw.json keeps its value — an absent key in a manifest a lay
130
+ * wrote is an app from before 0.7.0, on public — and a `--tables` that
131
+ * says otherwise is refused. Where no lay wrote a makersclaw.json (there
132
+ * is none, or it holds only the platform's keys) and it names no tables,
133
+ * there is no record to keep: a new app is "own" unless told, and an app
134
+ * laid before makersclaw kept one is "public" unless told (the platform,
135
+ * which holds the truth, always tells).
136
+ */
137
+ function tablesFor(dir, before, asked) {
138
+ if (asked === MOVED) throw new CliError(`--tables moved: ${MOVED_WHY}`, `leave --tables out: a moved app keeps its makersclaw.json's "public" until its rebuild sets "own"`);
139
+ if (before.manifest?.tables === MOVED) throw movedManifest();
140
+ if (asked !== undefined && !TABLES.includes(asked)) {
141
+ throw new CliError(`--tables takes ${TABLES.join(" or ")}, not "${asked}"`, "own: the app's tables in its own schema (every new app) · public: an app from before 0.7.0");
142
+ }
143
+ const named = Boolean(before.manifest) && Object.hasOwn(before.manifest, "tables");
144
+ if (!named && !writtenByLay(before.manifest)) return asked ?? (existsSync(join(dir, "package.json")) ? "public" : "own");
145
+ const had = named ? before.manifest.tables : "public";
146
+ if (!TABLES.includes(had)) {
147
+ throw new CliError(`${MANIFEST} says tables ${JSON.stringify(had)}; an app's tables are ${TABLES.join(" or ")}`, `set "tables" in ${MANIFEST} to the one the platform holds for this app`);
148
+ }
149
+ if (asked !== undefined && asked !== had) {
150
+ throw new CliError(
151
+ (named ? `this app's tables are ${had}, not ${asked}` : `${MANIFEST} names no tables, so this app's are public (laid before 0.7.0), not ${asked}`) +
152
+ (had === "public" ? ": an app's tables move from public to their own schema only with its workspace" : ": an app on its own schema never goes back to public"),
153
+ `leave --tables out, or pass --tables ${had}` + (named ? "" : `; if its tables are already in its own schema, put "tables": "own" back in ${MANIFEST}`),
154
+ );
155
+ }
156
+ return had;
157
+ }
158
+
159
+ /** Which fixed files an app runs on: "server", a Next server in the
160
+ * workspace (files/ as it is; an absent key, every app before 0.7.0), or
161
+ * "browser", code that runs in the member's browser: files the platform
162
+ * serves, reading and writing through its data door (template.json
163
+ * `modes.browser`). */
164
+ const MODES = [BASE_MODE, "browser"];
165
+
166
+ /** The app's mode, from its makersclaw.json. Anything but the two is
167
+ * refused: which fixed files to lay depends on it. */
168
+ function modeOf(manifest) {
169
+ const mode = manifest && Object.hasOwn(manifest, "mode") ? manifest.mode : BASE_MODE;
170
+ if (!MODES.includes(mode)) {
171
+ throw new CliError(`${MANIFEST} says mode ${JSON.stringify(mode)}; an app's mode is ${MODES.join(" or ")}`, `makersclaw mode <${MODES.join("|")}> sets the one the platform holds for this app`);
172
+ }
173
+ return mode;
174
+ }
175
+
176
+ /**
177
+ * The `mode` init writes. Like `tables`, init never changes an app's: a
178
+ * refresh is claw's `init --force` over the app it has, and a flip there
179
+ * would turn a server app into a browser one whose Server Actions then fail
180
+ * its export. So a manifest that names a mode keeps it, whoever wrote it,
181
+ * and so does one a lay wrote (absent: server); a --mode that differs is
182
+ * refused, and `makersclaw mode` is the one switch. A new manifest takes
183
+ * --mode's value, and with none no key: an absent mode is server, the
184
+ * CLI's own default, because outside Makersclaw no door answers a browser
185
+ * app. { mode, key }: the app's mode, and the value to write (undefined:
186
+ * the key stays as it is).
187
+ */
188
+ function modeFor(before, asked) {
189
+ if (asked !== undefined && !MODES.includes(asked)) {
190
+ throw new CliError(`--mode takes ${MODES.join(" or ")}, not "${asked}"`, "server: a Next server in the workspace (the default) · browser: its code runs in the member's browser, and every read and write goes through the platform's data door");
191
+ }
192
+ const named = Boolean(before.manifest) && Object.hasOwn(before.manifest, "mode");
193
+ if (!named && !writtenByLay(before.manifest)) return { mode: asked ?? BASE_MODE, key: asked };
194
+ const had = modeOf(before.manifest);
195
+ if (asked !== undefined && asked !== had) {
196
+ throw new CliError(
197
+ `this app is a ${had} app, not ${asked}: an app changes mode only with makersclaw mode`,
198
+ `leave --mode out, or run makersclaw mode ${asked}: it lays that mode's fixed files and says what the app's own code must change`,
199
+ );
200
+ }
201
+ return { mode: had, key: undefined };
202
+ }
203
+
204
+ /** A browser app's tables are its own: the door serves an app's own schema
205
+ * and nothing else, so a browser app on public would read nothing. `how`
206
+ * says where the pair came from, for the refusal's words. */
207
+ function requireOwnTables(mode, tables, how) {
208
+ if (mode !== "browser" || tables === "own") return;
209
+ const why = "a browser app reads and writes only through the platform's data door, which serves an app's own schema and nothing else";
210
+ if (how === "flags") throw new CliError(`a browser app's tables are own, not ${tables}: ${why}`, "leave --tables out: --mode browser writes tables own");
211
+ if (how === "switch") {
212
+ throw new CliError(
213
+ `${tables === undefined ? `${MANIFEST} names no tables, so this app's are public (laid before 0.7.0)` : `this app's tables are ${tables}`}, not own: ${why}`,
214
+ "an app's tables move to their own schema only with its workspace (the platform's move); until then it stays a server app",
215
+ );
216
+ }
217
+ throw new CliError(`${MANIFEST} says mode browser and tables ${tables}: ${why}`, `if its tables are in its own schema, set "tables": "own"; if not, makersclaw mode server lays it as a server app`);
218
+ }
219
+
220
+ /** The files the template lays in this mode (registry modeFiles). */
221
+ function modeFilesOrFail(tpl, mode) {
222
+ const files = modeFiles(tpl, mode);
223
+ if (!files) throw new CliError(`template ${tpl.name} has no ${mode} mode in makersclaw ${version()}`, `its modes: ${[BASE_MODE, ...Object.keys(tpl.modes ?? {})].join(", ")}`);
224
+ return files;
225
+ }
226
+
83
227
  function loadTemplateOrFail(name) {
84
228
  const tpl = loadTemplate(name);
85
229
  if (!tpl) throw new CliError(`this makersclaw has no template "${name}"`, `templates: ${templateNames().join(", ")}`);
@@ -149,11 +293,12 @@ function writeLock(dir, cut, written) {
149
293
 
150
294
  const lockSummary = (cut) => (cut ? { covered: cut.covered.length, carried: cut.carried, left: cut.left } : null);
151
295
 
152
- /** Paths a template may remove when a newer version stops shipping them:
153
- * its fixed files, the ones it names as retired (middleware.js since
154
- * 0.6.0: Next 16 fails a build that has it beside proxy.js), and the kit,
155
- * parts and block folders. Anything else a pins.json names is left alone
156
- * — it is not ours to delete. */
296
+ /** Paths a template may remove when a newer version stops shipping them,
297
+ * or the app's mode stops laying them: its fixed files (the base's, so
298
+ * the ones a mode drops too), the ones it names as retired (middleware.js
299
+ * since 0.6.0: Next 16 fails a build that has it beside proxy.js), and
300
+ * the kit, parts and block folders. Anything else a pins.json names is
301
+ * left alone — it is not ours to delete. */
157
302
  function ownedPath(path, tpl) {
158
303
  if (/^components\/(?:ui|parts|blocks)\//.test(path) || /^lib\/[^/]+\.js$/.test(path)) return true;
159
304
  if ((tpl.retired ?? []).includes(path)) return true;
@@ -162,12 +307,24 @@ function ownedPath(path, tpl) {
162
307
 
163
308
  /**
164
309
  * Lay a template — and the blocks the app already has — over `dir`: the
165
- * one routine behind init, init --force and update. Fixed files and docs
166
- * are replaced; starters are laid only when missing; package.json is
310
+ * one routine behind init, init --force, update and mode. Fixed files and
311
+ * docs are replaced; starters are laid only when missing; package.json is
167
312
  * merged; blocks come again from this version; what an older version
168
- * pinned and this one no longer ships is removed.
313
+ * pinned and this one no longer ships is removed. makersclaw.json is
314
+ * rewritten over what it held: `template`, `version` and `blocks` are
315
+ * this lay's, `tables` is init's (tablesFor; update leaves it as it was),
316
+ * `mode` is a new manifest's init's or `makersclaw mode`'s (modeFor), and
317
+ * every other key — the platform's `brain` declaration, one a later
318
+ * version adds — survives a refresh and an update. Until 0.7.0 it was
319
+ * rewritten as exactly those three, and a declaration was lost.
320
+ *
321
+ * The files are the app's mode's (modeFiles): a browser app gets the
322
+ * browser overlay's copy of a fixed file where there is one, and none of
323
+ * the files browser mode drops. The pins follow: a file the mode does not
324
+ * lay is not pinned, and one that was pinned is removed as a retired one
325
+ * is, so a switch of mode leaves only the new mode's fixed files.
169
326
  */
170
- function lay(dir, tpl, before) {
327
+ function lay(dir, tpl, before, { tables, mode } = {}) {
171
328
  const v = version();
172
329
  const written = [];
173
330
  const kept = [];
@@ -177,7 +334,13 @@ function lay(dir, tpl, before) {
177
334
  const pins = {};
178
335
  const fresh = !existsSync(join(dir, "package.json"));
179
336
 
180
- for (const f of tpl.files) {
337
+ // Decided before a file is written: a refused mode leaves the app as it was.
338
+ const keys = { ...(tables ? { tables } : {}), ...(mode ? { mode } : {}) };
339
+ const appMode = modeOf({ ...before.manifest, ...keys });
340
+ requireOwnTables(appMode, keys.tables ?? before.manifest?.tables ?? "public", "manifest");
341
+ const files = modeFilesOrFail(tpl, appMode);
342
+
343
+ for (const f of files) {
181
344
  const to = join(dir, f.path);
182
345
  if (f.class === "starter" && existsSync(to)) {
183
346
  kept.push(f.path);
@@ -259,12 +422,24 @@ function lay(dir, tpl, before) {
259
422
  removed.push(p);
260
423
  }
261
424
  }
425
+ // A file the mode leaves out that no pin names is not the template's to
426
+ // delete (the app wrote it, or a hand laid it), but it is in the way:
427
+ // under output: "export" Next ignores a proxy.js, and lib/db.js is pg.
428
+ const laid = new Set(files.map((f) => f.path));
429
+ for (const f of tpl.files) {
430
+ if (f.class !== "fixed" || laid.has(f.path) || f.path in before.pins || !existsSync(join(dir, f.path))) continue;
431
+ notes.push(`${f.path}: ${appMode} mode lays none, and this one is not pinned — left alone; delete it unless the app's own code needs it`);
432
+ }
262
433
 
263
434
  writeJson(join(dir, PINS), sortKeys(pins));
264
- writeJson(join(dir, MANIFEST), { template: tpl.name, version: v, blocks: sortKeys(blocks) });
435
+ const manifest = { ...before.manifest, template: tpl.name, version: v, blocks: sortKeys(blocks), ...keys };
436
+ writeJson(join(dir, MANIFEST), manifest);
265
437
  return {
266
438
  template: tpl.name,
267
439
  version: v,
440
+ // An absent key is an app from before 0.7.0: its tables are in public.
441
+ tables: manifest.tables ?? "public",
442
+ mode: appMode,
268
443
  from: before.manifest?.version ?? null,
269
444
  written,
270
445
  kept,
@@ -278,14 +453,14 @@ function lay(dir, tpl, before) {
278
453
  };
279
454
  }
280
455
 
281
- /** `init [dir] [-t next] [--force]` */
282
- export function init(dir, { template = "next", force = false } = {}) {
456
+ /** `init [dir] [-t next] [--force] [--tables own|public] [--mode browser|server]` */
457
+ export function init(dir, { template = "next", force = false, tables, mode: askedMode } = {}) {
283
458
  const tpl = loadTemplateOrFail(template);
284
459
  const before = readState(dir);
285
460
  if (existsSync(join(dir, "package.json")) && !force) {
286
461
  throw new CliError(
287
462
  `${dir} already holds an app`,
288
- before.manifest
463
+ writtenByLay(before.manifest)
289
464
  ? "makersclaw update lays this version over it"
290
465
  : "makersclaw init --force lays the template over it: fixed files and docs are replaced, your own files stay",
291
466
  );
@@ -293,7 +468,13 @@ export function init(dir, { template = "next", force = false } = {}) {
293
468
  if (before.manifest?.template && before.manifest.template !== template) {
294
469
  throw new CliError(`this app was laid from template "${before.manifest.template}", not "${template}"`, `makersclaw init --force -t ${before.manifest.template}`);
295
470
  }
296
- return lay(dir, tpl, before);
471
+ // Decided before a file is written: a refused --tables or --mode leaves
472
+ // the app as it was. A new browser app's tables are own unless --tables
473
+ // says otherwise, and then it is refused.
474
+ const m = modeFor(before, askedMode);
475
+ const t = tablesFor(dir, before, tables ?? (m.key === "browser" ? "own" : undefined));
476
+ if (tables !== undefined) requireOwnTables(m.mode, t, "flags");
477
+ return lay(dir, tpl, before, { tables: t, mode: m.key });
297
478
  }
298
479
 
299
480
  /** `update [--major] [--adopt]` — this version's fixed files, docs and
@@ -330,6 +511,57 @@ export function update(dir, { major = false, adopt = false } = {}) {
330
511
  return lay(dir, tpl, before);
331
512
  }
332
513
 
514
+ /** What an app's own code must change when it moves to a mode. The
515
+ * template lays the fixed files; the surfaces are the app's, and move in
516
+ * the same build (claw's STATIC-SPEC §3, "What the Builder is told"). */
517
+ export const MODE_CHANGES = {
518
+ browser: [
519
+ 'every surface a client component ("use client") that reads through the lib/data hooks (useRows, useRow, useCount, useCountBy, useActivity) and writes through lib/actions; a hook compares its options on every render (a time in filter or since to the minute), so anything else worked out per render (a Date.now() number) is worked out once, with useMemo',
520
+ "view state (?row=) from useSearchParams(): a browser app's page is a file built once and has no request, so a surface gets no searchParams",
521
+ "a write of more than one row is a function in the app's schema ((args jsonb) returns jsonb), called with runAction and named in references/actions.md",
522
+ 'no server code of its own: no "use server" module, no route.js under app/, no proxy.js or middleware.js, no import of next/headers, next/cache, next/server, pg or lib/db',
523
+ "no export const dynamic, revalidate, fetchCache, runtime or dynamicParams in the app's own files",
524
+ "no process.env in the app's own code but CLAW_BASE_PATH, CLAW_PRODUCT and NEXT_PUBLIC_*: a prerendered page prints what it reads into a file anyone who opens the app can fetch",
525
+ ],
526
+ server: [
527
+ "lib/data.js is the server's again (pg, next/headers): a client surface takes its hooks from lib/door.js, or becomes a server component that calls loadRows",
528
+ "lib/actions.js is Server Actions again: a client surface calls them as it did",
529
+ "server code the app needs (a route handler, a Server Action of its own) may come back",
530
+ ],
531
+ };
532
+
533
+ /**
534
+ * `mode <browser|server>` — the one switch of an app's mode. It sets
535
+ * `mode` in makersclaw.json, lays that mode's fixed files, removes the
536
+ * other mode's (as update removes a retired file), and says what the
537
+ * app's own code must change. Browser needs `tables: "own"`: the door
538
+ * serves only an app's own schema. The app must be on this version, as
539
+ * for add: the switch lays this version's fixed files.
540
+ */
541
+ export function mode(dir, to) {
542
+ if (to === undefined) throw new CliError("name a mode: browser or server", "makersclaw mode browser · makersclaw mode server");
543
+ if (!MODES.includes(to)) throw new CliError(`an app's mode is ${MODES.join(" or ")}, not "${to}"`, "server: a Next server in the workspace · browser: its code runs in the member's browser, and every read and write goes through the platform's data door");
544
+ const before = requireManifest(dir);
545
+ const { manifest } = before;
546
+ const v = version();
547
+ if (manifest.version !== v) {
548
+ throw new CliError(
549
+ `this app is on makersclaw ${manifest.version}; this is ${v}, and a switch of mode lays this version's fixed files`,
550
+ newer(manifest.version, v)
551
+ ? `npx makersclaw@${manifest.version} mode ${to}`
552
+ : compatible(manifest.version, v)
553
+ ? `makersclaw update first (${manifest.version} → ${v} is compatible), then mode ${to}`
554
+ : `makersclaw update --major first (makersclaw diff shows what changes), then mode ${to}`,
555
+ );
556
+ }
557
+ const tpl = loadTemplateOrFail(manifest.template);
558
+ // Not checked: a mode this CLI cannot read is what the switch mends.
559
+ const was = Object.hasOwn(manifest, "mode") ? manifest.mode : BASE_MODE;
560
+ requireOwnTables(to, manifest.tables, "switch");
561
+ const r = lay(dir, tpl, before, { mode: to });
562
+ return { ...r, was, changed: was !== to, change: MODE_CHANGES[to] };
563
+ }
564
+
333
565
  function importFor(item) {
334
566
  if (item.type === "block") {
335
567
  const names = item.exports?.length ? item.exports.join(", ") : "…";
@@ -356,6 +588,9 @@ export function add(dir, names) {
356
588
  }
357
589
  const tpl = loadTemplateOrFail(manifest.template);
358
590
  const items = loadItems(tpl);
591
+ // A kit component is restored from the app's mode's copy of its file.
592
+ const appMode = modeOf(manifest);
593
+ const laid = new Map(modeFilesOrFail(tpl, appMode).map((f) => [f.path, f.from]));
359
594
  const order = [];
360
595
  const seen = new Set();
361
596
  const visit = (name, by) => {
@@ -380,7 +615,9 @@ export function add(dir, names) {
380
615
  const files = [];
381
616
  let changed = 0;
382
617
  for (const f of item.files) {
383
- const bytes = readFileSync(f.from);
618
+ const from = item.type === "block" ? f.from : laid.get(f.to);
619
+ if (!from) throw new CliError(`${item.name}: ${f.to} is not laid in ${appMode} mode`);
620
+ const bytes = readFileSync(from);
384
621
  const hash = sha256(bytes);
385
622
  if (hashFile(join(dir, f.to)) !== hash) {
386
623
  writeFile(join(dir, f.to), bytes);
@@ -490,6 +727,8 @@ export function diff(dir) {
490
727
  const { manifest, pins } = requireManifest(dir);
491
728
  const tpl = loadTemplateOrFail(manifest.template);
492
729
  const v = version();
730
+ const appMode = modeOf(manifest);
731
+ const files = modeFilesOrFail(tpl, appMode);
493
732
  const entries = [];
494
733
  const statusOf = (path, have, want, pinned) => {
495
734
  if (have === want) return null;
@@ -498,12 +737,14 @@ export function diff(dir) {
498
737
  return "changed";
499
738
  };
500
739
 
501
- for (const f of tpl.files) {
740
+ // The app's mode's files: update lays those, and removes a pinned file
741
+ // the mode does not lay ("dropped").
742
+ for (const f of files) {
502
743
  if (f.class !== "fixed" && f.class !== "docs") continue;
503
744
  const status = statusOf(f.path, hashFile(join(dir, f.path)), sha256(readFileSync(f.from)), f.class === "fixed" ? pins[f.path] : null);
504
745
  if (status) entries.push({ item: "template", path: f.path, status });
505
746
  }
506
- const shipped = new Set(tpl.files.map((f) => f.path));
747
+ const shipped = new Set(files.map((f) => f.path));
507
748
  for (const p of Object.keys(pins)) {
508
749
  if (!p.startsWith(`${BLOCK_DIR}/`) && !shipped.has(p) && ownedPath(p, tpl)) entries.push({ item: "template", path: p, status: "dropped" });
509
750
  }
@@ -541,6 +782,7 @@ export function diff(dir) {
541
782
  from: manifest.version,
542
783
  to: v,
543
784
  compatible: compatible(manifest.version, v) && !newer(manifest.version, v),
785
+ mode: appMode,
544
786
  entries,
545
787
  package: pkg,
546
788
  blocks: manifest.blocks ?? {},
package/src/registry.mjs CHANGED
@@ -56,6 +56,82 @@ export const FILE_CLASSES = ["fixed", "docs", "starter", "merged"];
56
56
  // Next 16 renamed proxy.js). `update` removes them from an app that has
57
57
  // them pinned; nothing lays them.
58
58
 
59
+ /**
60
+ * The mode files/ is: a Next server in the workspace. Every other mode is
61
+ * files/ with some fixed files replaced and some left out (template.json
62
+ * `modes`), and an app says which in makersclaw.json `mode` (absent: this
63
+ * one). Browser mode (0.7.0, claw's STATIC-SPEC §3): the app's code runs
64
+ * in the member's browser, from files the platform serves, and every read
65
+ * and write goes through the platform's data door. So it lays no
66
+ * proxy.js, no /claw-alive, no pg (lib/db.js) and no server reads of the
67
+ * brain (lib/brain-server.js).
68
+ */
69
+ export const BASE_MODE = "server";
70
+
71
+ /**
72
+ * A mode's overlay and drop list, checked against the base's files. Pure:
73
+ * names in, the first thing wrong thrown, nothing returned. `base` is the
74
+ * base's files ({ path, class }), `overlay` the paths under the mode's
75
+ * overlay folder, `drop` the base paths the mode leaves out.
76
+ * - No file is both dropped and overlaid: a mode lays a file or leaves
77
+ * it out, never both.
78
+ * - Each overlay file is a fixed file of the base. The overlay replaces
79
+ * what the check pins (and update lays again); it adds no file, and
80
+ * never a starter or a doc, which are the app's or read only.
81
+ * - Each dropped file is a fixed file of the base: a drop the base does
82
+ * not ship drops nothing, without a word, and only a pinned file is
83
+ * the template's to remove from an app.
84
+ */
85
+ export function checkMode(template, mode, { base, overlay, drop }) {
86
+ const fixed = new Set(base.filter((f) => f.class === "fixed").map((f) => f.path));
87
+ const dropped = new Set(drop);
88
+ for (const path of overlay) {
89
+ if (dropped.has(path)) throw new Error(`template ${template}: mode ${mode} both drops and overlays ${path} — a mode lays a file or leaves it out, not both`);
90
+ if (!fixed.has(path)) {
91
+ throw new Error(`template ${template}: mode ${mode} overlays ${path}, which is not a fixed file of files/ — an overlay replaces a fixed file and adds none`);
92
+ }
93
+ }
94
+ for (const path of drop) {
95
+ if (!fixed.has(path)) throw new Error(`template ${template}: mode ${mode} drops ${path}, which is not a fixed file of files/ — it would drop nothing`);
96
+ }
97
+ }
98
+
99
+ /** A mode's overlay folder: beside files/, a plain name. */
100
+ const OVERLAY = /^[a-z]+$/;
101
+
102
+ function loadModes(name, dir, meta, base) {
103
+ const modes = {};
104
+ for (const [mode, spec] of Object.entries(meta.modes ?? {})) {
105
+ if (!NAME.test(mode) || mode === BASE_MODE) throw new Error(`template ${name}: mode "${mode}" — ${BASE_MODE} is files/ itself, and a mode is named lowercase and dashed`);
106
+ const folder = spec?.overlay;
107
+ if (typeof folder !== "string" || !OVERLAY.test(folder) || folder === "files") {
108
+ throw new Error(`template ${name}: mode ${mode} names its overlay ${JSON.stringify(folder)} — a plain folder name beside files/`);
109
+ }
110
+ const root = join(dir, folder);
111
+ // A folder that is not there is an empty overlay: the mode only leaves
112
+ // files out. Browser mode's own files come after its drop list does.
113
+ const overlay = existsSync(root) ? walk(root) : [];
114
+ const drop = [...(spec.drop ?? [])];
115
+ checkMode(name, mode, { base, overlay, drop });
116
+ modes[mode] = { root, drop, files: overlay.map((path) => ({ path, class: "fixed", from: join(root, path) })) };
117
+ }
118
+ return modes;
119
+ }
120
+
121
+ /**
122
+ * The files a template lays in a mode: the base's, less the mode's drops,
123
+ * with the mode's copy in place of the base's where its overlay has one.
124
+ * The base mode is files/ as it is. null for a mode the template has not.
125
+ */
126
+ export function modeFiles(template, mode = BASE_MODE) {
127
+ if (mode === BASE_MODE) return template.files;
128
+ const m = template.modes?.[mode];
129
+ if (!m) return null;
130
+ const over = new Map(m.files.map((f) => [f.path, f]));
131
+ const drop = new Set(m.drop);
132
+ return template.files.filter((f) => !drop.has(f.path)).map((f) => over.get(f.path) ?? f);
133
+ }
134
+
59
135
  export function loadTemplate(name) {
60
136
  const dir = join(TEMPLATES, name);
61
137
  const meta = readJson(join(dir, "template.json"));
@@ -94,7 +170,8 @@ export function loadTemplate(name) {
94
170
  // The release lock (0.6.0 on): beside files/, never laid as it is. Each
95
171
  // app's pnpm-lock.yaml is cut from it (src/lockfile.mjs).
96
172
  const lock = existsSync(join(dir, RELEASE_LOCK)) ? join(dir, RELEASE_LOCK) : null;
97
- return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components, retired, lock };
173
+ const modes = loadModes(name, dir, meta, files);
174
+ return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components, retired, lock, modes };
98
175
  }
99
176
 
100
177
  /** The release lock's file, in templates/<name>/: made by `npm run lock`
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { claw } from "../lib/claw";
5
+ import { dataChanged, whenLanded } from "../lib/door";
6
+ import { changeFeed, refreshLoop } from "./frame";
7
+
8
+ // A browser app's auto-refresh (makersclaw.json "mode": "browser"). Rows
9
+ // change under this page from outside it: the agent writes them while
10
+ // the founder watches, and so do jobs and other members. The page is
11
+ // files, so there is nothing to re-render from a server: the hooks that
12
+ // read the rows (lib/data's useRows and the rest, lib/brain.js's) read
13
+ // again, and only when the data door says something changed.
14
+ //
15
+ // The rules are app/frame.js's (refreshLoop, and the reasons with each),
16
+ // the server app's every one:
17
+ //
18
+ // - only while the tab is visible AND the App tab is on screen: no timer
19
+ // at all otherwise, and coming back asks at once;
20
+ // - only while someone is there: after 30 minutes with no key, pointer,
21
+ // touch, wheel or focus in the app it stops, and the next sign asks
22
+ // at once;
23
+ // - only while the founder is not typing and no dialog is open
24
+ // (isEditing), and never while a surface has opted out with
25
+ // data-claw-refresh="off" on its root;
26
+ // - one tick at a time, the next timed once the last has landed: the
27
+ // interval plus what the last one took.
28
+ //
29
+ // What a tick does is this mode's own (changeFeed):
30
+ //
31
+ // - it asks GET _claw/data/changes?since=<cursor>. The first answer
32
+ // only sets the cursor and the stamp of the app's own tables; after
33
+ // it, a brain change or a different stamp is a change;
34
+ // - a change is dataChanged(), every hook on the page reading again,
35
+ // and the tick lands when whenLanded() says they all have their new
36
+ // rows. A read that fails keeps the rows it had;
37
+ // - never router.refresh(): under an export it fetches the same
38
+ // prebuilt payload. Never /claw-alive: nothing in the machine answers
39
+ // for a browser app, and the door is the platform's.
40
+ //
41
+ // The feed's first answer is asked as the page mounts, not at the first
42
+ // tick. That answer only sets where the feed is, so a change between it
43
+ // and the tick would never count: asked five seconds late, it would hide
44
+ // every change made while the page opened. This sits ahead of the Shell
45
+ // in the layout and React runs effects in that order, so the ask goes
46
+ // out before the surfaces' first reads: a change made in between is read
47
+ // twice, not missed.
48
+ export default function AutoRefresh() {
49
+ useEffect(() => {
50
+ const feed = changeFeed((path, signal) => claw(path, { signal }));
51
+ feed.probe();
52
+ const loop = refreshLoop({
53
+ doc: document,
54
+ probe: feed.probe,
55
+ refresh: () => {
56
+ feed.covered();
57
+ dataChanged();
58
+ return whenLanded();
59
+ },
60
+ });
61
+ return () => {
62
+ loop.stop();
63
+ feed.stop();
64
+ };
65
+ }, []);
66
+ return null;
67
+ }
@@ -0,0 +1,94 @@
1
+ import "./globals.css";
2
+ import { Examples } from "../components/parts/fixture";
3
+ import { surfaceNav } from "../surfaces";
4
+ import AutoRefresh from "./auto-refresh";
5
+ import ErrorRelay from "./error-relay";
6
+ import EscapeRelay from "./escape-relay";
7
+ import Shell from "./shell";
8
+
9
+ // FIXED — the platform agent does not edit this file. Design lives in
10
+ // app/shell.jsx and the surfaces; colour lives nowhere in the app.
11
+ //
12
+ // A browser app's layout (makersclaw.json "mode": "browser"). The app is
13
+ // files: `next build` prerenders every page once, with `output: "export"`
14
+ // (next.config.mjs), and the platform serves what it wrote. So nothing
15
+ // here reads the request. There is no request: no headers(), no
16
+ // cookies(), and no `dynamic` export, which an export refuses anyway.
17
+ // Everything the server layout decides per request is decided here at
18
+ // build, or in the browser.
19
+ export const metadata = { title: process.env.CLAW_PRODUCT || "Artifact" };
20
+
21
+ // The theme, decided in the browser before the body paints. It follows
22
+ // the desk: the OS window opens the app with ?theme=light|dark, and the
23
+ // product proxy keeps that query through its redirect and sets the
24
+ // claw_theme cookie (Path=/, readable here) on the same answer, so every
25
+ // later page reads the cookie. Dark when neither says: the desk is dark,
26
+ // and so is the data-theme the build writes.
27
+ //
28
+ // Why there is no flash: an inline script in <head> runs while the HTML
29
+ // is parsed, before anything paints, and _claw/theme.css after it still
30
+ // blocks the first paint. suppressHydrationWarning on <html> covers the
31
+ // one attribute React then finds changed. Anything that throws (a
32
+ // browser with cookies off) leaves the build's dark.
33
+ //
34
+ // A plain string, not a function turned into one: what runs is exactly
35
+ // what test/browser.test.mjs runs against a fake document.
36
+ const THEME = `(function () {
37
+ try {
38
+ var d = document.documentElement;
39
+ var q = new URLSearchParams(location.search).get("theme");
40
+ var m = document.cookie.match(/(?:^|; )claw_theme=(light|dark)(?:;|$)/);
41
+ d.dataset.theme = q === "light" || q === "dark" ? q : m ? m[1] : "dark";
42
+ } catch (e) {}
43
+ })();`;
44
+
45
+ // <html> carries what the browser-side code reads: the base path for
46
+ // lib/claw.js, the product name, the mode, the examples decision and
47
+ // data-theme.
48
+ //
49
+ // The palette follows the WORKSPACE: <head> links `_claw/theme.css`, the
50
+ // preset picked in System Settings → Appearance, served by the platform
51
+ // with the page's own cookie as identity. app/globals.css holds the same
52
+ // preset as defaults for a run with nothing serving it (a local `next
53
+ // dev`, where the link 404s harmlessly). The href is prefixed by hand:
54
+ // Next's basePath applies to next/link and assets, not to a literal
55
+ // <link>.
56
+ //
57
+ // Examples (components/parts/fixture.jsx) are off inside a workspace, by
58
+ // the server layout's own expression, read here at build: the platform
59
+ // builds every browser app with its CLAW_ARTIFACT_ID, and its publish
60
+ // refuses an export that says data-claw-examples="1". The store's own
61
+ // loop builds without one, and shows its examples. The title and
62
+ // data-claw-product are the name at build: a rename reaches them at the
63
+ // next ship.
64
+ export default function RootLayout({ children }) {
65
+ const basePath = process.env.CLAW_BASE_PATH || "";
66
+ const name = process.env.CLAW_PRODUCT || "Artifact";
67
+ const examples = process.env.CLAW_EXAMPLES === "1" || !process.env.CLAW_ARTIFACT_ID;
68
+ return (
69
+ <html
70
+ lang="en"
71
+ data-claw-base-path={basePath}
72
+ data-claw-product={name}
73
+ data-claw-mode="browser"
74
+ data-claw-examples={examples ? "1" : "0"}
75
+ data-theme="dark"
76
+ suppressHydrationWarning
77
+ >
78
+ <head>
79
+ <script dangerouslySetInnerHTML={{ __html: THEME }} />
80
+ <link rel="stylesheet" href={`${basePath}/_claw/theme.css`} />
81
+ </head>
82
+ <body>
83
+ <AutoRefresh />
84
+ <ErrorRelay />
85
+ <EscapeRelay />
86
+ <Examples allowed={examples}>
87
+ <Shell name={name} surfaces={surfaceNav()}>
88
+ {children}
89
+ </Shell>
90
+ </Examples>
91
+ </body>
92
+ </html>
93
+ );
94
+ }