@objectstack/cli 5.1.0 → 5.2.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.
@@ -0,0 +1,336 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Studio field-patch — ts-morph powered surgery on a single field
4
+ * inside an `ObjectSchema.create({...})` or `defineObject({...})` call.
5
+ *
6
+ * Supported field shapes:
7
+ * account_number: Field.autonumber({ ...props }) // CallExpression
8
+ * owner: Field.lookup('user', { ...props }) // CallExpression with second-arg object
9
+ * custom: { ...props } // bare ObjectLiteral
10
+ *
11
+ * Properties we know how to update: `label`, `description`, `required`.
12
+ * Falsy values (`null`, `''`, `false`) are interpreted as "remove this
13
+ * property" so the source stays minimal.
14
+ */
15
+ import { Project, SyntaxKind, } from 'ts-morph';
16
+ /**
17
+ * Locate the field's inner object-literal and apply the patch.
18
+ * Writes the file in place when successful.
19
+ */
20
+ export async function patchObjectFieldFile(absPath, fieldKey, patch) {
21
+ return withFieldsObj(absPath, async (fieldsObj) => {
22
+ const fieldProp = fieldsObj.getProperty(fieldKey);
23
+ if (!fieldProp || !fieldProp.isKind(SyntaxKind.PropertyAssignment)) {
24
+ return { ok: false, error: `field \`${fieldKey}\` not found in fields` };
25
+ }
26
+ const innerObj = resolveInnerObjectLiteral(fieldProp.getInitializer());
27
+ if (!innerObj) {
28
+ return { ok: false, error: `field \`${fieldKey}\` initializer has no editable object literal` };
29
+ }
30
+ if ('label' in patch)
31
+ applyStringProp(innerObj, 'label', patch.label);
32
+ if ('description' in patch)
33
+ applyStringProp(innerObj, 'description', patch.description);
34
+ if ('required' in patch)
35
+ applyBooleanProp(innerObj, 'required', patch.required);
36
+ return { ok: true };
37
+ });
38
+ }
39
+ /**
40
+ * Append a new field to the `fields: { ... }` object literal. The
41
+ * `initializer` is the raw TS source that goes on the right-hand side
42
+ * of `<fieldName>: …` — typically an object literal but may be any
43
+ * expression (e.g. `Field.text({ ... })`).
44
+ *
45
+ * Refuses to overwrite an existing field — callers must surface a
46
+ * conflict to the user.
47
+ */
48
+ export async function addObjectField(absPath, fieldName, initializer) {
49
+ if (!/^[a-z_][a-z0-9_]*$/.test(fieldName)) {
50
+ return { ok: false, error: `invalid field name \`${fieldName}\` (must be snake_case)` };
51
+ }
52
+ return withFieldsObj(absPath, async (fieldsObj) => {
53
+ if (fieldsObj.getProperty(fieldName)) {
54
+ return { ok: false, error: `field \`${fieldName}\` already exists` };
55
+ }
56
+ try {
57
+ // Append by raw-text insertion so we match the file's existing
58
+ // indentation / line-break conventions instead of forcing ts-morph
59
+ // defaults (which produced 6-space indent and a flush-left closing
60
+ // brace inside a 4-space-indented body).
61
+ const openBrace = fieldsObj.getFirstChildByKind(SyntaxKind.OpenBraceToken);
62
+ const closeBrace = fieldsObj.getLastChildByKind(SyntaxKind.CloseBraceToken);
63
+ if (!openBrace || !closeBrace) {
64
+ return { ok: false, error: 'malformed object literal (missing braces)' };
65
+ }
66
+ const sf = fieldsObj.getSourceFile();
67
+ const fullText = sf.getFullText();
68
+ const bodyStart = openBrace.getEnd();
69
+ const bodyEnd = closeBrace.getStart();
70
+ // Detect existing indentation by sniffing the original source:
71
+ // propIndent — leading whitespace before the first existing prop
72
+ // closeIndent — leading whitespace before the existing close brace
73
+ // Both are read verbatim so the new prop and the close brace land at
74
+ // the exact columns the file already uses (handles 2-space, 4-space,
75
+ // tabs, or anything else without guessing the indent step).
76
+ const sniffIndent = (pos) => {
77
+ let i = pos - 1;
78
+ while (i >= 0 && (fullText[i] === ' ' || fullText[i] === '\t'))
79
+ i--;
80
+ return fullText.slice(i + 1, pos);
81
+ };
82
+ const firstProp = fieldsObj.getProperties()[0];
83
+ const closeIndent = sniffIndent(closeBrace.getStart());
84
+ let propIndent;
85
+ if (firstProp && firstProp.isKind(SyntaxKind.PropertyAssignment)) {
86
+ propIndent = sniffIndent(firstProp.getStart());
87
+ }
88
+ else {
89
+ // Empty `{}` — give the new prop one step beyond the close brace.
90
+ // Default to two spaces; if the close-brace indent looks tab-based,
91
+ // use a tab.
92
+ propIndent = closeIndent + (closeIndent.includes('\t') ? '\t' : ' ');
93
+ }
94
+ // Body region between braces; rebuild with the new prop appended,
95
+ // ensuring trailing comma + newline + indent before the new prop and
96
+ // a newline + closing indent before the close brace.
97
+ const body = fullText.slice(bodyStart, bodyEnd);
98
+ let trimmed = body.replace(/\s+$/, '');
99
+ if (trimmed && !trimmed.endsWith(','))
100
+ trimmed += ',';
101
+ const isEmpty = trimmed.length === 0;
102
+ const newBody = isEmpty
103
+ ? `\n${propIndent}${fieldName}: ${initializer},\n${closeIndent}`
104
+ : `${trimmed}\n${propIndent}${fieldName}: ${initializer},\n${closeIndent}`;
105
+ // Use raw SourceFile.replaceText to avoid ts-morph auto-reindenting
106
+ // every line inside the literal (replaceWithText on an ObjectLiteral
107
+ // re-indents by manipulation settings, shifting unchanged lines).
108
+ sf.replaceText([bodyStart, bodyEnd], newBody);
109
+ return { ok: true };
110
+ }
111
+ catch (err) {
112
+ return { ok: false, error: `add failed: ${err?.message ?? String(err)}` };
113
+ }
114
+ });
115
+ }
116
+ /**
117
+ * Re-order the properties inside the `fields: { ... }` object literal
118
+ * to match `order[]`. Field names absent from `order` are appended at
119
+ * the end in their existing relative order — keeps the operation safe
120
+ * if the UI has a stale snapshot.
121
+ *
122
+ * Implementation: slice each property's source range out of the file
123
+ * (including any leading whitespace / blank-line / comment trivia and
124
+ * the trailing `,`), then concatenate the slices in the new order. By
125
+ * working on the raw source, indentation, trailing commas, blank-line
126
+ * separators and inline comments are all preserved verbatim. The only
127
+ * cost: a hanging blank line that previously sat *between* two props
128
+ * follows whichever property it precedes after the reorder, which is
129
+ * the natural and least-surprising behaviour.
130
+ */
131
+ export async function reorderObjectFields(absPath, order) {
132
+ if (!Array.isArray(order)) {
133
+ return { ok: false, error: 'order must be an array of field names' };
134
+ }
135
+ return withFieldsObj(absPath, async (fieldsObj) => {
136
+ const props = fieldsObj.getProperties();
137
+ const openBrace = fieldsObj.getFirstChildByKind(SyntaxKind.OpenBraceToken);
138
+ const closeBrace = fieldsObj.getLastChildByKind(SyntaxKind.CloseBraceToken);
139
+ if (!openBrace || !closeBrace) {
140
+ return { ok: false, error: 'malformed object literal (missing braces)' };
141
+ }
142
+ const sf = fieldsObj.getSourceFile();
143
+ const fullText = sf.getFullText();
144
+ const bodyStart = openBrace.getEnd();
145
+ const bodyEnd = closeBrace.getStart();
146
+ // For each property: capture the slice from "just after previous prop's
147
+ // trailing comma" (or bodyStart for the first prop) up to and including
148
+ // this prop's own trailing comma (skipping inline whitespace up to it).
149
+ // Leading whitespace / blank lines / comments that precede a prop stay
150
+ // with that prop, so they travel together when reordered.
151
+ const slices = new Map();
152
+ let cursor = bodyStart;
153
+ for (let i = 0; i < props.length; i++) {
154
+ const p = props[i];
155
+ if (!p.isKind(SyntaxKind.PropertyAssignment) && !p.isKind(SyntaxKind.ShorthandPropertyAssignment)) {
156
+ continue;
157
+ }
158
+ const name = p.getName();
159
+ // Find end-of-slice: skip whitespace after the prop, swallow one comma if present.
160
+ let end = p.getEnd();
161
+ while (end < bodyEnd && /[ \t]/.test(fullText[end]))
162
+ end++;
163
+ if (fullText[end] === ',')
164
+ end++;
165
+ slices.set(name, fullText.slice(cursor, end));
166
+ cursor = end;
167
+ }
168
+ // Anything between the last prop's comma and the close brace (typically
169
+ // a trailing newline + indentation) becomes the new "tail".
170
+ const tail = fullText.slice(cursor, bodyEnd);
171
+ if (slices.size === 0) {
172
+ return { ok: false, error: '`fields` is empty — nothing to reorder' };
173
+ }
174
+ const seen = new Set();
175
+ const ordered = [];
176
+ for (const name of order) {
177
+ const slice = slices.get(name);
178
+ if (slice !== undefined) {
179
+ ordered.push(slice);
180
+ seen.add(name);
181
+ }
182
+ }
183
+ // Append anything the UI didn't know about so we never drop fields.
184
+ for (const [name, slice] of slices) {
185
+ if (!seen.has(name))
186
+ ordered.push(slice);
187
+ }
188
+ const newBody = ordered.join('') + tail;
189
+ try {
190
+ // sf.replaceText avoids re-indenting unchanged lines, which is what
191
+ // makes fieldsObj.replaceWithText destroy formatting.
192
+ sf.replaceText([bodyStart, bodyEnd], newBody);
193
+ return { ok: true };
194
+ }
195
+ catch (err) {
196
+ return { ok: false, error: `reorder failed: ${err?.message ?? String(err)}` };
197
+ }
198
+ });
199
+ }
200
+ /**
201
+ * Shared entry: open the file, drill into the `fields` literal, run
202
+ * `mutate(...)`, persist on success. Centralizes the schema-call /
203
+ * fields-literal lookup that every operation needs.
204
+ */
205
+ async function withFieldsObj(absPath, mutate) {
206
+ const project = new Project({ useInMemoryFileSystem: false, skipAddingFilesFromTsConfig: true });
207
+ let sf;
208
+ try {
209
+ sf = project.addSourceFileAtPath(absPath);
210
+ }
211
+ catch (err) {
212
+ return { ok: false, error: `parse failed: ${err?.message ?? String(err)}` };
213
+ }
214
+ const schemaCall = findSchemaCall(sf);
215
+ if (!schemaCall) {
216
+ return { ok: false, error: 'no ObjectSchema.create / defineObject call found in file' };
217
+ }
218
+ const schemaArg = schemaCall.getArguments()[0];
219
+ if (!schemaArg || !schemaArg.isKind(SyntaxKind.ObjectLiteralExpression)) {
220
+ return { ok: false, error: 'schema call argument is not an object literal' };
221
+ }
222
+ const fieldsProp = schemaArg.getProperty('fields');
223
+ if (!fieldsProp || !fieldsProp.isKind(SyntaxKind.PropertyAssignment)) {
224
+ return { ok: false, error: 'schema object has no `fields` property' };
225
+ }
226
+ const fieldsInit = fieldsProp.getInitializer();
227
+ if (!fieldsInit || !fieldsInit.isKind(SyntaxKind.ObjectLiteralExpression)) {
228
+ return { ok: false, error: '`fields` initializer is not an object literal' };
229
+ }
230
+ const result = await mutate(fieldsInit);
231
+ if (!result.ok)
232
+ return result;
233
+ try {
234
+ await sf.save();
235
+ return { ok: true };
236
+ }
237
+ catch (err) {
238
+ return { ok: false, error: `write failed: ${err?.message ?? String(err)}` };
239
+ }
240
+ }
241
+ // ─── helpers ────────────────────────────────────────────────────────
242
+ function findSchemaCall(sf) {
243
+ const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression);
244
+ for (const call of calls) {
245
+ const expr = call.getExpression().getText();
246
+ if (expr === 'ObjectSchema.create' || expr === 'defineObject') {
247
+ return call;
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+ /**
253
+ * Given a property initializer, return the inner ObjectLiteralExpression
254
+ * we should patch.
255
+ * - `{ ... }` → that literal
256
+ * - `Field.X({ ... })` → first arg if object literal
257
+ * - `Field.X('rel', { ... })` → second arg
258
+ * - `Field.X({ ... }, { ... })` → first arg (defensive)
259
+ */
260
+ function resolveInnerObjectLiteral(init) {
261
+ if (!init)
262
+ return null;
263
+ if (init.isKind(SyntaxKind.ObjectLiteralExpression)) {
264
+ return init;
265
+ }
266
+ if (init.isKind(SyntaxKind.CallExpression)) {
267
+ const args = init.getArguments();
268
+ for (const arg of args) {
269
+ if (arg.isKind(SyntaxKind.ObjectLiteralExpression)) {
270
+ return arg;
271
+ }
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+ function applyStringProp(obj, key, value) {
277
+ const existing = obj.getProperty(key);
278
+ if (value == null || value === '') {
279
+ if (existing)
280
+ existing.remove();
281
+ return;
282
+ }
283
+ const literal = renderStringLiteral(obj, value);
284
+ if (existing && existing.isKind(SyntaxKind.PropertyAssignment)) {
285
+ existing.setInitializer(literal);
286
+ }
287
+ else {
288
+ obj.addPropertyAssignment({ name: key, initializer: literal });
289
+ }
290
+ }
291
+ /**
292
+ * Render `value` as a TS string literal, preferring whichever quote
293
+ * style (single vs double) the surrounding object already uses for
294
+ * existing string props. Falls back to single quotes — the dominant
295
+ * convention across the codebase — when there's no signal.
296
+ */
297
+ function renderStringLiteral(obj, value) {
298
+ let single = 0;
299
+ let double = 0;
300
+ for (const p of obj.getProperties()) {
301
+ if (!p.isKind(SyntaxKind.PropertyAssignment))
302
+ continue;
303
+ const init = p.getInitializer();
304
+ if (!init || !init.isKind(SyntaxKind.StringLiteral))
305
+ continue;
306
+ const raw = init.getText();
307
+ if (raw.startsWith("'"))
308
+ single++;
309
+ else if (raw.startsWith('"'))
310
+ double++;
311
+ }
312
+ const useDouble = double > single;
313
+ if (useDouble || value.includes("'")) {
314
+ // JSON.stringify gives us proper escaping for `"` and control chars.
315
+ return JSON.stringify(value);
316
+ }
317
+ // Single-quoted: escape only single quotes and backslashes.
318
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
319
+ }
320
+ function applyBooleanProp(obj, key, value) {
321
+ const existing = obj.getProperty(key);
322
+ // Default for `required` is false, so we omit the property when false
323
+ // to keep the source minimal — matches what authors hand-write.
324
+ if (value !== true) {
325
+ if (existing)
326
+ existing.remove();
327
+ return;
328
+ }
329
+ if (existing && existing.isKind(SyntaxKind.PropertyAssignment)) {
330
+ existing.setInitializer('true');
331
+ }
332
+ else {
333
+ obj.addPropertyAssignment({ name: key, initializer: 'true' });
334
+ }
335
+ }
336
+ //# sourceMappingURL=studio-field-patch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"studio-field-patch.js","sourceRoot":"","sources":["../../src/utils/studio-field-patch.ts"],"names":[],"mappings":"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"}
@@ -56,4 +56,32 @@ export declare function createStudioStaticPlugin(distPath: string, options?: {
56
56
  init: () => Promise<void>;
57
57
  start: (ctx: any) => Promise<void>;
58
58
  };
59
+ /**
60
+ * Dev-only plugin that exposes a tiny write API at
61
+ * `/_studio/api/metadata/*` so Studio's "Create" dialogs can scaffold
62
+ * real `.ts` files instead of asking the user to paste a snippet.
63
+ *
64
+ * Security posture: enabled ONLY when `isDev === true`. All file paths
65
+ * must live under `<cwd>`, contain a `/src/` segment, and carry an
66
+ * approved extension. Path traversal (`..`) and absolute paths are
67
+ * rejected outright. Existing files are NEVER overwritten unless the
68
+ * caller passes `mode: 'overwrite'`.
69
+ *
70
+ * Endpoints:
71
+ * GET /_studio/api/metadata/layout?package=<id>
72
+ * 200: { srcRoot: string } relative to cwd, e.g. "src" or "packages/<id>/src"
73
+ *
74
+ * POST /_studio/api/metadata/file
75
+ * body: { path: string, content: string, mode?: 'create' | 'overwrite' }
76
+ * 200: { ok: true, path: string }
77
+ * 409: { ok: false, error: 'exists' }
78
+ * 400: { ok: false, error: ... }
79
+ */
80
+ export declare function createStudioWriteApiPlugin(cwd: string, options?: {
81
+ isDev: boolean;
82
+ }): {
83
+ name: string;
84
+ init: () => Promise<void>;
85
+ start: (ctx: any) => Promise<void>;
86
+ };
59
87
  //# sourceMappingURL=studio.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"studio.d.ts","sourceRoot":"","sources":["../../src/utils/studio.ts"],"names":[],"mappings":"AAaA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAKzD,sEAAsE;AACtE,eAAO,MAAM,WAAW,aAAa,CAAC;AAOtC;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CA+CjD;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEzD;AAID;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,MAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAYlF;AAID,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,2BAA2B;IAC3B,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACpC,OAAO,CAAC,aAAa,CAAC,CAwExB;AAID;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM;;;iBAMjC,GAAG;EAwCzB;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE;;;iBAMzF,GAAG;EAsEzB"}
1
+ {"version":3,"file":"studio.d.ts","sourceRoot":"","sources":["../../src/utils/studio.ts"],"names":[],"mappings":"AAaA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAKzD,sEAAsE;AACtE,eAAO,MAAM,WAAW,aAAa,CAAC;AAOtC;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CA+CjD;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEzD;AAID;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,MAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAYlF;AAID,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,2BAA2B;IAC3B,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACpC,OAAO,CAAC,aAAa,CAAC,CAwExB;AAID;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM;;;iBAMjC,GAAG;EAwCzB;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE;;;iBAMzF,GAAG;EAsEzB;AAID;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE;IAAE,KAAK,EAAE,OAAO,CAAA;CAAqB;;;iBAM/E,GAAG;EAmMzB"}
@@ -278,6 +278,231 @@ export function createStudioStaticPlugin(distPath, options) {
278
278
  },
279
279
  };
280
280
  }
281
+ // ─── Dev-only Write API ─────────────────────────────────────────────
282
+ /**
283
+ * Dev-only plugin that exposes a tiny write API at
284
+ * `/_studio/api/metadata/*` so Studio's "Create" dialogs can scaffold
285
+ * real `.ts` files instead of asking the user to paste a snippet.
286
+ *
287
+ * Security posture: enabled ONLY when `isDev === true`. All file paths
288
+ * must live under `<cwd>`, contain a `/src/` segment, and carry an
289
+ * approved extension. Path traversal (`..`) and absolute paths are
290
+ * rejected outright. Existing files are NEVER overwritten unless the
291
+ * caller passes `mode: 'overwrite'`.
292
+ *
293
+ * Endpoints:
294
+ * GET /_studio/api/metadata/layout?package=<id>
295
+ * 200: { srcRoot: string } relative to cwd, e.g. "src" or "packages/<id>/src"
296
+ *
297
+ * POST /_studio/api/metadata/file
298
+ * body: { path: string, content: string, mode?: 'create' | 'overwrite' }
299
+ * 200: { ok: true, path: string }
300
+ * 409: { ok: false, error: 'exists' }
301
+ * 400: { ok: false, error: ... }
302
+ */
303
+ export function createStudioWriteApiPlugin(cwd, options = { isDev: false }) {
304
+ return {
305
+ name: 'com.objectstack.studio-write-api',
306
+ init: async () => { },
307
+ start: async (ctx) => {
308
+ if (!options.isDev)
309
+ return;
310
+ const httpServer = ctx.getService?.('http.server');
311
+ if (!httpServer?.getRawApp) {
312
+ ctx.logger?.warn?.('Studio write API: http.server not found — skipping');
313
+ return;
314
+ }
315
+ const app = httpServer.getRawApp();
316
+ const projectRoot = path.resolve(cwd);
317
+ const ALLOWED_EXT = new Set(['.ts', '.tsx', '.json']);
318
+ const respond = (_c, status, body) => new Response(JSON.stringify(body), {
319
+ status,
320
+ headers: { 'content-type': 'application/json; charset=utf-8' },
321
+ });
322
+ // Resolve the most likely source-code root for a given package id.
323
+ const resolveSrcRoot = (pkgId) => {
324
+ const candidates = [
325
+ pkgId ? path.join('packages', pkgId, 'src') : null,
326
+ pkgId ? path.join('examples', pkgId, 'src') : null,
327
+ 'src', // single-app project layout
328
+ ].filter(Boolean);
329
+ for (const c of candidates) {
330
+ if (fs.existsSync(path.join(projectRoot, c)))
331
+ return c;
332
+ }
333
+ return null;
334
+ };
335
+ app.get(`${STUDIO_PATH}/api/metadata/layout`, (c) => {
336
+ const pkgId = c.req.query?.('package') ?? null;
337
+ const srcRoot = resolveSrcRoot(pkgId);
338
+ return respond(c, 200, { srcRoot });
339
+ });
340
+ app.post(`${STUDIO_PATH}/api/metadata/file`, async (c) => {
341
+ let body;
342
+ try {
343
+ body = await c.req.json();
344
+ }
345
+ catch {
346
+ return respond(c, 400, { ok: false, error: 'invalid json body' });
347
+ }
348
+ const rel = typeof body?.path === 'string' ? body.path : '';
349
+ const content = typeof body?.content === 'string' ? body.content : '';
350
+ const mode = body?.mode === 'overwrite' ? 'overwrite' : 'create';
351
+ if (!rel)
352
+ return respond(c, 400, { ok: false, error: 'path is required' });
353
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
354
+ return respond(c, 400, { ok: false, error: 'path must be a project-relative path without `..`' });
355
+ }
356
+ const ext = path.extname(rel).toLowerCase();
357
+ if (!ALLOWED_EXT.has(ext)) {
358
+ return respond(c, 400, { ok: false, error: `unsupported extension ${ext}` });
359
+ }
360
+ const abs = path.resolve(projectRoot, rel);
361
+ if (!abs.startsWith(projectRoot + path.sep)) {
362
+ return respond(c, 400, { ok: false, error: 'path escapes project root' });
363
+ }
364
+ // Must contain a `/src/` segment — keeps writes scoped to source
365
+ // code, not random config files at the repo root.
366
+ const segments = path.relative(projectRoot, abs).split(path.sep);
367
+ if (!segments.includes('src')) {
368
+ return respond(c, 400, { ok: false, error: 'path must live under a src/ directory' });
369
+ }
370
+ if (fs.existsSync(abs) && mode === 'create') {
371
+ return respond(c, 409, { ok: false, error: 'exists' });
372
+ }
373
+ try {
374
+ await fs.promises.mkdir(path.dirname(abs), { recursive: true });
375
+ await fs.promises.writeFile(abs, content, 'utf-8');
376
+ ctx.logger?.info?.(`Studio write API: ${mode} ${rel}`);
377
+ return respond(c, 200, { ok: true, path: rel });
378
+ }
379
+ catch (err) {
380
+ ctx.logger?.error?.(`Studio write API failed: ${err?.message}`);
381
+ return respond(c, 500, { ok: false, error: err?.message ?? String(err) });
382
+ }
383
+ });
384
+ ctx.logger?.info?.(`Studio write API mounted at ${STUDIO_PATH}/api/metadata/* (dev mode)`);
385
+ /**
386
+ * Shared validation for endpoints that mutate an existing `.ts`
387
+ * source file by relative path. Returns `{ abs }` on success or
388
+ * a respond() Response when validation fails.
389
+ */
390
+ const validateTsPath = (c, rel) => {
391
+ if (!rel)
392
+ return respond(c, 400, { ok: false, error: 'path is required' });
393
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
394
+ return respond(c, 400, { ok: false, error: 'path must be a project-relative path without `..`' });
395
+ }
396
+ if (path.extname(rel).toLowerCase() !== '.ts') {
397
+ return respond(c, 400, { ok: false, error: 'only .ts files are supported' });
398
+ }
399
+ const abs = path.resolve(projectRoot, rel);
400
+ if (!abs.startsWith(projectRoot + path.sep)) {
401
+ return respond(c, 400, { ok: false, error: 'path escapes project root' });
402
+ }
403
+ if (!path.relative(projectRoot, abs).split(path.sep).includes('src')) {
404
+ return respond(c, 400, { ok: false, error: 'path must live under a src/ directory' });
405
+ }
406
+ if (!fs.existsSync(abs)) {
407
+ return respond(c, 404, { ok: false, error: 'file not found' });
408
+ }
409
+ return { abs };
410
+ };
411
+ app.post(`${STUDIO_PATH}/api/metadata/field-patch`, async (c) => {
412
+ let body;
413
+ try {
414
+ body = await c.req.json();
415
+ }
416
+ catch {
417
+ return respond(c, 400, { ok: false, error: 'invalid json body' });
418
+ }
419
+ const rel = typeof body?.path === 'string' ? body.path : '';
420
+ const fieldKey = typeof body?.field === 'string' ? body.field : '';
421
+ const patch = body?.patch && typeof body.patch === 'object' ? body.patch : null;
422
+ if (!fieldKey || !patch) {
423
+ return respond(c, 400, { ok: false, error: 'field and patch are required' });
424
+ }
425
+ const v = validateTsPath(c, rel);
426
+ if (!v.abs)
427
+ return v;
428
+ try {
429
+ const { patchObjectFieldFile } = await import('./studio-field-patch.js');
430
+ const result = await patchObjectFieldFile(v.abs, fieldKey, patch);
431
+ if (!result.ok)
432
+ return respond(c, 400, result);
433
+ ctx.logger?.info?.(`Studio field-patch: ${rel} field=${fieldKey} keys=${Object.keys(patch).join(',')}`);
434
+ return respond(c, 200, { ok: true, path: rel, field: fieldKey });
435
+ }
436
+ catch (err) {
437
+ ctx.logger?.error?.(`Studio field-patch failed: ${err?.message}`);
438
+ return respond(c, 500, { ok: false, error: err?.message ?? String(err) });
439
+ }
440
+ });
441
+ app.post(`${STUDIO_PATH}/api/metadata/field-add`, async (c) => {
442
+ let body;
443
+ try {
444
+ body = await c.req.json();
445
+ }
446
+ catch {
447
+ return respond(c, 400, { ok: false, error: 'invalid json body' });
448
+ }
449
+ const rel = typeof body?.path === 'string' ? body.path : '';
450
+ const fieldName = typeof body?.fieldName === 'string' ? body.fieldName : '';
451
+ const initializer = typeof body?.initializer === 'string' ? body.initializer : '';
452
+ if (!fieldName || !initializer) {
453
+ return respond(c, 400, { ok: false, error: 'fieldName and initializer are required' });
454
+ }
455
+ const v = validateTsPath(c, rel);
456
+ if (!v.abs)
457
+ return v;
458
+ try {
459
+ const { addObjectField } = await import('./studio-field-patch.js');
460
+ const result = await addObjectField(v.abs, fieldName, initializer);
461
+ if (!result.ok) {
462
+ // Existing field is a 409 conflict; other errors stay 400.
463
+ const status = result.error.includes('already exists') ? 409 : 400;
464
+ return respond(c, status, result);
465
+ }
466
+ ctx.logger?.info?.(`Studio field-add: ${rel} field=${fieldName}`);
467
+ return respond(c, 200, { ok: true, path: rel, field: fieldName });
468
+ }
469
+ catch (err) {
470
+ ctx.logger?.error?.(`Studio field-add failed: ${err?.message}`);
471
+ return respond(c, 500, { ok: false, error: err?.message ?? String(err) });
472
+ }
473
+ });
474
+ app.post(`${STUDIO_PATH}/api/metadata/field-reorder`, async (c) => {
475
+ let body;
476
+ try {
477
+ body = await c.req.json();
478
+ }
479
+ catch {
480
+ return respond(c, 400, { ok: false, error: 'invalid json body' });
481
+ }
482
+ const rel = typeof body?.path === 'string' ? body.path : '';
483
+ const order = Array.isArray(body?.order) ? body.order.filter((s) => typeof s === 'string') : null;
484
+ if (!order || order.length === 0) {
485
+ return respond(c, 400, { ok: false, error: 'order is required (non-empty string[])' });
486
+ }
487
+ const v = validateTsPath(c, rel);
488
+ if (!v.abs)
489
+ return v;
490
+ try {
491
+ const { reorderObjectFields } = await import('./studio-field-patch.js');
492
+ const result = await reorderObjectFields(v.abs, order);
493
+ if (!result.ok)
494
+ return respond(c, 400, result);
495
+ ctx.logger?.info?.(`Studio field-reorder: ${rel} (${order.length} fields)`);
496
+ return respond(c, 200, { ok: true, path: rel, count: order.length });
497
+ }
498
+ catch (err) {
499
+ ctx.logger?.error?.(`Studio field-reorder failed: ${err?.message}`);
500
+ return respond(c, 500, { ok: false, error: err?.message ?? String(err) });
501
+ }
502
+ });
503
+ },
504
+ };
505
+ }
281
506
  // ─── Helpers ────────────────────────────────────────────────────────
282
507
  const MIME_TYPES = {
283
508
  '.html': 'text/html; charset=utf-8',