makersclaw 0.6.0 → 0.8.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 (152) hide show
  1. package/README.md +56 -9
  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 +6 -6
  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 +10 -4
  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 +126 -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 +3 -3
  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 +24 -12
  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/chart/cartesian.jsx +1 -1
  67. package/registry/blocks/chart/core.jsx +33 -23
  68. package/registry/blocks/chart/pie.jsx +1 -1
  69. package/registry/blocks/chat/README.md +47 -14
  70. package/registry/blocks/chat/demo.jsx +2 -2
  71. package/registry/blocks/code-editor/README.md +4 -0
  72. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  73. package/registry/blocks/comment-box/README.md +20 -13
  74. package/registry/blocks/contact-section/README.md +7 -6
  75. package/registry/blocks/csv-import/README.md +34 -12
  76. package/registry/blocks/data-table/README.md +41 -8
  77. package/registry/blocks/diagram/diagram.jsx +1 -1
  78. package/registry/blocks/doc-editor/README.md +18 -6
  79. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  80. package/registry/blocks/doc-page/README.md +16 -6
  81. package/registry/blocks/file-upload/README.md +7 -1
  82. package/registry/blocks/flow-canvas/README.md +30 -12
  83. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  84. package/registry/blocks/footer/README.md +7 -4
  85. package/registry/blocks/gantt/README.md +18 -12
  86. package/registry/blocks/job-panel/README.md +1 -1
  87. package/registry/blocks/json-editor/README.md +16 -4
  88. package/registry/blocks/map/README.md +19 -3
  89. package/registry/blocks/map/map-view.jsx +1 -1
  90. package/registry/blocks/map/maplibre.js +1 -1
  91. package/registry/blocks/mind-map/README.md +20 -7
  92. package/registry/blocks/org-chart/README.md +14 -9
  93. package/registry/blocks/page-builder/README.md +14 -7
  94. package/registry/blocks/page-builder/demo.jsx +3 -3
  95. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  96. package/registry/blocks/page-builder/page-view.jsx +1 -1
  97. package/registry/blocks/page-builder/page.js +1 -1
  98. package/registry/blocks/pdf-viewer/README.md +26 -21
  99. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  100. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  101. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  102. package/registry/blocks/product-detail/README.md +13 -7
  103. package/registry/blocks/product-detail/demo.jsx +1 -1
  104. package/registry/blocks/product-grid/README.md +12 -10
  105. package/registry/blocks/record-form/README.md +26 -9
  106. package/registry/blocks/record-notes/README.md +42 -30
  107. package/registry/blocks/search-dialog/README.md +38 -7
  108. package/registry/blocks/settings-list/README.md +29 -17
  109. package/registry/blocks/setup-checklist/README.md +12 -14
  110. package/registry/blocks/share-dialog/README.md +1 -1
  111. package/registry/blocks/sidebar/README.md +23 -13
  112. package/registry/blocks/signature-pad/README.md +15 -4
  113. package/registry/blocks/sketch-canvas/README.md +25 -10
  114. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  115. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  116. package/registry/blocks/spreadsheet/README.md +17 -8
  117. package/registry/blocks/stat-cards/README.md +17 -8
  118. package/registry/blocks/task-list/README.md +14 -11
  119. package/registry/blocks/task-model/README.md +3 -3
  120. package/registry/blocks/transaction-list/README.md +15 -7
  121. package/registry/blocks/whiteboard/README.md +34 -22
  122. package/src/cli.mjs +33 -6
  123. package/src/install.mjs +285 -26
  124. package/src/registry.mjs +109 -1
  125. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  126. package/templates/next/browser/app/layout.jsx +94 -0
  127. package/templates/next/browser/app/page.jsx +60 -0
  128. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  129. package/templates/next/browser/lib/actions.js +16 -0
  130. package/templates/next/browser/lib/data.js +43 -0
  131. package/templates/next/files/README.md +1100 -89
  132. package/templates/next/files/app/frame.js +128 -4
  133. package/templates/next/files/app/shell.jsx +54 -37
  134. package/templates/next/files/check/run.mjs +1514 -45
  135. package/templates/next/files/components/ui/index.js +1 -0
  136. package/templates/next/files/components/ui/item.jsx +2 -2
  137. package/templates/next/files/components/ui/layout.jsx +307 -0
  138. package/templates/next/files/components/ui/notice.jsx +1 -1
  139. package/templates/next/files/components/ui/separator.jsx +1 -1
  140. package/templates/next/files/lib/actions.js +170 -51
  141. package/templates/next/files/lib/brain-server.js +96 -0
  142. package/templates/next/files/lib/brain.js +413 -0
  143. package/templates/next/files/lib/claw.js +15 -1
  144. package/templates/next/files/lib/data.js +119 -29
  145. package/templates/next/files/lib/db.js +97 -17
  146. package/templates/next/files/lib/door.js +680 -0
  147. package/templates/next/files/lib/hooks.js +71 -2
  148. package/templates/next/files/lib/platform.js +93 -4
  149. package/templates/next/files/next.config.mjs +36 -2
  150. package/templates/next/files/surfaces.js +3 -2
  151. package/templates/next/template.json +18 -3
  152. 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, modeDropScripts, modeFiles, modePackage, 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(", ")}`);
@@ -87,8 +231,11 @@ function loadTemplateOrFail(name) {
87
231
  }
88
232
 
89
233
  /** package.json: the template's scripts and dependencies win for the keys
90
- * it names; every other key is the app's. */
91
- function mergePackage(app, tpl) {
234
+ * it names; every other key is the app's. `dropScripts` are the scripts
235
+ * the app's mode has none of (registry modeDropScripts): removed whatever
236
+ * they say, so a server app switched to browser loses its `start`; a
237
+ * removal is a change `to: null`. */
238
+ function mergePackage(app, tpl, dropScripts = []) {
92
239
  const out = { ...app };
93
240
  const changes = [];
94
241
  for (const key of ["scripts", "dependencies", "devDependencies"]) {
@@ -100,6 +247,12 @@ function mergePackage(app, tpl) {
100
247
  out[key][name] = value;
101
248
  }
102
249
  }
250
+ for (const name of dropScripts) {
251
+ if (out.scripts?.[name] === undefined) continue;
252
+ out.scripts = { ...out.scripts };
253
+ changes.push({ key: "scripts", name, from: out.scripts[name], to: null });
254
+ delete out.scripts[name];
255
+ }
103
256
  return { out, changes };
104
257
  }
105
258
 
@@ -149,11 +302,12 @@ function writeLock(dir, cut, written) {
149
302
 
150
303
  const lockSummary = (cut) => (cut ? { covered: cut.covered.length, carried: cut.carried, left: cut.left } : null);
151
304
 
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. */
305
+ /** Paths a template may remove when a newer version stops shipping them,
306
+ * or the app's mode stops laying them: its fixed files (the base's, so
307
+ * the ones a mode drops too), the ones it names as retired (middleware.js
308
+ * since 0.6.0: Next 16 fails a build that has it beside proxy.js), and
309
+ * the kit, parts and block folders. Anything else a pins.json names is
310
+ * left alone — it is not ours to delete. */
157
311
  function ownedPath(path, tpl) {
158
312
  if (/^components\/(?:ui|parts|blocks)\//.test(path) || /^lib\/[^/]+\.js$/.test(path)) return true;
159
313
  if ((tpl.retired ?? []).includes(path)) return true;
@@ -162,12 +316,24 @@ function ownedPath(path, tpl) {
162
316
 
163
317
  /**
164
318
  * 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
319
+ * one routine behind init, init --force, update and mode. Fixed files and
320
+ * docs are replaced; starters are laid only when missing; package.json is
167
321
  * merged; blocks come again from this version; what an older version
168
- * pinned and this one no longer ships is removed.
322
+ * pinned and this one no longer ships is removed. makersclaw.json is
323
+ * rewritten over what it held: `template`, `version` and `blocks` are
324
+ * this lay's, `tables` is init's (tablesFor; update leaves it as it was),
325
+ * `mode` is a new manifest's init's or `makersclaw mode`'s (modeFor), and
326
+ * every other key — the platform's `brain` declaration, one a later
327
+ * version adds — survives a refresh and an update. Until 0.7.0 it was
328
+ * rewritten as exactly those three, and a declaration was lost.
329
+ *
330
+ * The files are the app's mode's (modeFiles): a browser app gets the
331
+ * browser overlay's copy of a fixed file where there is one, and none of
332
+ * the files browser mode drops. The pins follow: a file the mode does not
333
+ * lay is not pinned, and one that was pinned is removed as a retired one
334
+ * is, so a switch of mode leaves only the new mode's fixed files.
169
335
  */
170
- function lay(dir, tpl, before) {
336
+ function lay(dir, tpl, before, { tables, mode } = {}) {
171
337
  const v = version();
172
338
  const written = [];
173
339
  const kept = [];
@@ -177,14 +343,22 @@ function lay(dir, tpl, before) {
177
343
  const pins = {};
178
344
  const fresh = !existsSync(join(dir, "package.json"));
179
345
 
180
- for (const f of tpl.files) {
346
+ // Decided before a file is written: a refused mode leaves the app as it was.
347
+ const keys = { ...(tables ? { tables } : {}), ...(mode ? { mode } : {}) };
348
+ const appMode = modeOf({ ...before.manifest, ...keys });
349
+ requireOwnTables(appMode, keys.tables ?? before.manifest?.tables ?? "public", "manifest");
350
+ const files = modeFilesOrFail(tpl, appMode);
351
+
352
+ for (const f of files) {
181
353
  const to = join(dir, f.path);
182
354
  if (f.class === "starter" && existsSync(to)) {
183
355
  kept.push(f.path);
184
356
  continue;
185
357
  }
358
+ // package.json is the mode's (registry modePackage): a browser app's
359
+ // has no start script, whether it is laid new or merged.
186
360
  if (f.class === "merged" && existsSync(to)) {
187
- const { out, changes } = mergePackage(readJson(to), readJson(f.from));
361
+ const { out, changes } = mergePackage(readJson(to), modePackage(tpl, appMode), modeDropScripts(tpl, appMode));
188
362
  if (changes.length) {
189
363
  writeJson(to, out);
190
364
  written.push(f.path);
@@ -192,6 +366,11 @@ function lay(dir, tpl, before) {
192
366
  pkg = changes;
193
367
  continue;
194
368
  }
369
+ if (f.class === "merged") {
370
+ writeJson(to, modePackage(tpl, appMode));
371
+ written.push(f.path);
372
+ continue;
373
+ }
195
374
  const bytes = readFileSync(f.from);
196
375
  const hash = sha256(bytes);
197
376
  if (hashFile(to) !== hash) {
@@ -259,12 +438,24 @@ function lay(dir, tpl, before) {
259
438
  removed.push(p);
260
439
  }
261
440
  }
441
+ // A file the mode leaves out that no pin names is not the template's to
442
+ // delete (the app wrote it, or a hand laid it), but it is in the way:
443
+ // under output: "export" Next ignores a proxy.js, and lib/db.js is pg.
444
+ const laid = new Set(files.map((f) => f.path));
445
+ for (const f of tpl.files) {
446
+ if (f.class !== "fixed" || laid.has(f.path) || f.path in before.pins || !existsSync(join(dir, f.path))) continue;
447
+ 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`);
448
+ }
262
449
 
263
450
  writeJson(join(dir, PINS), sortKeys(pins));
264
- writeJson(join(dir, MANIFEST), { template: tpl.name, version: v, blocks: sortKeys(blocks) });
451
+ const manifest = { ...before.manifest, template: tpl.name, version: v, blocks: sortKeys(blocks), ...keys };
452
+ writeJson(join(dir, MANIFEST), manifest);
265
453
  return {
266
454
  template: tpl.name,
267
455
  version: v,
456
+ // An absent key is an app from before 0.7.0: its tables are in public.
457
+ tables: manifest.tables ?? "public",
458
+ mode: appMode,
268
459
  from: before.manifest?.version ?? null,
269
460
  written,
270
461
  kept,
@@ -278,14 +469,14 @@ function lay(dir, tpl, before) {
278
469
  };
279
470
  }
280
471
 
281
- /** `init [dir] [-t next] [--force]` */
282
- export function init(dir, { template = "next", force = false } = {}) {
472
+ /** `init [dir] [-t next] [--force] [--tables own|public] [--mode browser|server]` */
473
+ export function init(dir, { template = "next", force = false, tables, mode: askedMode } = {}) {
283
474
  const tpl = loadTemplateOrFail(template);
284
475
  const before = readState(dir);
285
476
  if (existsSync(join(dir, "package.json")) && !force) {
286
477
  throw new CliError(
287
478
  `${dir} already holds an app`,
288
- before.manifest
479
+ writtenByLay(before.manifest)
289
480
  ? "makersclaw update lays this version over it"
290
481
  : "makersclaw init --force lays the template over it: fixed files and docs are replaced, your own files stay",
291
482
  );
@@ -293,7 +484,13 @@ export function init(dir, { template = "next", force = false } = {}) {
293
484
  if (before.manifest?.template && before.manifest.template !== template) {
294
485
  throw new CliError(`this app was laid from template "${before.manifest.template}", not "${template}"`, `makersclaw init --force -t ${before.manifest.template}`);
295
486
  }
296
- return lay(dir, tpl, before);
487
+ // Decided before a file is written: a refused --tables or --mode leaves
488
+ // the app as it was. A new browser app's tables are own unless --tables
489
+ // says otherwise, and then it is refused.
490
+ const m = modeFor(before, askedMode);
491
+ const t = tablesFor(dir, before, tables ?? (m.key === "browser" ? "own" : undefined));
492
+ if (tables !== undefined) requireOwnTables(m.mode, t, "flags");
493
+ return lay(dir, tpl, before, { tables: t, mode: m.key });
297
494
  }
298
495
 
299
496
  /** `update [--major] [--adopt]` — this version's fixed files, docs and
@@ -330,6 +527,57 @@ export function update(dir, { major = false, adopt = false } = {}) {
330
527
  return lay(dir, tpl, before);
331
528
  }
332
529
 
530
+ /** What an app's own code must change when it moves to a mode. The
531
+ * template lays the fixed files; the surfaces are the app's, and move in
532
+ * the same build (claw's STATIC-SPEC §3, "What the Builder is told"). */
533
+ export const MODE_CHANGES = {
534
+ browser: [
535
+ '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',
536
+ "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",
537
+ "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",
538
+ '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',
539
+ "no export const dynamic, revalidate, fetchCache, runtime or dynamicParams in the app's own files",
540
+ "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",
541
+ ],
542
+ server: [
543
+ "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",
544
+ "lib/actions.js is Server Actions again: a client surface calls them as it did",
545
+ "server code the app needs (a route handler, a Server Action of its own) may come back",
546
+ ],
547
+ };
548
+
549
+ /**
550
+ * `mode <browser|server>` — the one switch of an app's mode. It sets
551
+ * `mode` in makersclaw.json, lays that mode's fixed files, removes the
552
+ * other mode's (as update removes a retired file), and says what the
553
+ * app's own code must change. Browser needs `tables: "own"`: the door
554
+ * serves only an app's own schema. The app must be on this version, as
555
+ * for add: the switch lays this version's fixed files.
556
+ */
557
+ export function mode(dir, to) {
558
+ if (to === undefined) throw new CliError("name a mode: browser or server", "makersclaw mode browser · makersclaw mode server");
559
+ 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");
560
+ const before = requireManifest(dir);
561
+ const { manifest } = before;
562
+ const v = version();
563
+ if (manifest.version !== v) {
564
+ throw new CliError(
565
+ `this app is on makersclaw ${manifest.version}; this is ${v}, and a switch of mode lays this version's fixed files`,
566
+ newer(manifest.version, v)
567
+ ? `npx makersclaw@${manifest.version} mode ${to}`
568
+ : compatible(manifest.version, v)
569
+ ? `makersclaw update first (${manifest.version} → ${v} is compatible), then mode ${to}`
570
+ : `makersclaw update --major first (makersclaw diff shows what changes), then mode ${to}`,
571
+ );
572
+ }
573
+ const tpl = loadTemplateOrFail(manifest.template);
574
+ // Not checked: a mode this CLI cannot read is what the switch mends.
575
+ const was = Object.hasOwn(manifest, "mode") ? manifest.mode : BASE_MODE;
576
+ requireOwnTables(to, manifest.tables, "switch");
577
+ const r = lay(dir, tpl, before, { mode: to });
578
+ return { ...r, was, changed: was !== to, change: MODE_CHANGES[to] };
579
+ }
580
+
333
581
  function importFor(item) {
334
582
  if (item.type === "block") {
335
583
  const names = item.exports?.length ? item.exports.join(", ") : "…";
@@ -356,6 +604,9 @@ export function add(dir, names) {
356
604
  }
357
605
  const tpl = loadTemplateOrFail(manifest.template);
358
606
  const items = loadItems(tpl);
607
+ // A kit component is restored from the app's mode's copy of its file.
608
+ const appMode = modeOf(manifest);
609
+ const laid = new Map(modeFilesOrFail(tpl, appMode).map((f) => [f.path, f.from]));
359
610
  const order = [];
360
611
  const seen = new Set();
361
612
  const visit = (name, by) => {
@@ -380,7 +631,9 @@ export function add(dir, names) {
380
631
  const files = [];
381
632
  let changed = 0;
382
633
  for (const f of item.files) {
383
- const bytes = readFileSync(f.from);
634
+ const from = item.type === "block" ? f.from : laid.get(f.to);
635
+ if (!from) throw new CliError(`${item.name}: ${f.to} is not laid in ${appMode} mode`);
636
+ const bytes = readFileSync(from);
384
637
  const hash = sha256(bytes);
385
638
  if (hashFile(join(dir, f.to)) !== hash) {
386
639
  writeFile(join(dir, f.to), bytes);
@@ -490,6 +743,8 @@ export function diff(dir) {
490
743
  const { manifest, pins } = requireManifest(dir);
491
744
  const tpl = loadTemplateOrFail(manifest.template);
492
745
  const v = version();
746
+ const appMode = modeOf(manifest);
747
+ const files = modeFilesOrFail(tpl, appMode);
493
748
  const entries = [];
494
749
  const statusOf = (path, have, want, pinned) => {
495
750
  if (have === want) return null;
@@ -498,12 +753,14 @@ export function diff(dir) {
498
753
  return "changed";
499
754
  };
500
755
 
501
- for (const f of tpl.files) {
756
+ // The app's mode's files: update lays those, and removes a pinned file
757
+ // the mode does not lay ("dropped").
758
+ for (const f of files) {
502
759
  if (f.class !== "fixed" && f.class !== "docs") continue;
503
760
  const status = statusOf(f.path, hashFile(join(dir, f.path)), sha256(readFileSync(f.from)), f.class === "fixed" ? pins[f.path] : null);
504
761
  if (status) entries.push({ item: "template", path: f.path, status });
505
762
  }
506
- const shipped = new Set(tpl.files.map((f) => f.path));
763
+ const shipped = new Set(files.map((f) => f.path));
507
764
  for (const p of Object.keys(pins)) {
508
765
  if (!p.startsWith(`${BLOCK_DIR}/`) && !shipped.has(p) && ownedPath(p, tpl)) entries.push({ item: "template", path: p, status: "dropped" });
509
766
  }
@@ -527,13 +784,14 @@ export function diff(dir) {
527
784
 
528
785
  const pkgPath = join(dir, "package.json");
529
786
  const tplPkg = tpl.files.find((f) => f.class === "merged");
530
- const pkg = existsSync(pkgPath) && tplPkg ? mergePackage(readJson(pkgPath), readJson(tplPkg.from)).changes : [];
787
+ const drop = modeDropScripts(tpl, appMode);
788
+ const pkg = existsSync(pkgPath) && tplPkg ? mergePackage(readJson(pkgPath), modePackage(tpl, appMode), drop).changes : [];
531
789
  // …and the installed blocks' npm packages, which update lays with them.
532
790
  const blockDeps = Object.assign({}, ...Object.keys(manifest.blocks ?? {}).map((n) => known.find((b) => b.name === n)?.dependencies ?? {}));
533
791
  if (existsSync(pkgPath) && Object.keys(blockDeps).length) pkg.push(...mergePackage(readJson(pkgPath), { dependencies: blockDeps }).changes);
534
792
  // …and the lock update would cut for the package.json it would leave.
535
793
  if (existsSync(pkgPath) && tplPkg) {
536
- const after = mergePackage(mergePackage(readJson(pkgPath), readJson(tplPkg.from)).out, { dependencies: blockDeps }).out;
794
+ const after = mergePackage(mergePackage(readJson(pkgPath), modePackage(tpl, appMode), drop).out, { dependencies: blockDeps }).out;
537
795
  const cut = cutLock(dir, tpl, after);
538
796
  if (cut && cut.text !== cut.before) entries.push({ item: "template", path: LOCKFILE, status: cut.before === null ? "new" : "changed" });
539
797
  }
@@ -541,6 +799,7 @@ export function diff(dir) {
541
799
  from: manifest.version,
542
800
  to: v,
543
801
  compatible: compatible(manifest.version, v) && !newer(manifest.version, v),
802
+ mode: appMode,
544
803
  entries,
545
804
  package: pkg,
546
805
  blocks: manifest.blocks ?? {},
package/src/registry.mjs CHANGED
@@ -56,6 +56,113 @@ 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
+ * - Each dropped script (`dropScripts`, 0.8.0) is a script of the base's
85
+ * package.json (`scripts`, its names), for the same reason.
86
+ */
87
+ export function checkMode(template, mode, { base, overlay, drop, scripts = [], dropScripts = [] }) {
88
+ const fixed = new Set(base.filter((f) => f.class === "fixed").map((f) => f.path));
89
+ const dropped = new Set(drop);
90
+ for (const path of overlay) {
91
+ 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`);
92
+ if (!fixed.has(path)) {
93
+ 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`);
94
+ }
95
+ }
96
+ for (const path of drop) {
97
+ 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`);
98
+ }
99
+ for (const name of dropScripts) {
100
+ if (!scripts.includes(name)) throw new Error(`template ${template}: mode ${mode} drops the script ${name}, which files/package.json has not — it would drop nothing`);
101
+ }
102
+ }
103
+
104
+ /** A mode's overlay folder: beside files/, a plain name. */
105
+ const OVERLAY = /^[a-z]+$/;
106
+
107
+ function loadModes(name, dir, meta, base) {
108
+ const modes = {};
109
+ for (const [mode, spec] of Object.entries(meta.modes ?? {})) {
110
+ 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`);
111
+ const folder = spec?.overlay;
112
+ if (typeof folder !== "string" || !OVERLAY.test(folder) || folder === "files") {
113
+ throw new Error(`template ${name}: mode ${mode} names its overlay ${JSON.stringify(folder)} — a plain folder name beside files/`);
114
+ }
115
+ const root = join(dir, folder);
116
+ // A folder that is not there is an empty overlay: the mode only leaves
117
+ // files out. Browser mode's own files come after its drop list does.
118
+ const overlay = existsSync(root) ? walk(root) : [];
119
+ const drop = [...(spec.drop ?? [])];
120
+ const dropScripts = [...(spec.dropScripts ?? [])];
121
+ const merged = base.find((f) => f.class === "merged");
122
+ const scripts = Object.keys((merged && readJson(merged.from)?.scripts) ?? {});
123
+ checkMode(name, mode, { base, overlay, drop, scripts, dropScripts });
124
+ modes[mode] = { root, drop, dropScripts, files: overlay.map((path) => ({ path, class: "fixed", from: join(root, path) })) };
125
+ }
126
+ return modes;
127
+ }
128
+
129
+ /**
130
+ * The files a template lays in a mode: the base's, less the mode's drops,
131
+ * with the mode's copy in place of the base's where its overlay has one.
132
+ * The base mode is files/ as it is. null for a mode the template has not.
133
+ */
134
+ export function modeFiles(template, mode = BASE_MODE) {
135
+ if (mode === BASE_MODE) return template.files;
136
+ const m = template.modes?.[mode];
137
+ if (!m) return null;
138
+ const over = new Map(m.files.map((f) => [f.path, f]));
139
+ const drop = new Set(m.drop);
140
+ return template.files.filter((f) => !drop.has(f.path)).map((f) => over.get(f.path) ?? f);
141
+ }
142
+
143
+ /**
144
+ * The scripts an app in this mode has none of (template.json
145
+ * `modes.<mode>.dropScripts`). Browser mode drops `start` (0.8.0): a
146
+ * browser app is built once and served as files, nothing ever starts it,
147
+ * and the store refuses a browser app's package.json that names one (its
148
+ * own-rules.mjs, browserScriptRefusals). Until 0.8.0 init laid the base's
149
+ * `start` into every app.
150
+ */
151
+ export function modeDropScripts(template, mode = BASE_MODE) {
152
+ return mode === BASE_MODE ? [] : [...(template.modes?.[mode]?.dropScripts ?? [])];
153
+ }
154
+
155
+ /** The template's package.json as a mode lays it: the base's, less the
156
+ * scripts the mode drops. null when the template has no merged file. */
157
+ export function modePackage(template, mode = BASE_MODE) {
158
+ const merged = template.files.find((f) => f.class === "merged");
159
+ if (!merged) return null;
160
+ const pkg = readJson(merged.from);
161
+ const drop = modeDropScripts(template, mode);
162
+ if (drop.length && pkg.scripts) pkg.scripts = Object.fromEntries(Object.entries(pkg.scripts).filter(([name]) => !drop.includes(name)));
163
+ return pkg;
164
+ }
165
+
59
166
  export function loadTemplate(name) {
60
167
  const dir = join(TEMPLATES, name);
61
168
  const meta = readJson(join(dir, "template.json"));
@@ -94,7 +201,8 @@ export function loadTemplate(name) {
94
201
  // The release lock (0.6.0 on): beside files/, never laid as it is. Each
95
202
  // app's pnpm-lock.yaml is cut from it (src/lockfile.mjs).
96
203
  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 };
204
+ const modes = loadModes(name, dir, meta, files);
205
+ return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components, retired, lock, modes };
98
206
  }
99
207
 
100
208
  /** 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
+ }