@unoverse-platform/studio 0.1.30 → 0.1.32

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/bin/studio.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  * universe, which is a different job. See docs/architecture/LOCAL_STUDIO.md.
18
18
  */
19
19
  import { createServer } from "vite";
20
- import { dirname, resolve, join } from "path";
20
+ import { dirname, resolve, join, basename } from "path";
21
21
  import { fileURLToPath } from "url";
22
22
  import { existsSync, readdirSync } from "fs";
23
23
  import { createInterface } from "readline/promises";
@@ -33,8 +33,9 @@ const appRoot = resolve(here, "..");
33
33
  * which is a tool telling you to do the thing it could do. The first thirty seconds of a
34
34
  * new install should end with something on screen, not with a mkdir.
35
35
  *
36
- * It asks rather than assumes: creating folders in whatever directory somebody happens to
37
- * be in is not a thing to do quietly. Answer no and the original message stands.
36
+ * It creates rather than asks. Standing in an empty folder you made and typing this IS the
37
+ * instruction; the folder says where and its name says what. The only question left is the
38
+ * one it cannot answer for you: a folder whose name no project can carry.
38
39
  */
39
40
  /** The same walk `catalog.ts` does: the nearest folder above us holding rx/. */
40
41
  function findProjectRootFromCwd() {
@@ -92,45 +93,40 @@ async function scaffoldIfEmpty() {
92
93
  return;
93
94
  }
94
95
 
95
- console.log(`\n No project here yet.\n\n ${cwd}\n`);
96
-
97
96
  /**
98
- * THE HOLDER IS CALLED `studio`. THE NAME YOU TYPE IS A PROJECT INSIDE IT.
99
- *
100
- * Studio used to scaffold into the cwd, which made whatever directory you were standing
101
- * in your repository. Fixing that by naming the folder after the project overcorrected:
102
- * it produced `sab/rx/sab`, and it said the wrong thing about what the folder IS.
97
+ * THE WORKSPACE IS THE FOLDER YOU ARE STANDING IN, and the project takes its name.
103
98
  *
104
- * This folder is not a project. It is the workspace a developer authors in, holding
105
- * `prompts/`, `nodes/`, one `.claude/` skill and an `rx/` of MANY projects — every one
106
- * of which is an org that publishes under its own name. Naming it after the first
107
- * project claims the second one belongs to the first, and the fifth project makes that
108
- * plainly false while the folder still carries the first one's name.
99
+ * Two earlier shapes invented a folder instead of accepting the one the developer had
100
+ * chosen: `rx/<name>/` into ANY cwd (so a scratch folder became a repository), then the
101
+ * typed name owning a folder, then a fixed `studio/` holder — which buried the project
102
+ * one level inside the folder made for it, and asked for a name it could already see.
103
+ * Standing in `~/code/bpp` and typing `unoverse studio` says both things: here, and bpp.
109
104
  *
110
- * So the holder is always `studio/`, and the name you typed is where it belongs: the
111
- * project, at `rx/<name>/`.
105
+ * A workspace does hold MANY projects (`rx/` is one folder per publishing org), and the
106
+ * folder carrying the first one's name is why the holder was introduced. That is a cost
107
+ * paid at project five, by a developer who can rename the folder; it does not buy the
108
+ * confusion of an extra level in the first thirty seconds.
112
109
  */
113
- const home = join(cwd, "studio");
114
- if (existsSync(home) && readdirSync(home).some((f) => !f.startsWith("."))) {
115
- // Not a re-prompt: a different project name would not make this folder free, and the
116
- // loop below would ask forever. (A `studio/` that IS a project was already found and
117
- // entered above, so getting here means it is something else.)
118
- console.log(` ./studio already has something in it, and it is not a Studio workspace.\n`);
119
- process.exit(1);
120
- }
121
-
122
- const rl = createInterface({ input: process.stdin, output: process.stdout });
123
-
124
- // Ask until the name is usable. It becomes a folder AND an item's `org` column, so a
125
- // rejection here is cheaper than a rename after something has been published.
126
- let name;
127
- for (;;) {
128
- name = (await rl.question(" Name your project [my-project]: ")).trim() || "my-project";
129
- const bad = checkProjectName(name);
130
- if (!bad) break;
131
- console.log(` ${bad}\n`);
110
+ const home = cwd;
111
+ // The folder's own name, when it can be a project name. It becomes an item's `org`
112
+ // column, so `checkProjectName` is the same gate the UI's New project uses.
113
+ const derived = basename(cwd);
114
+ let name = checkProjectName(derived) ? null : derived;
115
+
116
+ if (!name) {
117
+ // NOTHING TO DERIVE — the folder is called something no project can be called
118
+ // (capitals, dots, a leading digit). This is the only case that still asks.
119
+ console.log(`\n No project here yet.\n\n ${cwd}\n`);
120
+ console.log(` "${derived}" cannot be a project name (lowercase letters, numbers and dashes).\n`);
121
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
122
+ for (;;) {
123
+ name = (await rl.question(" Name your project [my-project]: ")).trim() || "my-project";
124
+ const bad = checkProjectName(name);
125
+ if (!bad) break;
126
+ console.log(` ${bad}\n`);
127
+ }
128
+ rl.close();
132
129
  }
133
- rl.close();
134
130
 
135
131
  // The same call the UI's New project makes, so the first project and the fifth are
136
132
  // identical (local/scaffold.mjs). rx/<name>/ stays nested: every subfolder of rx/ IS a
@@ -138,9 +134,7 @@ async function scaffoldIfEmpty() {
138
134
  // publishes, so a flat rx/ could hold exactly one project and would read `components`
139
135
  // and `templates` as orgs.
140
136
  createFirstProject(home, name);
141
- // Studio reads from the cwd, and the project is no longer the folder we launched in.
142
- process.chdir(home);
143
- console.log(`\n Created studio/ with rx/${name}/, prompts/ and nodes/\n`);
137
+ console.log(`\n Created rx/${name}/, prompts/ and nodes/ in ${cwd}\n`);
144
138
  }
145
139
 
146
140
  await scaffoldIfEmpty();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -39,6 +39,16 @@ const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
39
39
  /** `props` remaps the atom's OWN field names to the host's fields, everywhere a field name
40
40
  * can appear: binds, both spellings of `visibleWhen`, and `style.when` predicates. */
41
41
  function remapFields(node: AnyNode, propMap: Record<string, string>): void {
42
+ // A NESTED `Ref` is remapped through its OWN `props`, whose values name fields in THIS
43
+ // tree's scope. An atom that composes another atom (toggle-row, form-toggle → toggle-switch)
44
+ // otherwise passes the inner one a name the host has renamed away, and the inner control
45
+ // reads an absent key: it draws, it just never reflects anything.
46
+ if (node.type === "Ref" && node.props && typeof node.props === "object") {
47
+ for (const k of Object.keys(node.props as AnyNode)) {
48
+ const v = (node.props as AnyNode)[k];
49
+ if (typeof v === "string" && propMap[v]) (node.props as AnyNode)[k] = propMap[v];
50
+ }
51
+ }
42
52
  if (node.bind) for (const k of Object.keys(node.bind)) if (propMap[node.bind[k]]) node.bind[k] = propMap[node.bind[k]];
43
53
  if (typeof node.visibleWhen === "string" && propMap[node.visibleWhen]) node.visibleWhen = propMap[node.visibleWhen];
44
54
  // The OBJECT form remaps too. A guard is a guard whichever way it is written; carrying
@@ -135,7 +135,7 @@ The rules, in the order they bite:
135
135
  of bound fields). Same shape, different values = ONE state, data-driven: seven wizard
136
136
  questions sharing one shape are one `question` state whose data changes, never seven
137
137
  files (LAYERS §4, §7).
138
- 5. **Atoms** (`rx/atoms/`, via `Ref`) are for shapes shared across components, authoring-time
138
+ 5. **Atoms** (`rx/marketplace/atoms/`, via `Ref`) are for shapes shared across components, authoring-time
139
139
  only (the server expands them before serving; no Studio view). `Ref props` remaps FIELDS;
140
140
  **`Ref with` passes LITERALS**: `{ "type": "Ref", "ref": "button", "with": { "label":
141
141
  "Learn more", "icon": "arrowRight" }, "action": { … } }`. A bind whose field is a `with`
@@ -157,38 +157,83 @@ The rules, in the order they bite:
157
157
  for surgical refinement). **The schema IS the instruction channel.**
158
158
  Write briefs, never side-channel prompts/skills for filling a component. Shape is
159
159
  closed + linted. Pairs with the single-state permanent pattern (rule 2) for
160
- continuously-enriched pages; exemplar: `rx/components/perfectday`.
160
+ continuously-enriched pages; exemplar: `rx/yasisland/components/perfect-day`.
161
161
  7. **Tokens only** (LAW 1) + closed style keys + real space-scale steps
162
162
  (`docs/design/06`): an invented step is silently broken CSS.
163
- 8. **Lifecycle handlers: a component's own server-side code** (deep law:
164
- [UNOVERSE_AUTHORING](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_AUTHORING.md) §3c). When a component needs live data from an
165
- API or a computation (not from an AI = brief, not from the workflow = `input` props), it
166
- may bring a small server-side script that runs when the component renders. It's for DATA,
167
- not styling or logic-in-the-def.
168
- - **Opt in via the manifest** (authorizes execution; auditable): `"lifecycle":
169
- ["onStart"]` in the manifest. A handler file with no opt-in is INERT.
170
- - **The code is a sibling file named for the lifecycle**: `onStart` → `onstart.js`,
171
- beside the root file. Its **default export** is the handler.
172
- - **`onStart`** is the only lifecycle today; it runs SERVER-SIDE when the instance is
173
- created (the MCP render path), and whatever it returns is **merged into the instance
174
- by prop name** (unknown keys inert, same contract as workflow-fed props). Return only
175
- the fields you're filling; **never overwrite a curated prop** you didn't intend to.
176
- - **`ctx` is a bounded sandbox**: `ctx.props` (the instance's current data),
177
- `ctx.instanceId`, `ctx.env(name)` (server config). The **API key comes from
178
- `ctx.env`, NEVER hardcoded**: `rx/` is a shared design folder; a secret in it leaks.
179
- - Self-contained (the external call lives in the handler); errors never break the render.
180
- Exemplar: `rx/yasisland/components/restaurantcard/onstart.js` (Google Maps details).
181
-
182
- ```js
183
- // onstart.js: manifest opts in with "lifecycle": ["onStart"]
184
- export default async function onStart(ctx) {
185
- const key = ctx.env("SEARCHAPI_KEY"); // secret from server config
186
- const res = await fetch(`https://…&q=${encodeURIComponent(ctx.props.title)}&api_key=${key}`);
187
- const place = (await res.json()).local_results?.[0] ?? {};
188
- return { rating: place.rating, address: place.address }; // merged by prop name
189
- }
163
+ 8. **Lifecycle hooks: the component fetches its own data** (full doc:
164
+ `docs/design/lifecycle-hooks.md`; deep law:
165
+ [UNOVERSE_AUTHORING](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_AUTHORING.md) §3c). When a component needs data the
166
+ workflow did not send (not from an AI = brief, not from the workflow = `input` props),
167
+ it declares CALLS that the platform runs at a platform-owned fire point. **No code**:
168
+ a hook is data like everything else in `rx/`. It's for DATA, not styling or logic.
169
+ - **Two phases**: `onStart` (the instance is created: data every copy needs) and
170
+ `onEnterView` (the instance entered a named STATE: data only that state needs). A
171
+ detail view's content is `onEnterView`, NEVER `onStart` — hydrating at creation
172
+ fetches a full document for every card in a grid.
173
+ - **Opt in via the manifest** (authorizes execution; auditable). Nothing undeclared
174
+ runs; an un-opted handler file is a lint error, not an inert file.
175
+ - **The scope key is spelled `layouts:` but its entries are STATE names.**
176
+ `onEnterView` only. Omit it and every state fires.
177
+ - **Name the handler for what it DOES, never for the phase** (`fetchPlaceDetails`, not
178
+ `onEnterView`): the phase already said the moment.
179
+ - **Nothing to wire up.** A component changing its own view writes `view`, and that
180
+ write IS the event the hook reacts to. No signal to author, no action to chain.
181
+ - **Prefer a platform-ready handler.** `getDetail` (zero code) reads the instance's
182
+ `universal_id` and merges the stored record's fields in by name, so authored copy
183
+ never travels through a model. Use it for "fill this card with its own record".
184
+
185
+ ```yaml
186
+ # manifest.yaml — the shipped rx/sab/components/product-card
187
+ lifecycle:
188
+ - phase: onEnterView
189
+ layouts: [ detail ] # STATE names; the grid state never wakes it
190
+ handler: getDetail # platform-ready, so this component ships no code
191
+ ```
192
+
193
+ - **Beyond the built-ins, DECLARE the calls** in `<handler>.yaml` beside the manifest
194
+ (**filename = the handler, lowercased**: `fetchPlaceDetails` → `fetchplacedetails.yaml`;
195
+ macOS ignores the case difference and Linux does not). `calls:` is the same list an
196
+ `api/run.yaml` holds, run by the same runtime (host allowlist, credentials, retries,
197
+ timeouts, transports); `returns:` projects the results into partial props, merged by
198
+ name. Calls run in order and read each other through `calls.<name>`.
199
+ - **`allowedHosts` in the manifest is NOT optional**: a hook that makes requests
200
+ without it is a lint ERROR (deny by default). Credentials are named by type in the
201
+ manifest and resolved server-side from encrypted storage; **a key never sits in
202
+ `rx/`** (a shared design folder) and never reaches the client.
203
+ - A phase fires once per instance; enrichment streams in (no blocked render); errors
204
+ fail quietly, leaving whatever the card already had.
205
+
206
+ ```yaml
207
+ # manifest.yaml
208
+ lifecycle:
209
+ - phase: onEnterView
210
+ layouts: [ focus ]
211
+ handler: fetchPlaceDetails
212
+ allowedHosts: # deny by default — lint error if calls exist without it
213
+ - www.searchapi.io
214
+ credentials:
215
+ - searchapiCredential # resolved server-side, never in this folder
190
216
  ```
191
217
 
218
+ ```yaml
219
+ # fetchplacedetails.yaml — the handler, lowercased
220
+ calls:
221
+ - name: place
222
+ method: GET
223
+ url: https://www.searchapi.io/api/v1/search
224
+ query: { engine: google_maps, q: "{{ props.title }}" }
225
+ credential: { scheme: apiKeyQuery, param: api_key, value: "{{ credentials.searchapiCredential.apiKey }}" }
226
+ transport: json
227
+ returns: >-
228
+ return ((p) => p ? { rating: p.rating, address: p.address } : {})((calls.place.local_results || [])[0])
229
+ ```
230
+
231
+ ⚠ A raw `<phase>.js` file beside the manifest (e.g. `onstart.js` with `ctx.env`) is the
232
+ LEGACY form. It still runs for handlers that predate declared calls, but it is **never
233
+ the one to copy**: a script fetches any host with a key from the environment, and the
234
+ calls runtime settles both questions.
235
+ Exemplar: `rx/yasisland/components/restaurant-card/` (maps lookup, declared calls).
236
+
192
237
  ## Discovery meta: write it to RANK (docs/nodes/node-discoverability.md)
193
238
 
194
239
  A component with a manifest is an **MCP app**: spatial embeds `` `title. whenToUse||description