-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathgenerate.ts
More file actions
2268 lines (2109 loc) · 105 KB
/
Copy pathgenerate.ts
File metadata and controls
2268 lines (2109 loc) · 105 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { Args, Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
// Type-only (erased at runtime): the three field-type vocabularies below are
// `satisfies Record<FieldType, …>`, which is what makes a field type added to
// the spec a named compile error here instead of a silent fallback (#14657).
import type { FieldType } from '@objectstack/spec/data';
// #16091 — IMPORTED, not transcribed. Both are on `@objectstack/spec/data`'s
// exported surface, and spec is not a driver package: the #5726 constraint the
// transcriptions below cite forbids a static value import of an
// `@objectstack/driver-*` package and nothing else, so it never reached these.
// The two driver-side readers that consume them — `isUniqueScopeDeclared` and
// `computeTenantField` — are spelled here in the driver's own terms ON TOP of
// these, so the part that can be shared is shared and only the part that
// genuinely lives on `driver-sql` is mirrored.
import { isTenancyDisabled, isUniqueDeclared, numericColumnFor } from '@objectstack/spec/data';
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js';
import { metadataFileName } from '../utils/metadata-file-name.js';
import { findEmissionParseFailures } from '../utils/emitted-source-parses.js';
// ─── Metadata Type Templates ────────────────────────────────────────
/**
* The scaffold templates, keyed by metadata type.
*
* A generator declares WHAT to write. It does not declare what the file is
* called: every filename comes from {@link metadataFileName}, which reads the
* type's own `filePatterns` out of `DEFAULT_METADATA_TYPE_REGISTRY`. The
* harness wrote `NAME.ts` for years, which matches no pattern the registry
* declares for any type; #11025 closed that for `skill` alone through a
* per-generator override, and #11071 replaced the override with the derived
* default so a type added here cannot arrive misnamed by omission.
*
* A type registered here that the registry gives no TypeScript pattern is
* refused at generation time rather than written to a name nothing globs, and
* `generate-file-name-registry-parity.test.ts` turns that runtime refusal into
* a CI failure so nobody meets it as a user.
*/
const GENERATORS: Record<string, {
description: string;
defaultDir: string;
generate: (name: string) => string;
}> = {
object: {
description: 'Business data object',
defaultDir: 'src/objects',
/**
* Carries an AUTHORED `sharingModel` (#14336).
*
* Unlike the other three repairs on that card this one is not shape drift:
* the object parsed fine and was refused one layer later, by
* `security-owd-unset` — an author-time ERROR rule saying the org-wide
* default must be a decision rather than an accident. So the scaffold
* handed the author a file their own `os validate` rejected.
*
* The value is NOT a fresh decision taken here. #9666 took it once for the
* `os init` templates, and this emits the SAME value with the same
* explanation, so the two doors an author can arrive through agree. If
* that template's value ever moves, this one moves with it.
*/
generate: (name: string) => `import * as Data from '@objectstack/spec/data';
/**
* ${toTitleCase(name)} Object
*/
const ${toCamelCase(name)}: Data.ServiceObject = {
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
pluralLabel: '${toTitleCase(name)}s',
fields: {
name: {
type: 'text',
label: 'Name',
required: true,
maxLength: 255,
},
description: {
type: 'textarea',
label: 'Description',
},
},
// Org-wide default (OWD): who can see records they don't own. 'private' is
// owner-only until access is widened by a permission grant or a sharing
// rule. Declaring it is required, deliberately: \`objectstack build\`
// refuses an object that declares no OWD, so the baseline is always an
// authored decision rather than an accident. The other values, and how to
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
sharingModel: 'private',
};
export default ${toCamelCase(name)};
`,
},
view: {
description: 'List or form view',
defaultDir: 'src/views',
/**
* A view CONTAINER — which is what a `view` artifact is (#14336).
*
* `ViewSchema` is `.strict()` and its view slots are `list` / `form` /
* `listViews` / `formViews`; `type` and `objectName` belong to a single
* VIEW, not to the container holding it. The template used to write both
* spellings at once: a flat list view's keys on the container AND a `list`
* block. `defineView` has guarded the flat shape since the container was
* introduced, and for a reason worth restating — a flat view parses to an
* EMPTY container, so zero views register and the Console renders nothing.
*
* `pageSize` moved too: it is `PaginationConfigSchema`'s key, reached
* through the list view's `pagination`, not a key on the list view itself.
*
* The object binding is `object` — the key `getViewsByObject()` reads and
* the one a stack-level `views: [...]` entry needs to say which object its
* views belong to. `objectName` is the spelling on the QUERY surface.
*/
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';
/**
* ${toTitleCase(name)} Views
*/
const ${toCamelCase(name)}Views: UI.View = {
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
object: '${toSnakeCase(name)}',
list: {
type: 'grid',
columns: [
{ field: 'name', width: 200 },
],
sort: [{ field: 'name', order: 'asc' }],
pagination: { pageSize: 25 },
},
};
export default ${toCamelCase(name)}Views;
`,
},
action: {
description: 'Button or batch action',
defaultDir: 'src/actions',
/**
* `type` comes from `ActionType` — `script | url | modal | flow | api |
* form` — and the handler binding is the single `target` slot (#14336).
*
* The template used to write `type: 'custom'`, which is not a member, plus
* a `handler: { type, target }` block, which is not an Action key: the
* `execute`/`handler` second slot was removed in protocol 17 precisely so
* no consumer has two places to disagree about. What that block was trying
* to express is exactly `type: 'flow'` with `target` naming the flow, so
* that is what it now says — and it targets the name `os g flow NAME`
* writes, so the two scaffolds compose.
*
* `target` is REQUIRED for every type but `script`, enforced by
* `ActionSchema`'s own refinement, so this cannot drift back to an action
* bound to nothing.
*/
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';
/**
* ${toTitleCase(name)} Action
*/
const ${toCamelCase(name)}Action: UI.Action = {
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
type: 'flow',
objectName: '${toSnakeCase(name)}',
target: '${toSnakeCase(name)}_flow',
};
export default ${toCamelCase(name)}Action;
`,
},
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';
/**
* ${toTitleCase(name)} Flow
*/
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'record_change',
status: 'draft',
nodes: [
{
id: 'start',
type: 'start',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};
export default ${toCamelCase(name)}Flow;
`,
},
dashboard: {
description: 'Analytics dashboard',
defaultDir: 'src/dashboards',
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';
/**
* ${toTitleCase(name)} Dashboard
*/
const ${toCamelCase(name)}Dashboard: UI.Dashboard = {
name: '${toSnakeCase(name)}_dashboard',
label: '${toTitleCase(name)} Dashboard',
widgets: [],
};
export default ${toCamelCase(name)}Dashboard;
`,
},
app: {
description: 'Application navigation',
defaultDir: 'src/apps',
/**
* `AppSchema.navigation` is an ARRAY of nav items (#14336).
*
* The template used to write `{ type: 'sidebar', items: [] }`. There is no
* `sidebar` wrapper on the authoring surface: the array IS the sidebar
* tree, and it nests through `type: 'group'` items carrying `children`.
*
* It scaffolds one real entry rather than an empty array, because the
* entry shape is the thing an author copies to add the second one — and
* because an app with no navigation renders a shell with nothing in it.
* The entry points at the object `os g object NAME` writes, so the two
* scaffolds compose.
*/
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';
/**
* ${toTitleCase(name)} App
*/
const ${toCamelCase(name)}App: UI.App = {
name: '${toSnakeCase(name)}_app',
label: '${toTitleCase(name)}',
navigation: [
{
id: '${toSnakeCase(name)}_nav',
type: 'object',
label: '${toTitleCase(name)}s',
objectName: '${toSnakeCase(name)}',
},
],
};
export default ${toCamelCase(name)}App;
`,
},
skill: {
description: 'AI skill (ADR-0063 extension primitive)',
defaultDir: 'src/skills',
/**
* Written as `NAME.skill.ts` — from the registry's own pattern now, not
* from a per-generator override.
*
* `skill` is where the consequence of getting this wrong was first
* measured (#11025). It is `allowRuntimeCreate: true`, a type the platform
* expects to DISCOVER rather than one wired in by hand, so a scaffold
* named `lead_qualification.ts` matches neither `*.skill.ts` nor
* `*.skill.yml`, and then type-checks, passes `os validate` and publishes
* with nothing anywhere saying it was skipped — the silent-strip shape
* ADR-0063's retirement of `os g agent` closed (#10359), re-entering
* through the scaffolder that replaced it.
*
* That reasoning was scoped to `skill` on the belief that the other six
* types were not filesystem-discovered. #11071 measured the loader
* instead — the mechanism, and the precondition that keeps it from
* firing in this repo today, are stated once in `metadata-file-name.ts`
* (#12075), not restated here. The override is gone and the rule is the
* harness default.
*/
generate: (name: string) => `import { defineSkill } from '@objectstack/spec/ai';
/**
* ${toTitleCase(name)} Skill
*
* Skills are the third-party AI extension primitive (ADR-0063 §2) — agents are
* platform-internal, so a skill, plus the declarative actions your app already
* ships, is how you give the assistant a new capability.
*
* Authored through \`defineSkill\` rather than as a bare typed literal so the
* object is parsed the moment this module loads: an unknown or retired key is
* a startup error naming the key, not a field that goes missing later.
*/
const ${toCamelCase(name)}Skill = defineSkill({
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
description: 'One line on what this skill is for — the model routes on it.',
// ADR-0063 §3 — the kernel agent surface this skill binds to, enforced at
// load time: 'ask' (the data console), 'build' (the authoring surface), or
// 'both' for a genuinely shared read-only capability. A skill only binds to
// an agent whose surface it matches. 'ask' is also the schema default, so
// writing it changes nothing at runtime; it is here because a default taken
// in silence is a decision the next author cannot see they are inheriting.
surface: 'ask',
// Injected into the active agent's system prompt, and projected onto the MCP
// \`prompts\` primitive by @objectstack/mcp — the half of a skill that runs in
// every distribution. This is the text the model actually reads.
instructions: 'Explain when this skill applies and how to use its tools.',
// Empty on purpose, and a complete skill as it stands: it contributes its
// instructions and no tools. Under ADR-0064 an agent's tool set is the union
// of its surface-compatible skills' tools with NO global fall-through, so an
// empty list grants nothing rather than everything.
//
// Fill it with names that resolve — a platform-registered tool, or
// \`action_NAME\` materialised from one of your own declarative actions that
// opts in with \`ai: { exposed: true, description: '…' }\` (ADR-0011/0109).
// A made-up placeholder would be worse than nothing: \`os validate\` reports
// it (\`ai-skill-tool-unresolved\`), and at runtime the reference is dropped
// while the instructions keep promising the capability.
//
// tools: ['action_${toSnakeCase(name)}', 'query_records'],
tools: [],
});
export default ${toCamelCase(name)}Skill;
`,
},
};
/**
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));
// ─── Retired Generators ─────────────────────────────────────────────
/**
* Scaffolder types that were withdrawn, and what this command says when one
* of them is run.
*
* A retired type is NOT an unknown type, and deliberately does not fall
* through to the `Unknown type:` branch in {@link runMetadataGeneration}.
* That branch prints the surviving roster and nothing else, so an author
* arriving from a doc page, a tutorial or a CI script that still names the
* retired type would learn only that their spelling is not on the list —
* and the natural next move is to hunt for the right spelling of something
* that no longer exists.
*
* `agent` (ADR-0063 §2, which reversed ADR-0040 §3): the kernel ships exactly
* two agents, `ask` and `build`, bound by surface and never picked from a
* roster. Tenant / app-package agents were withdrawn, and the runtime catalog
* filters out every non-platform agent record. The file this generator wrote
* into `src/agents/` therefore passed `os validate`, published without
* complaint, and was then dropped on the floor: no error at any step, the
* agent simply never appeared. Retiring the command silently would have moved
* that silence one step earlier instead of ending it, which is why each entry
* owes both halves — the decision that withdrew the surface, and the surface
* to author instead.
*/
const RETIRED_GENERATORS: Record<string, {
/** Reason clause completing "`os g <type>` was retired — …". */
reason: string;
/** Body lines, printed in order; an empty string prints a blank line. */
detail: string[];
}> = {
agent: {
reason: 'agents are platform-internal (ADR-0063 §2).',
detail: [
'The kernel ships exactly two agents, `ask` and `build`, bound by surface.',
'An agent you author still parses and still publishes — and the runtime',
'catalog then filters it out, so it never appears and nothing tells you.',
'This command scaffolded exactly that file, so it is retired, not repaired.',
'',
'Author a SKILL instead. Skills (plus tools / MCP) are the third-party',
'extension primitive ADR-0063 names — the live surface this one was not.',
'',
'Scaffold one — the file lands where the loader looks for it:',
'',
' os g skill <name> -> src/skills/<name>.skill.ts',
'',
"It writes a `defineSkill` template with `surface` and `tools` filled in",
'and explained, ready to edit.',
'',
'Docs: https://objectstack.ai/docs/ai/agents',
],
},
};
// ─── Helpers ────────────────────────────────────────────────────────
function toCamelCase(str: string): string {
return str.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase());
}
function toTitleCase(str: string): string {
return str.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function toSnakeCase(str: string): string {
return str.replace(/[-]/g, '_').replace(/[A-Z]/g, c => `_${c.toLowerCase()}`).replace(/^_/, '');
}
// ─── Field Type Mapping ─────────────────────────────────────────────
/**
* The TypeScript type each authored field type generates (#13871).
*
* Every key here MUST be a member of the `FieldType` enum in
* `@objectstack/spec/data` — that enum is the only statement of which field
* types exist, and a key outside it describes nothing. This table used to carry
* six that never existed anywhere (`integer`, `slug`, `uuid`, `ip_address`,
* `geo_point`, `encrypted`): invented here, mirrored into the migration
* codegen below, and readable as an acceptance surface the platform cannot
* honour. `generate-field-type-vocabulary.pin.test.ts` now fails on any such
* key, in this table and in the two vocabularies below it.
*
* TOTAL since #14657, and total BY CONSTRUCTION: the `satisfies
* Record<FieldType, string>` below makes a missing member a named `tsc` error
* (`Property 'x' is missing …`), so the next field type the spec adds cannot
* arrive here in silence. Before it, 21 real members had no entry and every one
* of them silently generated `unknown` — a plausible-looking wrong type with
* nothing to tell the author. The `|| 'unknown'` below stays, and now means
* only what it always should have: this generator's answer for a `type` string
* that is not a `FieldType` at all, which the UNVALIDATED authoring door (a
* plain-object config export, `defineStack(x, { strict: false })`) can still
* deliver.
*
* Values are MEASURED, not invented — each one is the shape the platform
* actually implements, read from the spec's ADR-0104 D1 value classes
* (`@objectstack/spec/data` `field-value.zod.ts`) and cross-checked against the
* `driver-sql` DDL emitter that creates the real columns. The two structured
* types point AT the spec's own exported types rather than transcribing them,
* so the generated interface cannot drift from the value contract.
*/
const FIELD_TYPE_MAP: Record<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
date: 'string',
datetime: 'string',
time: 'string',
email: 'string',
phone: 'string',
url: 'string',
select: 'string',
multiselect: 'string[]',
lookup: 'string',
master_detail: 'string',
formula: 'unknown',
autonumber: 'string',
json: 'Record<string, unknown>',
file: 'string',
image: 'string',
password: 'string',
color: 'string',
rating: 'number',
vector: 'number[]',
// #14657 — the members that used to fall to `|| 'unknown'`. Grouped by the
// spec's ADR-0104 D1 value class, which is what decides each answer.
// STRING_VALUE_TYPES. `secret` is a string because the ROW holds an opaque
// ref, not the credential: the engine encrypts via the ICryptoProvider,
// stores the ciphertext handle in `sys_secret`, and masks on read (ADR-0100).
secret: 'string',
code: 'string',
signature: 'string',
qrcode: 'string',
// BOOLEAN_VALUE_TYPES.
toggle: 'boolean',
// SINGLE_OPTION_TYPES / MULTI_OPTION_TYPES — an option code, or an array of
// them. `tags` is the free-form member of the multi class.
radio: 'string',
checkboxes: 'string[]',
tags: 'string[]',
// NUMERIC_VALUE_TYPES — `valueSchemaFor` gives all three `z.number()`.
slider: 'number',
progress: 'number',
summary: 'number',
// REFERENCE_VALUE_TYPES — the STORED form of a reference is the related
// record's id string; the expanded record is the read shape and is never
// stored. `user` stores identically to `lookup` (field.zod says so).
user: 'string',
tree: 'string',
// FILE_REFERENCE_TYPES — the stored form is an opaque `sys_file` id string
// (`FileReferenceIdValueSchema`), which is why `file`/`image` above are
// already `string`; these three are the same class and take the same answer.
avatar: 'string',
video: 'string',
audio: 'string',
// STRUCTURED_JSON_TYPES — embedded structured values stored as JSON on the
// parent row. ONE decision for the whole family, not four independent ones.
// `location` and `address` name the spec's own exported value types (the
// generated file already imports `* as Data`), so the emitted interface is
// derived from the value contract instead of transcribing `{lat, lng}` here.
composite: 'Record<string, unknown>',
repeater: 'Record<string, unknown>[]',
record: 'Record<string, Record<string, unknown>>',
location: 'Data.LocationValue',
address: 'Data.AddressValue',
} satisfies Record<FieldType, string>;
function fieldTypeToTs(fieldType: string, multiple?: boolean): string {
const base = FIELD_TYPE_MAP[fieldType] || 'unknown';
return multiple ? `${base}[]` : base;
}
export function generateTypesFromConfig(config: Record<string, unknown>): string {
const lines: string[] = [
'// Auto-generated by ObjectStack CLI — do not edit manually',
`// Generated at ${new Date().toISOString()}`,
'',
"import type * as Data from '@objectstack/spec/data';",
'',
];
// Extract objects from config (supports both top-level and nested)
const objects: Record<string, unknown>[] = [];
const rawObjects = (config as any).objects ?? (config as any).data?.objects ?? {};
if (Array.isArray(rawObjects)) {
objects.push(...rawObjects);
} else if (typeof rawObjects === 'object') {
for (const val of Object.values(rawObjects)) {
if (val && typeof val === 'object') objects.push(val as Record<string, unknown>);
}
}
if (objects.length === 0) {
lines.push('// No objects found in configuration');
return lines.join('\n') + '\n';
}
for (const obj of objects) {
const name = String(obj.name || 'unknown');
const typeName = name
.split('_')
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const fields = (obj.fields ?? {}) as Record<string, Record<string, unknown>>;
lines.push(`/** ${String(obj.label || typeName)} record type */`);
lines.push(`export interface ${typeName}Record {`);
lines.push(' id: string;');
for (const [fieldName, fieldDef] of Object.entries(fields)) {
const fType = String(fieldDef.type || 'text');
const tsType = fieldTypeToTs(fType, !!fieldDef.multiple);
const required = fieldDef.required ? '' : '?';
if (fieldDef.label) {
lines.push(` /** ${fieldDef.label} */`);
}
lines.push(` ${fieldName}${required}: ${tsType};`);
}
lines.push('}');
lines.push('');
}
return lines.join('\n') + '\n';
}
// ─── Command ────────────────────────────────────────────────────────
async function runMetadataGeneration(type: string, name: string, flags: { dir?: string; dryRun?: boolean }): Promise<void> {
printHeader('Generate');
// A withdrawn type answers for itself, ahead of the roster lookup — see
// RETIRED_GENERATORS for why "unknown type" is the wrong answer here.
const retired = RETIRED_GENERATORS[type];
if (retired) {
printError(`\`${CLI_ALIAS} g ${type}\` was retired — ${retired.reason}`);
console.log('');
for (const line of retired.detail) {
console.log(line ? chalk.dim(` ${line}`) : '');
}
console.log('');
process.exit(1);
}
const generator = GENERATORS[type];
if (!generator) {
printError(`Unknown type: ${type}`);
console.log('');
console.log(chalk.bold(' Available types:'));
for (const [key, gen] of Object.entries(GENERATORS)) {
console.log(` ${chalk.cyan(key.padEnd(12))} ${chalk.dim(gen.description)}`);
}
console.log('');
console.log(chalk.dim(' Usage: objectstack generate <type> <name>'));
console.log(chalk.dim(' Example: objectstack generate object project'));
console.log(chalk.dim(' Alias: os g object project'));
process.exit(1);
}
const dir = flags.dir || generator.defaultDir;
// The written name comes from the registry's `filePatterns` for this type
// — see `metadataFileName`, which carries why it is derived rather than
// tabulated.
const fileName = metadataFileName(type, toSnakeCase(name));
if (fileName === null) {
// Unreachable for every registered generator, and pinned that way by
// `generate-file-name-registry-parity.test.ts`. Reached only if someone
// adds a generator for a type the registry gives no TypeScript pattern,
// and refusing is the point: the alternative is a file that type-checks,
// validates, publishes and is never loaded, with no diagnostic at any
// step.
printError(`No file naming convention is declared for type: ${type}`);
console.log('');
console.log(chalk.dim(
` DEFAULT_METADATA_TYPE_REGISTRY has no recursive TypeScript file pattern`,
));
console.log(chalk.dim(
` for \`${type}\`, so there is no name this scaffold could be written to`,
));
console.log(chalk.dim(
' that the metadata loader would ever glob. Declare one there first.',
));
console.log('');
process.exit(1);
}
// The barrel re-export has to name the file that was actually written, so
// it is derived from `fileName` rather than rebuilt from `name` — the
// infix is part of the module specifier, and a barrel rebuilt from the
// metadata name alone would resolve to nothing.
const moduleSpecifier = `./${fileName.replace(/\.ts$/, '')}`;
const filePath = path.join(process.cwd(), dir, fileName);
console.log(` ${chalk.dim('Type:')} ${chalk.cyan(type)} — ${generator.description}`);
console.log(` ${chalk.dim('Name:')} ${chalk.white(name)}`);
console.log(` ${chalk.dim('File:')} ${chalk.white(path.join(dir, fileName))}`);
console.log('');
// Both emissions are rendered ONCE, here, and every branch below reuses
// them: the scaffold file, and the barrel re-export line. They are the two
// files one name reaches (#16541), and rendering them at the single point
// where the name has finished being derived is what lets one refusal cover
// all 14 emission sites across all 7 generators instead of 14 patches.
const content = generator.generate(name);
const exportLine = `export { default as ${toCamelCase(name)} } from '${moduleSpecifier}';`;
// ⛔ REFUSE rather than rewrite (#16541).
//
// This command ran no name validation at all, so a name that is legal as a
// NAME but not as an IDENTIFIER was interpolated straight into a binding
// position and written out under `exit 0` — `const foo.bar:
// Data.ServiceObject = {`, plus a matching barrel line: two files that are
// not TypeScript, from a command that reported success.
//
// The criterion is PARSEABILITY, not a charset. `findEmissionParseFailures`
// asks the compiler about the bytes above and about nothing else, which is
// why it also covers what a rule about identifier characters would miss —
// a reserved word is illegal as a `const` binding and legal as an
// `export { default as … }` alias, and `${toCamelCase(name)}Views` parses
// for a name that bare `${toCamelCase(name)}` refuses.
//
// Which names this command should ACCEPT — and whether it should normalise
// the ones it does, the way `os create` derives its identifier since
// #15892 — is an OPEN decision. Sanitising here would answer it by quietly
// widening tolerance, and a legal-looking identifier derived from a name
// that should have been refused is the worse of the two failures. So
// nothing is rewritten, acceptance is unchanged for every name that already
// produced parseable output, and the refusal is loud.
//
// Placed AHEAD of the dry-run branch on purpose: a preview that prints
// un-parseable TypeScript and exits 0 is the same defect in preview form.
const parseFailures = await findEmissionParseFailures([
{ label: path.join(dir, fileName), source: content },
{ label: path.join(dir, 'index.ts'), source: exportLine },
]);
if (parseFailures.length > 0) {
printError('Refusing to generate — the TypeScript this would write does not parse');
console.log('');
console.log(` ${chalk.dim('Name:')} ${chalk.white(name)}`);
console.log(` ${chalk.dim('Identifier:')} ${chalk.white(toCamelCase(name))}`);
console.log('');
for (const failure of parseFailures) {
console.log(` ${chalk.white(failure.label)}`);
for (const diagnostic of failure.diagnostics) {
console.log(chalk.dim(` ${diagnostic}`));
}
}
console.log('');
console.log(chalk.dim(
` \`${CLI_ALIAS} g\` derives a TypeScript identifier from the name you give it, and`,
));
console.log(chalk.dim(
' this one is not something the compiler can parse — so what is listed above',
));
console.log(chalk.dim(
' would be written broken. Nothing was written.',
));
console.log('');
console.log(chalk.dim(
' It refuses instead of rewriting your name into a legal-looking identifier,',
));
console.log(chalk.dim(
' which would decide in silence which names this command accepts. Pick a name',
));
console.log(chalk.dim(
` that survives as an identifier — \`${CLI_ALIAS} g ${type} order_line\` and`,
));
console.log(chalk.dim(
` \`${CLI_ALIAS} g ${type} order-line\` both work, and both fold to \`orderLine\`.`,
));
console.log('');
process.exit(1);
}
if (flags.dryRun) {
printInfo('Dry run — no files written');
console.log('');
console.log(chalk.dim(' Content:'));
console.log(chalk.dim(' ' + '-'.repeat(38)));
for (const line of content.split('\n')) {
console.log(chalk.dim(` ${line}`));
}
console.log('');
return;
}
// Check if file exists
if (fs.existsSync(filePath)) {
printError(`File already exists: ${filePath}`);
process.exit(1);
}
try {
// Create directory
const fullDir = path.dirname(filePath);
if (!fs.existsSync(fullDir)) {
fs.mkdirSync(fullDir, { recursive: true });
}
// Write file — the same `content` the parse check above accepted, ⛔ not
// a re-render: a second call to `generator.generate` would make the
// bytes that were checked and the bytes that land two different things.
fs.writeFileSync(filePath, content);
printSuccess(`Created ${path.join(dir, fileName)}`);
// Check for barrel index
const indexPath = path.join(process.cwd(), dir, 'index.ts');
if (fs.existsSync(indexPath)) {
const indexContent = fs.readFileSync(indexPath, 'utf-8');
if (!indexContent.includes(toCamelCase(name))) {
fs.appendFileSync(indexPath, exportLine + '\n');
printSuccess(`Updated ${dir}/index.ts with export`);
}
} else {
// Create barrel index
fs.writeFileSync(indexPath, exportLine + '\n');
printSuccess(`Created ${dir}/index.ts`);
}
console.log('');
console.log(chalk.dim(` Tip: Run \`objectstack validate\` to check your config`));
console.log('');
} catch (error: any) {
printError(error.message || String(error));
process.exit(1);
}
}
async function runTypesGeneration(configPath: string | undefined, flags: { output: string; dryRun?: boolean }): Promise<void> {
printHeader('Generate Types');
try {
const { loadConfig } = await import('../utils/config.js');
printInfo('Loading configuration...');
const { config, absolutePath } = await loadConfig(configPath);
console.log(` ${chalk.dim('Config:')} ${chalk.white(absolutePath)}`);
console.log(` ${chalk.dim('Output:')} ${chalk.white(flags.output)}`);
console.log('');
const content = generateTypesFromConfig(config as Record<string, unknown>);
if (flags.dryRun) {
printInfo('Dry run — no files written');
console.log('');
for (const line of content.split('\n')) {
console.log(chalk.dim(` ${line}`));
}
console.log('');
return;
}
const outPath = path.resolve(process.cwd(), flags.output);
const outDir = path.dirname(outPath);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
fs.writeFileSync(outPath, content);
printSuccess(`Generated types at ${flags.output}`);
console.log('');
} catch (error: any) {
// [#15547] `resolveConfigPath()` already reported its refusal on stderr
// before throwing; a second copy on stdout is what this guards.
if (!isReportedError(error)) printError(error.message || String(error));
process.exit(1);
}
}
// ─── Client SDK Generator ───────────────────────────────────────────
function generateClientFromConfig(config: Record<string, unknown>): string {
const lines: string[] = [
'// Auto-generated by ObjectStack CLI — do not edit manually',
`// Generated at ${new Date().toISOString()}`,
'',
"import type * as Data from '@objectstack/spec/data';",
'',
];
const objects: Record<string, unknown>[] = [];
const rawObjects = (config as any).objects ?? (config as any).data?.objects ?? {};
if (Array.isArray(rawObjects)) {
objects.push(...rawObjects);
} else if (typeof rawObjects === 'object') {
for (const val of Object.values(rawObjects)) {
if (val && typeof val === 'object') objects.push(val as Record<string, unknown>);
}
}
if (objects.length === 0) {
lines.push('// No objects found in configuration');
return lines.join('\n') + '\n';
}
// Generate type interfaces
for (const obj of objects) {
const name = String(obj.name || 'unknown');
const typeName = name
.split('_')
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const fields = (obj.fields ?? {}) as Record<string, Record<string, unknown>>;
lines.push(`export interface ${typeName}Record {`);
lines.push(' id: string;');
for (const [fieldName, fieldDef] of Object.entries(fields)) {
const fType = String(fieldDef.type || 'text');
const tsType = fieldTypeToTs(fType, !!fieldDef.multiple);
const required = fieldDef.required ? '' : '?';
lines.push(` ${fieldName}${required}: ${tsType};`);
}
lines.push('}');
lines.push('');
}
// Generate client class
lines.push('export class ObjectStackClient {');
lines.push(' constructor(private baseUrl: string, private headers: Record<string, string> = {}) {}');
lines.push('');
lines.push(' private async request<T>(method: string, path: string, body?: unknown): Promise<T> {');
lines.push(' const res = await fetch(`${this.baseUrl}${path}`, {');
lines.push(' method,');
lines.push(" headers: { 'Content-Type': 'application/json', ...this.headers },");
lines.push(' body: body ? JSON.stringify(body) : undefined,');
lines.push(' });');
lines.push(' if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);');
lines.push(' return res.json() as Promise<T>;');
lines.push(' }');
for (const obj of objects) {
const name = String(obj.name || 'unknown');
const typeName = name
.split('_')
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const endpoint = `/api/${name}`;
lines.push('');
lines.push(` async list${typeName}(): Promise<${typeName}Record[]> {`);
lines.push(` return this.request<${typeName}Record[]>('GET', '${endpoint}');`);
lines.push(' }');
lines.push('');
lines.push(` async get${typeName}(id: string): Promise<${typeName}Record> {`);
lines.push(` return this.request<${typeName}Record>('GET', '${endpoint}/\${id}');`);
lines.push(' }');
lines.push('');
lines.push(` async create${typeName}(data: Omit<${typeName}Record, 'id'>): Promise<${typeName}Record> {`);
lines.push(` return this.request<${typeName}Record>('POST', '${endpoint}', data);`);
lines.push(' }');
lines.push('');
lines.push(` async update${typeName}(id: string, data: Partial<${typeName}Record>): Promise<${typeName}Record> {`);
lines.push(` return this.request<${typeName}Record>('PATCH', '${endpoint}/\${id}', data);`);
lines.push(' }');
lines.push('');
lines.push(` async delete${typeName}(id: string): Promise<void> {`);
lines.push(` return this.request<void>('DELETE', '${endpoint}/\${id}');`);
lines.push(' }');
}
lines.push('}');
lines.push('');
return lines.join('\n') + '\n';
}
async function runClientGeneration(configPath: string | undefined, flags: { output: string; dryRun?: boolean }): Promise<void> {
printHeader('Generate Client SDK');
try {
const { loadConfig } = await import('../utils/config.js');
const timer = createTimer();
printInfo('Loading configuration...');
const { config, absolutePath } = await loadConfig(configPath);
console.log(` ${chalk.dim('Config:')} ${chalk.white(absolutePath)}`);
console.log(` ${chalk.dim('Output:')} ${chalk.white(flags.output)}`);
console.log('');
printStep('Generating client SDK...');
const content = generateClientFromConfig(config as Record<string, unknown>);
if (flags.dryRun) {
printInfo('Dry run — no files written');
console.log('');
for (const line of content.split('\n')) {
console.log(chalk.dim(` ${line}`));
}
console.log('');
return;
}