@vsceasy/cli 0.1.8 → 0.1.10

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 (30) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +40 -0
  3. package/dist/bin/cli.js +858 -76
  4. package/dist/index.js +717 -19
  5. package/dist/lib/config.d.ts +2 -0
  6. package/dist/lib/contributesMerge.d.ts +18 -0
  7. package/dist/lib/crud/parseModel.d.ts +12 -0
  8. package/dist/lib/helper/add.d.ts +1 -1
  9. package/dist/lib/model/add.d.ts +18 -0
  10. package/dist/lib/model/parseFields.d.ts +9 -3
  11. package/dist/lib/scaffold.d.ts +4 -0
  12. package/dist/lib/templatesData.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/templates/_assets/language/README.language.md +39 -0
  15. package/templates/_assets/language/contributes.extra.json +40 -0
  16. package/templates/_assets/language/fileicons/{{langId}}-icon-theme.json +13 -0
  17. package/templates/_assets/language/icons/{{langId}}.svg +5 -0
  18. package/templates/_assets/language/language-configuration.json +30 -0
  19. package/templates/_assets/language/snippets/{{langId}}.json +12 -0
  20. package/templates/_assets/language/src/colorize.ts +31 -0
  21. package/templates/_assets/language/src/commands/applyColors.ts +11 -0
  22. package/templates/_assets/language/src/commands/removeColors.ts +11 -0
  23. package/templates/_assets/language/src/extension/extension.ts +25 -0
  24. package/templates/_assets/language/src/helpers/colorize.ts +70 -0
  25. package/templates/_assets/language/syntaxes/{{langId}}.tmLanguage.json +45 -0
  26. package/templates/_generators/crud/formApp.tsx.tpl +2 -0
  27. package/templates/_generators/crud/formPanel.ts.tpl +2 -2
  28. package/templates/_generators/helper/colorize.ts.tpl +85 -0
  29. package/templates/_generators/model/model.ts.tpl +1 -1
  30. package/templates/react/scripts/gen.ts +55 -0
package/dist/bin/cli.js CHANGED
@@ -3738,15 +3738,26 @@ async function scaffold(opts) {
3738
3738
  throw new Error(`Target directory not empty: ${opts.targetDir}`);
3739
3739
  }
3740
3740
  fs2.mkdirSync(opts.targetDir, { recursive: true });
3741
+ const type = opts.type ?? "ui";
3741
3742
  const vars = buildVars(opts);
3742
3743
  await copyTree(src, opts.targetDir, vars);
3743
- applyPreset(opts.targetDir, opts.preset ?? "full");
3744
+ applyType(opts.targetDir, type, opts.preset ?? "full", opts.templatesRoot, vars);
3744
3745
  writeConfig(opts.targetDir, {
3745
3746
  publisher: opts.publisher,
3746
3747
  commandPrefix: vars.commandPrefix,
3747
- ui: opts.ui
3748
+ type,
3749
+ ...type === "ui" ? { ui: opts.ui } : {}
3748
3750
  });
3749
3751
  }
3752
+ function applyType(targetDir, type, preset, templatesRoot, vars) {
3753
+ if (type === "ui") {
3754
+ applyPreset(targetDir, preset);
3755
+ return;
3756
+ }
3757
+ stripWebview(targetDir);
3758
+ if (type === "language")
3759
+ applyLanguage(targetDir, templatesRoot, vars);
3760
+ }
3750
3761
  function applyPreset(targetDir, preset) {
3751
3762
  if (preset === "full")
3752
3763
  return;
@@ -3769,15 +3780,104 @@ function applyPreset(targetDir, preset) {
3769
3780
  `);
3770
3781
  }
3771
3782
  }
3783
+ function stripWebview(targetDir) {
3784
+ const removals = [
3785
+ "src/panels",
3786
+ "src/webview",
3787
+ "src/commands/hello.ts",
3788
+ "vite.config.ts"
3789
+ ];
3790
+ for (const rel of removals) {
3791
+ const abs = path2.join(targetDir, rel);
3792
+ if (fs2.existsSync(abs))
3793
+ fs2.rmSync(abs, { recursive: true, force: true });
3794
+ }
3795
+ const apiPath = path2.join(targetDir, "src", "shared", "api.ts");
3796
+ if (fs2.existsSync(apiPath)) {
3797
+ fs2.writeFileSync(apiPath, `// RPC contracts go here (add a panel with \`vsceasy panel add\`).
3798
+ `);
3799
+ }
3800
+ trimReactFromPackageJson(targetDir);
3801
+ }
3802
+ function trimReactFromPackageJson(targetDir) {
3803
+ const pkgPath = path2.join(targetDir, "package.json");
3804
+ if (!fs2.existsSync(pkgPath))
3805
+ return;
3806
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
3807
+ for (const section of ["dependencies", "devDependencies"]) {
3808
+ if (!pkg[section])
3809
+ continue;
3810
+ for (const dep of Object.keys(pkg[section])) {
3811
+ if (REACT_DEPS.has(dep))
3812
+ delete pkg[section][dep];
3813
+ }
3814
+ if (Object.keys(pkg[section]).length === 0)
3815
+ delete pkg[section];
3816
+ }
3817
+ if (pkg.scripts) {
3818
+ for (const key of Object.keys(pkg.scripts)) {
3819
+ if (UI_SCRIPT_KEYS.has(key))
3820
+ delete pkg.scripts[key];
3821
+ }
3822
+ if (pkg.scripts.dev) {
3823
+ pkg.scripts.dev = "bun run gen:scan && bun run dev:ext";
3824
+ }
3825
+ for (const [k, v] of Object.entries(pkg.scripts)) {
3826
+ pkg.scripts[k] = v.replace(/\s*&&\s*bun run build:ui/g, "");
3827
+ }
3828
+ }
3829
+ fs2.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + `
3830
+ `);
3831
+ }
3832
+ function applyLanguage(targetDir, templatesRoot, vars) {
3833
+ const assetsDir = path2.join(templatesRoot, "_assets", "language");
3834
+ if (!fs2.existsSync(assetsDir)) {
3835
+ throw new Error(`Language assets not found: ${assetsDir}`);
3836
+ }
3837
+ copyAssetsTree(assetsDir, targetDir, vars);
3838
+ const langReadme = path2.join(targetDir, "README.language.md");
3839
+ if (fs2.existsSync(langReadme)) {
3840
+ fs2.renameSync(langReadme, path2.join(targetDir, "README.md"));
3841
+ }
3842
+ const pkgPath = path2.join(targetDir, "package.json");
3843
+ if (fs2.existsSync(pkgPath)) {
3844
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
3845
+ pkg.activationEvents = [`onLanguage:${vars.langId}`];
3846
+ fs2.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + `
3847
+ `);
3848
+ }
3849
+ }
3850
+ function copyAssetsTree(srcDir, destDir, vars) {
3851
+ for (const entry of fs2.readdirSync(srcDir, { withFileTypes: true })) {
3852
+ if (SKIP_NAMES.has(entry.name))
3853
+ continue;
3854
+ const srcPath = path2.join(srcDir, entry.name);
3855
+ const destName = substitute(entry.name, vars);
3856
+ const destPath = path2.join(destDir, destName);
3857
+ if (entry.isDirectory()) {
3858
+ fs2.mkdirSync(destPath, { recursive: true });
3859
+ copyAssetsTree(srcPath, destPath, vars);
3860
+ } else if (entry.isFile()) {
3861
+ fs2.mkdirSync(path2.dirname(destPath), { recursive: true });
3862
+ fs2.writeFileSync(destPath, substitute(fs2.readFileSync(srcPath, "utf8"), vars));
3863
+ }
3864
+ }
3865
+ }
3772
3866
  function buildVars(opts) {
3773
3867
  const simpleName = opts.name.replace(/^@[^/]+\//, "");
3774
3868
  const commandPrefix = simpleName.replace(/[^a-zA-Z0-9]+/g, "");
3869
+ const langId = simpleName.toLowerCase().replace(/[^a-z0-9]+/g, "");
3870
+ const scopeName = `source.${langId}`;
3871
+ const langExt = langId.slice(0, 4).toUpperCase();
3775
3872
  return {
3776
3873
  name: opts.name,
3777
3874
  displayName: opts.displayName,
3778
3875
  description: opts.description,
3779
3876
  publisher: opts.publisher,
3780
- commandPrefix
3877
+ commandPrefix,
3878
+ langId,
3879
+ scopeName,
3880
+ langExt
3781
3881
  };
3782
3882
  }
3783
3883
  async function copyTree(srcDir, destDir, vars) {
@@ -3803,7 +3903,7 @@ async function copyTree(srcDir, destDir, vars) {
3803
3903
  function substitute(input, vars) {
3804
3904
  return input.replace(/\{\{(\w+)\}\}/g, (_m, key) => vars[key] ?? `{{${key}}}`);
3805
3905
  }
3806
- var fs2, path2, PLACEHOLDER_EXTS, SKIP_NAMES;
3906
+ var fs2, path2, PLACEHOLDER_EXTS, SKIP_NAMES, REACT_DEPS, UI_SCRIPT_KEYS;
3807
3907
  var init_scaffold = __esm(() => {
3808
3908
  init_config();
3809
3909
  fs2 = __toESM(require("fs"));
@@ -3823,12 +3923,326 @@ var init_scaffold = __esm(() => {
3823
3923
  ".yaml"
3824
3924
  ]);
3825
3925
  SKIP_NAMES = new Set(["node_modules", "dist", ".DS_Store"]);
3926
+ REACT_DEPS = new Set([
3927
+ "react",
3928
+ "react-dom",
3929
+ "@types/react",
3930
+ "@types/react-dom",
3931
+ "@vitejs/plugin-react",
3932
+ "vite"
3933
+ ]);
3934
+ UI_SCRIPT_KEYS = new Set(["dev:ui", "build:ui"]);
3826
3935
  });
3827
3936
 
3828
3937
  // src/lib/templatesData.ts
3829
- var TEMPLATES_VERSION = "0.1.8", TEMPLATE_FILES;
3938
+ var TEMPLATES_VERSION = "0.1.10", TEMPLATE_FILES;
3830
3939
  var init_templatesData = __esm(() => {
3831
3940
  TEMPLATE_FILES = {
3941
+ "_assets/language/README.language.md": "## {{displayName}} language support\n\nThis extension was scaffolded with `vsceasy create --type language`. It provides\neditor support for `.{{langId}}` files:\n\n- **Syntax highlighting** — TextMate grammar in `syntaxes/{{langId}}.tmLanguage.json`\n- **Language configuration** — comments, brackets, auto-closing pairs, folding in\n `language-configuration.json`\n- **Snippets** — `snippets/{{langId}}.json`\n- **File icon** (opt-in) — `fileicons/{{langId}}-icon-theme.json`\n\n### How contributions are wired\n\n`vsceasy`'s `scripts/gen.ts` regenerates the generated parts of\n`package.json#contributes` (commands, views…) on every build. Language\ncontributions are **not** generated — they live in **`contributes.extra.json`**\nat the project root and are deep-merged into `package.json` by `gen.ts`. Edit\n`contributes.extra.json` to change languages / grammars / snippets / iconThemes,\nthen run `bun run gen`.\n\n### File icon is opt-in\n\nVS Code file icons are provided by an **icon theme**, which is global: activating\nit replaces *all* file icons in the workbench, not just `.{{langId}}`. This\nextension ships a `{{displayName}} Icons` theme but does **not** force it. To use\nit: `Preferences: File Icon Theme` → pick `{{displayName}} Icons`. If you don't\nwant to override every icon, leave it unselected — highlighting, config and\nsnippets work regardless.\n\n### Develop\n\n```sh\nbun install\nbun run gen # sync package.json#contributes\nbun run package # build a .vsix\n```\n\nPress `F5` (or run `bun run launch`) to open an Extension Development Host and\nopen a `.{{langId}}` file to see highlighting.\n",
3942
+ "_assets/language/contributes.extra.json": `{
3943
+ "languages": [
3944
+ {
3945
+ "id": "{{langId}}",
3946
+ "aliases": ["{{displayName}}", "{{langId}}"],
3947
+ "extensions": [".{{langId}}"],
3948
+ "configuration": "./language-configuration.json"
3949
+ }
3950
+ ],
3951
+ "grammars": [
3952
+ {
3953
+ "language": "{{langId}}",
3954
+ "scopeName": "{{scopeName}}",
3955
+ "path": "./syntaxes/{{langId}}.tmLanguage.json"
3956
+ }
3957
+ ],
3958
+ "snippets": [
3959
+ {
3960
+ "language": "{{langId}}",
3961
+ "path": "./snippets/{{langId}}.json"
3962
+ }
3963
+ ],
3964
+ "iconThemes": [
3965
+ {
3966
+ "id": "{{langId}}-icons",
3967
+ "label": "{{displayName}} Icons",
3968
+ "path": "./fileicons/{{langId}}-icon-theme.json"
3969
+ }
3970
+ ],
3971
+ "configuration": {
3972
+ "title": "{{displayName}}",
3973
+ "properties": {
3974
+ "{{commandPrefix}}.colorize": {
3975
+ "type": "boolean",
3976
+ "default": true,
3977
+ "markdownDescription": "Automatically apply distinct section colors to \`.{{langId}}\` files (scoped to \`{{scopeName}}\`, so other languages are unaffected). Turn off to keep your theme's default colors."
3978
+ }
3979
+ }
3980
+ }
3981
+ }
3982
+ `,
3983
+ "_assets/language/fileicons/{{langId}}-icon-theme.json": `{
3984
+ "iconDefinitions": {
3985
+ "{{langId}}_file": {
3986
+ "iconPath": "../icons/{{langId}}.svg"
3987
+ }
3988
+ },
3989
+ "languageIds": {
3990
+ "{{langId}}": "{{langId}}_file"
3991
+ },
3992
+ "fileExtensions": {
3993
+ "{{langId}}": "{{langId}}_file"
3994
+ }
3995
+ }
3996
+ `,
3997
+ "_assets/language/icons/{{langId}}.svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
3998
+ <path fill="#8a8a8a" d="M6 3h14l6 6v20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/>
3999
+ <path fill="#5a5a5a" d="M20 3l6 6h-6z"/>
4000
+ <text x="16" y="22" font-family="monospace" font-size="9" font-weight="bold" fill="#fff" text-anchor="middle">{{langExt}}</text>
4001
+ </svg>
4002
+ `,
4003
+ "_assets/language/language-configuration.json": `{
4004
+ "comments": {
4005
+ "lineComment": "#"
4006
+ },
4007
+ "brackets": [
4008
+ ["{", "}"],
4009
+ ["[", "]"],
4010
+ ["(", ")"]
4011
+ ],
4012
+ "autoClosingPairs": [
4013
+ { "open": "{", "close": "}" },
4014
+ { "open": "[", "close": "]" },
4015
+ { "open": "(", "close": ")" },
4016
+ { "open": "\\"", "close": "\\"", "notIn": ["string"] },
4017
+ { "open": "'", "close": "'", "notIn": ["string"] }
4018
+ ],
4019
+ "surroundingPairs": [
4020
+ ["{", "}"],
4021
+ ["[", "]"],
4022
+ ["(", ")"],
4023
+ ["\\"", "\\""],
4024
+ ["'", "'"]
4025
+ ],
4026
+ "folding": {
4027
+ "markers": {
4028
+ "start": "^\\\\s*#\\\\s*region\\\\b",
4029
+ "end": "^\\\\s*#\\\\s*endregion\\\\b"
4030
+ }
4031
+ }
4032
+ }
4033
+ `,
4034
+ "_assets/language/snippets/{{langId}}.json": `{
4035
+ "Section": {
4036
+ "prefix": "section",
4037
+ "body": ["[\${1:name}]", "$0"],
4038
+ "description": "A {{displayName}} section"
4039
+ },
4040
+ "Key/Value": {
4041
+ "prefix": "kv",
4042
+ "body": ["\${1:key} = \${2:value}"],
4043
+ "description": "A key/value pair"
4044
+ }
4045
+ }
4046
+ `,
4047
+ "_assets/language/src/colorize.ts": `import type * as vscode from 'vscode';
4048
+ import { applyTokenColors, removeTokenColors, type TokenColorRule } from './helpers/colorize';
4049
+
4050
+ /** Root TextMate scope of this language — rules are applied only to these files. */
4051
+ export const SCOPE = '{{scopeName}}';
4052
+
4053
+ /**
4054
+ * Default token colors for {{displayName}}, emphasizing each construct. Edit
4055
+ * freely — they are applied to \`[{{scopeName}}]\` only, so other languages keep
4056
+ * the user's theme. Scope names must match your grammar
4057
+ * (syntaxes/{{langId}}.tmLanguage.json).
4058
+ */
4059
+ export const RULES: TokenColorRule[] = [
4060
+ { scope: 'comment.line.number-sign.{{langId}}', settings: { foreground: '#6b7a6e', fontStyle: 'italic' } },
4061
+ { scope: 'string.quoted.double.basic.{{langId}}', settings: { foreground: '#98c379' } },
4062
+ { scope: 'string.quoted.single.literal.{{langId}}', settings: { foreground: '#98c379' } },
4063
+ { scope: 'constant.numeric.{{langId}}', settings: { foreground: '#d19a66' } },
4064
+ ];
4065
+
4066
+ export async function applyColors(vscodeNs: typeof vscode): Promise<void> {
4067
+ await applyTokenColors(SCOPE, RULES);
4068
+ }
4069
+
4070
+ export async function removeColors(vscodeNs: typeof vscode): Promise<void> {
4071
+ await removeTokenColors(SCOPE);
4072
+ }
4073
+
4074
+ /** True when the user has opted in (default) to automatic coloring. */
4075
+ export function colorizeEnabled(vscodeNs: typeof vscode): boolean {
4076
+ return vscodeNs.workspace.getConfiguration('{{commandPrefix}}').get<boolean>('colorize', true);
4077
+ }
4078
+ `,
4079
+ "_assets/language/src/commands/applyColors.ts": `import { defineCommand } from '../shared/vsceasy';
4080
+ import { applyColors } from '../colorize';
4081
+
4082
+ export default defineCommand({
4083
+ id: 'applyColors',
4084
+ title: '{{displayName}}: Apply Colors',
4085
+ run: async (vscode) => {
4086
+ await applyColors(vscode);
4087
+ vscode.window.showInformationMessage('{{displayName}} colors applied.');
4088
+ },
4089
+ });
4090
+ `,
4091
+ "_assets/language/src/commands/removeColors.ts": `import { defineCommand } from '../shared/vsceasy';
4092
+ import { removeColors } from '../colorize';
4093
+
4094
+ export default defineCommand({
4095
+ id: 'removeColors',
4096
+ title: '{{displayName}}: Remove Colors',
4097
+ run: async (vscode) => {
4098
+ await removeColors(vscode);
4099
+ vscode.window.showInformationMessage('{{displayName}} colors removed.');
4100
+ },
4101
+ });
4102
+ `,
4103
+ "_assets/language/src/extension/extension.ts": `import { bootstrap } from '../shared/vsceasy';
4104
+ import { registry } from './_registry';
4105
+ import { applyColors, removeColors, colorizeEnabled } from '../colorize';
4106
+
4107
+ export const activate = bootstrap(registry, {
4108
+ onActivate: [
4109
+ async (context, vscode) => {
4110
+ // Auto-apply scoped token colors on activate when opted in (default).
4111
+ // Scoped to {{scopeName}} only — other languages are untouched.
4112
+ if (colorizeEnabled(vscode)) {
4113
+ await applyColors(vscode);
4114
+ }
4115
+ // React to the user toggling \`{{commandPrefix}}.colorize\` at runtime.
4116
+ context.subscriptions.push(
4117
+ vscode.workspace.onDidChangeConfiguration(async (e) => {
4118
+ if (!e.affectsConfiguration('{{commandPrefix}}.colorize')) return;
4119
+ if (colorizeEnabled(vscode)) await applyColors(vscode);
4120
+ else await removeColors(vscode);
4121
+ }),
4122
+ );
4123
+ },
4124
+ ],
4125
+ });
4126
+
4127
+ export function deactivate() {}
4128
+ `,
4129
+ "_assets/language/src/helpers/colorize.ts": `import * as vscode from 'vscode';
4130
+
4131
+ /**
4132
+ * Apply theme-independent token colors to a single TextMate scope (e.g. a
4133
+ * language's root scope like \`{{scopeName}}\`), written to the user's
4134
+ * \`editor.tokenColorCustomizations\`. Because the rules are keyed by
4135
+ * \`[<scope>]\`, only files in that scope are recolored — every other language
4136
+ * keeps the active theme's colors.
4137
+ *
4138
+ * Rules are tagged with a marker so {@link removeTokenColors} can strip exactly
4139
+ * the ones this extension added, preserving any the user wrote by hand.
4140
+ */
4141
+
4142
+ export interface TokenColorRule {
4143
+ /** Comma-separated TextMate scopes, e.g. 'entity.name.section.foo, comment.line.foo'. */
4144
+ scope: string;
4145
+ settings: { foreground?: string; background?: string; fontStyle?: string };
4146
+ }
4147
+
4148
+ type TaggedRule = TokenColorRule & { [MARK]?: true };
4149
+
4150
+ /** Marker key identifying rules this extension wrote (vs. the user's own). */
4151
+ const MARK = '{{commandPrefix}}Colorize';
4152
+ const SECTION = 'editor.tokenColorCustomizations';
4153
+
4154
+ const blockKey = (scope: string) => \`[\${scope}]\`;
4155
+
4156
+ /**
4157
+ * Merge \`rules\` into \`editor.tokenColorCustomizations["[<scope>]"].textMateRules\`,
4158
+ * preserving the user's own rules and other scope keys. Idempotent — re-applying
4159
+ * replaces only previously-applied rules from this extension.
4160
+ */
4161
+ export async function applyTokenColors(
4162
+ scope: string,
4163
+ rules: TokenColorRule[],
4164
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
4165
+ ): Promise<void> {
4166
+ const cfg = vscode.workspace.getConfiguration();
4167
+ const current = (cfg.get<Record<string, any>>(SECTION) ?? {}) as Record<string, any>;
4168
+ const key = blockKey(scope);
4169
+ const block = (current[key] ?? {}) as { textMateRules?: TaggedRule[] };
4170
+ const existing = Array.isArray(block.textMateRules) ? block.textMateRules : [];
4171
+ const userRules = existing.filter((r) => !r[MARK]);
4172
+ const ours: TaggedRule[] = rules.map((r) => ({ ...r, [MARK]: true }));
4173
+ const next = { ...current, [key]: { ...block, textMateRules: [...userRules, ...ours] } };
4174
+ await cfg.update(SECTION, next, target);
4175
+ }
4176
+
4177
+ /** Remove only the rules this extension added for \`scope\`; leave the rest intact. */
4178
+ export async function removeTokenColors(
4179
+ scope: string,
4180
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
4181
+ ): Promise<void> {
4182
+ const cfg = vscode.workspace.getConfiguration();
4183
+ const current = cfg.get<Record<string, any>>(SECTION);
4184
+ const key = blockKey(scope);
4185
+ if (!current || !current[key]) return;
4186
+ const block = current[key] as { textMateRules?: TaggedRule[] };
4187
+ const userRules = (block.textMateRules ?? []).filter((r) => !r[MARK]);
4188
+
4189
+ const nextBlock: Record<string, unknown> = { ...block };
4190
+ if (userRules.length) nextBlock.textMateRules = userRules;
4191
+ else delete nextBlock.textMateRules;
4192
+
4193
+ const next = { ...current };
4194
+ if (Object.keys(nextBlock).length) next[key] = nextBlock;
4195
+ else delete next[key];
4196
+
4197
+ await cfg.update(SECTION, Object.keys(next).length ? next : undefined, target);
4198
+ }
4199
+ `,
4200
+ "_assets/language/syntaxes/{{langId}}.tmLanguage.json": `{
4201
+ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
4202
+ "name": "{{displayName}}",
4203
+ "scopeName": "{{scopeName}}",
4204
+ "patterns": [
4205
+ { "include": "#comments" },
4206
+ { "include": "#strings" },
4207
+ { "include": "#numbers" }
4208
+ ],
4209
+ "repository": {
4210
+ "comments": {
4211
+ "patterns": [
4212
+ {
4213
+ "name": "comment.line.number-sign.{{langId}}",
4214
+ "match": "#.*$"
4215
+ }
4216
+ ]
4217
+ },
4218
+ "strings": {
4219
+ "patterns": [
4220
+ {
4221
+ "name": "string.quoted.double.{{langId}}",
4222
+ "begin": "\\"",
4223
+ "end": "\\"",
4224
+ "patterns": [
4225
+ { "name": "constant.character.escape.{{langId}}", "match": "\\\\\\\\." }
4226
+ ]
4227
+ },
4228
+ {
4229
+ "name": "string.quoted.single.{{langId}}",
4230
+ "begin": "'",
4231
+ "end": "'"
4232
+ }
4233
+ ]
4234
+ },
4235
+ "numbers": {
4236
+ "patterns": [
4237
+ {
4238
+ "name": "constant.numeric.{{langId}}",
4239
+ "match": "\\\\b[0-9]+(\\\\.[0-9]+)?\\\\b"
4240
+ }
4241
+ ]
4242
+ }
4243
+ }
4244
+ }
4245
+ `,
3832
4246
  "_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
3833
4247
 
3834
4248
  export default defineCommand({
@@ -4038,6 +4452,7 @@ export function App() {
4038
4452
  const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
4039
4453
  const [error, setError] = useState<string | null>(null);
4040
4454
  const [saving, setSaving] = useState(false);
4455
+ {{relationOptionsState}}
4041
4456
 
4042
4457
  const load = useCallback(async (initial: boolean) => {
4043
4458
  // The list stashes a row id before revealing this panel. Pull it (the host
@@ -4075,6 +4490,7 @@ export function App() {
4075
4490
  document.removeEventListener('visibilitychange', onVisible);
4076
4491
  };
4077
4492
  }, [load]);
4493
+ {{relationOptionsLoad}}
4078
4494
 
4079
4495
  const onChange = <K extends keyof FormState>(k: K, v: FormState[K]) => {
4080
4496
  setForm((f) => ({ ...f, [k]: v }));
@@ -4138,7 +4554,7 @@ import { {{Name}}Service } from '../services/{{Name}}Service';
4138
4554
  import { takePending{{Name}}Id } from '../services/{{name}}FormNav';
4139
4555
  import type { {{Name}}FormApi } from '../shared/api';
4140
4556
  import type { {{Name}} } from '../models/{{Name}}';
4141
-
4557
+ {{relationImports}}
4142
4558
  export default definePanel<{{Name}}FormApi>({
4143
4559
  title: '{{title}}',
4144
4560
  column: 'beside',
@@ -4160,7 +4576,7 @@ export default definePanel<{{Name}}FormApi>({
4160
4576
  void vscode.commands.executeCommand('{{prefix}}.open{{Plural}}List');
4161
4577
  return saved;
4162
4578
  },
4163
- async cancel() {
4579
+ {{relationOptionsHandler}} async cancel() {
4164
4580
  // No-op — webview closes itself.
4165
4581
  },
4166
4582
  }),
@@ -4434,6 +4850,92 @@ export function createCache<V = unknown>(opts: CacheOptions = {}): Cache<V> {
4434
4850
 
4435
4851
  return cache;
4436
4852
  }
4853
+ `,
4854
+ "_generators/helper/colorize.ts.tpl": `import * as vscode from 'vscode';
4855
+
4856
+ /**
4857
+ * Apply theme-independent token colors to a single TextMate scope (e.g. a
4858
+ * language's root scope like \`source.toml\`), written to the user's
4859
+ * \`editor.tokenColorCustomizations\`. Because the rules are keyed by
4860
+ * \`[<scope>]\`, only files in that scope are recolored — every other language
4861
+ * keeps the active theme's colors.
4862
+ *
4863
+ * Rules are tagged with a marker so {@link removeTokenColors} can strip exactly
4864
+ * the ones this extension added, preserving any the user wrote by hand.
4865
+ *
4866
+ * Typical use — auto-apply on activate behind an opt-out setting:
4867
+ *
4868
+ * // extension.ts (onActivate hook)
4869
+ * if (config.get<boolean>('colorize', true)) {
4870
+ * await applyTokenColors('source.{{commandPrefix}}', MY_RULES);
4871
+ * }
4872
+ * vscode.workspace.onDidChangeConfiguration(async (e) => {
4873
+ * if (!e.affectsConfiguration('{{commandPrefix}}.colorize')) return;
4874
+ * if (config.get<boolean>('colorize', true)) await applyTokenColors('source.{{commandPrefix}}', MY_RULES);
4875
+ * else await removeTokenColors('source.{{commandPrefix}}');
4876
+ * });
4877
+ *
4878
+ * Declare the opt-out in package.json#contributes.configuration:
4879
+ * "{{commandPrefix}}.colorize": { "type": "boolean", "default": true }
4880
+ */
4881
+
4882
+ export interface TokenColorRule {
4883
+ /** Comma-separated TextMate scopes, e.g. 'entity.name.section.foo, comment.line.foo'. */
4884
+ scope: string;
4885
+ settings: { foreground?: string; background?: string; fontStyle?: string };
4886
+ }
4887
+
4888
+ type TaggedRule = TokenColorRule & { [MARK]?: true };
4889
+
4890
+ /** Marker key identifying rules this extension wrote (vs. the user's own). */
4891
+ const MARK = '{{commandPrefix}}Colorize';
4892
+ const SECTION = 'editor.tokenColorCustomizations';
4893
+
4894
+ const blockKey = (scope: string) => \`[\${scope}]\`;
4895
+
4896
+ /**
4897
+ * Merge \`rules\` into \`editor.tokenColorCustomizations["[<scope>]"].textMateRules\`,
4898
+ * preserving the user's own rules and other scope keys. Idempotent — re-applying
4899
+ * replaces only previously-applied rules from this extension.
4900
+ */
4901
+ export async function applyTokenColors(
4902
+ scope: string,
4903
+ rules: TokenColorRule[],
4904
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
4905
+ ): Promise<void> {
4906
+ const cfg = vscode.workspace.getConfiguration();
4907
+ const current = (cfg.get<Record<string, any>>(SECTION) ?? {}) as Record<string, any>;
4908
+ const key = blockKey(scope);
4909
+ const block = (current[key] ?? {}) as { textMateRules?: TaggedRule[] };
4910
+ const existing = Array.isArray(block.textMateRules) ? block.textMateRules : [];
4911
+ const userRules = existing.filter((r) => !r[MARK]);
4912
+ const ours: TaggedRule[] = rules.map((r) => ({ ...r, [MARK]: true }));
4913
+ const next = { ...current, [key]: { ...block, textMateRules: [...userRules, ...ours] } };
4914
+ await cfg.update(SECTION, next, target);
4915
+ }
4916
+
4917
+ /** Remove only the rules this extension added for \`scope\`; leave the rest intact. */
4918
+ export async function removeTokenColors(
4919
+ scope: string,
4920
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
4921
+ ): Promise<void> {
4922
+ const cfg = vscode.workspace.getConfiguration();
4923
+ const current = cfg.get<Record<string, any>>(SECTION);
4924
+ const key = blockKey(scope);
4925
+ if (!current || !current[key]) return;
4926
+ const block = current[key] as { textMateRules?: TaggedRule[] };
4927
+ const userRules = (block.textMateRules ?? []).filter((r) => !r[MARK]);
4928
+
4929
+ const nextBlock: Record<string, unknown> = { ...block };
4930
+ if (userRules.length) nextBlock.textMateRules = userRules;
4931
+ else delete nextBlock.textMateRules;
4932
+
4933
+ const next = { ...current };
4934
+ if (Object.keys(nextBlock).length) next[key] = nextBlock;
4935
+ else delete next[key];
4936
+
4937
+ await cfg.update(SECTION, Object.keys(next).length ? next : undefined, target);
4938
+ }
4437
4939
  `,
4438
4940
  "_generators/helper/config.ts.tpl": `import * as vscode from 'vscode';
4439
4941
 
@@ -5006,7 +5508,7 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
5006
5508
  * import { {{Plural}}Repo } from '../models/{{Name}}';
5007
5509
  * await {{Plural}}Repo().insert({ ... });
5008
5510
  */
5009
- export const {{Plural}}Repo = () => db()({{Plural}});
5511
+ export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}
5010
5512
  `,
5011
5513
  "_generators/panel/App.tsx.tpl": `import React from 'react';
5012
5514
  {{apiBlock}}
@@ -5752,6 +6254,12 @@ bun run package # → {{name}}-0.0.1.vsix
5752
6254
  "react/scripts/gen.ts": `#!/usr/bin/env bun
5753
6255
  // Scans src/panels, src/commands, and src/menus; writes src/extension/_registry.ts
5754
6256
  // and syncs package.json#contributes (commands, viewsContainers, views).
6257
+ //
6258
+ // Non-generated contributes (e.g. languages, grammars, snippets, themes,
6259
+ // iconThemes, walkthroughs) go in an optional \`contributes.extra.json\` at the
6260
+ // project root. It is deep-merged into package.json#contributes on every run —
6261
+ // the keys gen.ts owns (commands, keybindings, menus.commandPalette,
6262
+ // viewsContainers, views) always win, everything else from extra is preserved.
5755
6263
 
5756
6264
  import * as fs from 'fs';
5757
6265
  import * as path from 'path';
@@ -5767,6 +6275,10 @@ const TREE_VIEWS_DIR = path.join(SRC, 'treeViews');
5767
6275
  const JOBS_DIR = path.join(SRC, 'jobs');
5768
6276
  const OUT = path.join(SRC, 'extension', '_registry.ts');
5769
6277
  const PKG_PATH = path.join(ROOT, 'package.json');
6278
+ const EXTRA_PATH = path.join(ROOT, 'contributes.extra.json');
6279
+
6280
+ /** Keys gen.ts owns — never overridden by contributes.extra.json. */
6281
+ const GEN_OWNED_KEYS = new Set(['commands', 'keybindings', 'viewsContainers', 'views']);
5770
6282
 
5771
6283
  interface Discovered {
5772
6284
  id: string;
@@ -5955,9 +6467,54 @@ function syncPackageJson(
5955
6467
  delete contributes.views;
5956
6468
  }
5957
6469
 
6470
+ mergeExtraContributes(contributes);
6471
+
5958
6472
  fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\\n');
5959
6473
  }
5960
6474
 
6475
+ /**
6476
+ * Deep-merge the optional \`contributes.extra.json\` (project root) into the
6477
+ * package's \`contributes\`. Use for any contribution point gen.ts doesn't
6478
+ * generate — languages, grammars, snippets, themes, iconThemes, walkthroughs…
6479
+ *
6480
+ * Rules:
6481
+ * - gen-owned keys (commands, keybindings, viewsContainers, views) are ignored
6482
+ * if present in extra — gen.ts stays authoritative for those.
6483
+ * - plain objects merge recursively; arrays and primitives from extra replace.
6484
+ *
6485
+ * NOTE: this is an inline copy of src/lib/contributesMerge.ts in the vsceasy
6486
+ * source (the script must run standalone). Keep the two in sync.
6487
+ */
6488
+ function mergeExtraContributes(contributes: Record<string, any>) {
6489
+ if (!fs.existsSync(EXTRA_PATH)) return;
6490
+ let extra: Record<string, any>;
6491
+ try {
6492
+ extra = JSON.parse(fs.readFileSync(EXTRA_PATH, 'utf8'));
6493
+ } catch (err) {
6494
+ console.warn(\`! Skipping contributes.extra.json — invalid JSON: \${(err as Error).message}\`);
6495
+ return;
6496
+ }
6497
+ if (!extra || typeof extra !== 'object') return;
6498
+ for (const [key, value] of Object.entries(extra)) {
6499
+ if (GEN_OWNED_KEYS.has(key)) continue;
6500
+ contributes[key] = deepMerge(contributes[key], value);
6501
+ }
6502
+ }
6503
+
6504
+ function isPlainObject(v: unknown): v is Record<string, any> {
6505
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
6506
+ }
6507
+
6508
+ function deepMerge(base: any, override: any): any {
6509
+ if (isPlainObject(base) && isPlainObject(override)) {
6510
+ const out: Record<string, any> = { ...base };
6511
+ for (const [k, v] of Object.entries(override)) out[k] = deepMerge(base[k], v);
6512
+ return out;
6513
+ }
6514
+ // arrays and primitives: override wins
6515
+ return override;
6516
+ }
6517
+
5961
6518
  function loadDef(file: string): {
5962
6519
  id?: string;
5963
6520
  title?: string;
@@ -8114,6 +8671,12 @@ var init_interactive = __esm(() => {
8114
8671
  function toTitle(s) {
8115
8672
  return s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
8116
8673
  }
8674
+ function normalizeType(v) {
8675
+ if (v === undefined || v === null || v === "")
8676
+ return;
8677
+ const s = String(v).trim().toLowerCase();
8678
+ return EXTENSION_TYPES.includes(s) ? s : undefined;
8679
+ }
8117
8680
  function toBool(v) {
8118
8681
  if (v === undefined || v === null || v === "")
8119
8682
  return;
@@ -8153,7 +8716,7 @@ Installing dependencies with ${pm}...
8153
8716
  ! ${pm} install failed — run it manually`);
8154
8717
  return false;
8155
8718
  }
8156
- var import_cli_maker, path4, import_child_process, createCommand, create_default;
8719
+ var import_cli_maker, path4, import_child_process, EXTENSION_TYPES, createCommand, create_default;
8157
8720
  var init_create = __esm(() => {
8158
8721
  init_scaffold();
8159
8722
  init_findProject();
@@ -8161,6 +8724,7 @@ var init_create = __esm(() => {
8161
8724
  import_cli_maker = __toESM(require_dist(), 1);
8162
8725
  path4 = __toESM(require("path"));
8163
8726
  import_child_process = require("child_process");
8727
+ EXTENSION_TYPES = ["ui", "language", "empty"];
8164
8728
  createCommand = {
8165
8729
  name: "create",
8166
8730
  description: "Scaffold a new VS Code extension project",
@@ -8169,8 +8733,9 @@ var init_create = __esm(() => {
8169
8733
  { name: "displayName", description: "Human-readable extension name", required: false, type: import_cli_maker.ParamType.Text },
8170
8734
  { name: "description", description: "Short description", required: false, type: import_cli_maker.ParamType.Text },
8171
8735
  { name: "publisher", description: "VS Code publisher id", required: false, type: import_cli_maker.ParamType.Text },
8172
- { name: "ui", description: "UI framework", required: false, type: import_cli_maker.ParamType.List, options: ["react"] },
8173
- { name: "preset", description: "Project preset (minimal = empty extension, full = panel + RPC sample)", required: false, type: import_cli_maker.ParamType.List, options: ["minimal", "full"] },
8736
+ { name: "type", description: "Extension type: ui (React webview + RPC), language (syntax/snippets/icon), empty (bare)", required: false, type: import_cli_maker.ParamType.List, options: ["ui", "language", "empty"] },
8737
+ { name: "ui", description: "UI framework (only for --type ui)", required: false, type: import_cli_maker.ParamType.List, options: ["react"] },
8738
+ { name: "preset", description: "UI preset (only for --type ui): minimal = empty extension, full = panel + RPC sample", required: false, type: import_cli_maker.ParamType.List, options: ["minimal", "full"] },
8174
8739
  { name: "dir", description: "Target directory (defaults to ./<name>)", required: false, type: import_cli_maker.ParamType.Text },
8175
8740
  { name: "git", description: "Initialize a git repository (skips the prompt)", required: false, type: import_cli_maker.ParamType.Boolean },
8176
8741
  { name: "install", description: "Install dependencies after scaffolding (skips the prompt)", required: false, type: import_cli_maker.ParamType.Boolean }
@@ -8178,6 +8743,15 @@ var init_create = __esm(() => {
8178
8743
  action: async (args) => {
8179
8744
  const name = args.name;
8180
8745
  const simpleName = name.replace(/^@[^/]+\//, "");
8746
+ const interactiveTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
8747
+ let type = normalizeType(args.type);
8748
+ if (!type) {
8749
+ type = interactiveTty ? await select("What kind of extension?", [
8750
+ { label: "UI / webview", value: "ui", hint: "React panel + typed RPC bridge" },
8751
+ { label: "Language support", value: "language", hint: "syntax highlighting, config, snippets, icon" },
8752
+ { label: "Empty", value: "empty", hint: "bare activate/deactivate, no UI" }
8753
+ ]) : "ui";
8754
+ }
8181
8755
  const ui = args.ui ?? "react";
8182
8756
  const preset = args.preset ?? "full";
8183
8757
  const targetDir = path4.resolve(process.cwd(), args.dir ?? simpleName);
@@ -8188,18 +8762,18 @@ var init_create = __esm(() => {
8188
8762
  description: args.description ?? `${simpleName} VS Code extension`,
8189
8763
  publisher: args.publisher ?? "your-publisher",
8190
8764
  ui,
8765
+ type,
8191
8766
  preset,
8192
8767
  targetDir,
8193
8768
  templatesRoot: findTemplatesRoot()
8194
8769
  });
8195
8770
  const rel = path4.relative(process.cwd(), targetDir) || ".";
8196
8771
  console.log(`
8197
- ✓ Created ${name} at ${rel}
8772
+ ✓ Created ${name} (${type}) at ${rel}
8198
8773
  `);
8199
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
8200
8774
  const gitFlag = toBool(args.git);
8201
8775
  const installFlag = toBool(args.install);
8202
- const wantGit = gitFlag ?? (interactive ? await confirm("Initialize a git repository?", true) : false);
8776
+ const wantGit = gitFlag ?? (interactiveTty ? await confirm("Initialize a git repository?", true) : false);
8203
8777
  if (wantGit) {
8204
8778
  if (which("git"))
8205
8779
  initGit(targetDir);
@@ -8207,7 +8781,7 @@ var init_create = __esm(() => {
8207
8781
  console.warn("! git not found — skipping repository init");
8208
8782
  }
8209
8783
  let pm = null;
8210
- const wantInstall = installFlag ?? (interactive ? await confirm("Install dependencies?", true) : false);
8784
+ const wantInstall = installFlag ?? (interactiveTty ? await confirm("Install dependencies?", true) : false);
8211
8785
  let installed = false;
8212
8786
  if (wantInstall) {
8213
8787
  pm = which("bun") ? "bun" : which("npm") ? "npm" : null;
@@ -8222,9 +8796,16 @@ Next steps:`);
8222
8796
  console.log(` cd ${rel}`);
8223
8797
  if (!installed)
8224
8798
  console.log(` ${run} install`);
8225
- console.log(` ${run} run launch # builds + opens Extension Development Host`);
8226
- console.log(` # or \`${run} run dev\` + F5 inside VS Code for watch mode
8799
+ if (type === "language") {
8800
+ console.log(` ${run} run gen # sync package.json#contributes from contributes.extra.json`);
8801
+ console.log(` ${run} run launch # opens Extension Development Host — open a matching file to see highlighting`);
8802
+ console.log(` # edit syntaxes/, snippets/, language-configuration.json to refine the language
8803
+ `);
8804
+ } else {
8805
+ console.log(` ${run} run launch # builds + opens Extension Development Host`);
8806
+ console.log(` # or \`${run} run dev\` + F5 inside VS Code for watch mode
8227
8807
  `);
8808
+ }
8228
8809
  } catch (err) {
8229
8810
  console.error(`
8230
8811
  ✗ Failed to scaffold: ${err.message}
@@ -8811,7 +9392,7 @@ var init_add4 = __esm(() => {
8811
9392
  init_config();
8812
9393
  fs9 = __toESM(require("fs"));
8813
9394
  path10 = __toESM(require("path"));
8814
- HELPER_KINDS = ["secrets", "config", "state", "notifications", "cache"];
9395
+ HELPER_KINDS = ["secrets", "config", "state", "notifications", "cache", "colorize"];
8815
9396
  });
8816
9397
 
8817
9398
  // src/lib/db/init.ts
@@ -8863,11 +9444,37 @@ function addModel(opts) {
8863
9444
  const pkField = explicitPk[0] ?? opts.fields.find((f) => f.name === "id") ?? opts.fields[0];
8864
9445
  const primaryKey = pkField.name;
8865
9446
  const indexes = opts.fields.filter((f) => f.indexed && f.name !== primaryKey).map((f) => f.name);
8866
- const target = path12.join(opts.projectRoot, "src", "models", `${Name}.ts`);
9447
+ const modelsDir = path12.join(opts.projectRoot, "src", "models");
9448
+ for (const f of opts.fields) {
9449
+ if (!f.relation)
9450
+ continue;
9451
+ if (f.relation.model === Name) {} else if (!fs11.existsSync(path12.join(modelsDir, `${f.relation.model}.ts`))) {
9452
+ throw new Error(`Field "${f.name}" references model "${f.relation.model}", but src/models/${f.relation.model}.ts does not exist. ` + `Run \`vsceasy model add --name ${f.relation.model}\` first.`);
9453
+ }
9454
+ }
9455
+ const target = path12.join(modelsDir, `${Name}.ts`);
8867
9456
  assertNoOverwrite(opts.projectRoot, target, "Model");
8868
9457
  const tpl = path12.join(opts.templatesRoot, "_generators", "model", "model.ts.tpl");
8869
- const fieldLines = opts.fields.map((f) => ` ${f.name}${f.optional ? "?" : ""}: ${f.type};`).join(`
8870
- `);
9458
+ const fkName = (f) => f.relation ? `${f.name}Id` : f.name;
9459
+ const fieldLines = opts.fields.map((f) => {
9460
+ const ts = f.relation ? "string" : f.type;
9461
+ const note = f.relation ? ` // → ${f.relation.model}` : "";
9462
+ return ` ${fkName(f)}${f.optional ? "?" : ""}: ${ts};${note}`;
9463
+ }).join(`
9464
+ `);
9465
+ const relFields = opts.fields.filter((f) => f.relation);
9466
+ const relationsBlock = relFields.length ? `
9467
+
9468
+ /** Relation metadata — used by \`vsceasy crud add\` to populate pickers. */
9469
+ ` + `export const ${Name}Relations = {
9470
+ ` + relFields.map((f) => {
9471
+ const r = f.relation;
9472
+ const lbl = r.label ? `, label: '${r.label}'` : "";
9473
+ return ` ${fkName(f)}: { model: '${r.model}'${lbl} },`;
9474
+ }).join(`
9475
+ `) + `
9476
+ } as const;
9477
+ ` : "";
8871
9478
  const vars = {
8872
9479
  name,
8873
9480
  Name,
@@ -8876,11 +9483,12 @@ function addModel(opts) {
8876
9483
  primaryKey,
8877
9484
  fieldLines,
8878
9485
  indexesLine: indexes.length ? `
8879
- indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : ""
9486
+ indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : "",
9487
+ relationsBlock
8880
9488
  };
8881
9489
  fs11.mkdirSync(path12.dirname(target), { recursive: true });
8882
9490
  fs11.writeFileSync(target, substitute(fs11.readFileSync(tpl, "utf8"), vars));
8883
- return { created: [target], primaryKey, indexes };
9491
+ return { created: [target], primaryKey, indexes, relations: relFields.map((f) => ({ field: fkName(f), ...f.relation })) };
8884
9492
  }
8885
9493
  function normalizeCamel3(s) {
8886
9494
  const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
@@ -8899,7 +9507,27 @@ var init_add5 = __esm(() => {
8899
9507
 
8900
9508
  // src/lib/model/parseFields.ts
8901
9509
  function parseFieldsSpec(spec) {
8902
- return spec.split(",").map((s) => s.trim()).filter(Boolean).map(parseFieldLine);
9510
+ return splitTopLevel(spec).map((s) => s.trim()).filter(Boolean).map(parseFieldLine);
9511
+ }
9512
+ function splitTopLevel(spec) {
9513
+ const out = [];
9514
+ let depth = 0;
9515
+ let cur = "";
9516
+ for (const ch of spec) {
9517
+ if (ch === "(")
9518
+ depth++;
9519
+ else if (ch === ")")
9520
+ depth = Math.max(0, depth - 1);
9521
+ if (ch === "," && depth === 0) {
9522
+ out.push(cur);
9523
+ cur = "";
9524
+ continue;
9525
+ }
9526
+ cur += ch;
9527
+ }
9528
+ if (cur.trim())
9529
+ out.push(cur);
9530
+ return out;
8903
9531
  }
8904
9532
  function parseFieldLine(raw) {
8905
9533
  const line = raw.trim();
@@ -8917,6 +9545,10 @@ function parseFieldLine(raw) {
8917
9545
  optional = true;
8918
9546
  name = name.slice(0, -1);
8919
9547
  }
9548
+ const relation = parseRef(type);
9549
+ if (relation) {
9550
+ return { name, type: "ref", optional, relation };
9551
+ }
8920
9552
  let primaryKey = false;
8921
9553
  let indexed = false;
8922
9554
  while (type.endsWith("!") || type.endsWith("@")) {
@@ -8934,6 +9566,14 @@ function parseFieldLine(raw) {
8934
9566
  throw new Error(`Field "${raw}" has no type after flags.`);
8935
9567
  return { name, type, optional, primaryKey, indexed };
8936
9568
  }
9569
+ function parseRef(type) {
9570
+ const m = /^ref\s*\(\s*([A-Za-z][A-Za-z0-9_]*)\s*(?:,\s*label\s*=\s*([A-Za-z][A-Za-z0-9_]*)\s*)?\)$/.exec(type.trim());
9571
+ if (!m)
9572
+ return null;
9573
+ const model = m[1];
9574
+ const label = m[2];
9575
+ return label ? { model, label } : { model };
9576
+ }
8937
9577
 
8938
9578
  // src/lib/wizard/run.ts
8939
9579
  async function runWizard(opts = {}) {
@@ -9589,6 +10229,9 @@ function runDoctor(opts) {
9589
10229
  results.push(...checkStatusBars(root));
9590
10230
  results.push(...checkSubpanels(root));
9591
10231
  results.push(checkContributesSync(root, pkg));
10232
+ const langCheck = checkLanguageAssets(root, pkg);
10233
+ if (langCheck)
10234
+ results.push(langCheck);
9592
10235
  results.push(checkActivationEvents(pkg));
9593
10236
  results.push(checkMarketplaceIcon(root, pkg));
9594
10237
  results.push(checkGenScript(root));
@@ -9851,6 +10494,50 @@ function checkContributesSync(root, pkg) {
9851
10494
  details: stale
9852
10495
  };
9853
10496
  }
10497
+ function checkLanguageAssets(root, pkg) {
10498
+ const sources = [pkg?.contributes];
10499
+ const extraPath = path14.join(root, "contributes.extra.json");
10500
+ if (fs12.existsSync(extraPath)) {
10501
+ try {
10502
+ sources.push(JSON.parse(fs12.readFileSync(extraPath, "utf8")));
10503
+ } catch {
10504
+ return {
10505
+ id: "language",
10506
+ level: "error",
10507
+ message: "contributes.extra.json is not valid JSON"
10508
+ };
10509
+ }
10510
+ }
10511
+ const refs = new Set;
10512
+ for (const c of sources) {
10513
+ if (!c)
10514
+ continue;
10515
+ for (const l of c.languages ?? [])
10516
+ if (l.configuration)
10517
+ refs.add(l.configuration);
10518
+ for (const g of c.grammars ?? [])
10519
+ if (g.path)
10520
+ refs.add(g.path);
10521
+ for (const s of c.snippets ?? [])
10522
+ if (s.path)
10523
+ refs.add(s.path);
10524
+ for (const t of c.iconThemes ?? [])
10525
+ if (t.path)
10526
+ refs.add(t.path);
10527
+ }
10528
+ if (refs.size === 0)
10529
+ return null;
10530
+ const missing = [...refs].filter((rel) => !fs12.existsSync(path14.join(root, rel)));
10531
+ if (missing.length === 0) {
10532
+ return { id: "language", level: "ok", message: `language ${refs.size} asset(s) present` };
10533
+ }
10534
+ return {
10535
+ id: "language",
10536
+ level: "error",
10537
+ message: `language: ${missing.length} referenced asset(s) missing`,
10538
+ details: missing
10539
+ };
10540
+ }
9854
10541
  function checkGitignore(root) {
9855
10542
  const file = path14.join(root, ".gitignore");
9856
10543
  const required = ["dist", "node_modules"];
@@ -12036,7 +12723,7 @@ var init_add18 = __esm(() => {
12036
12723
  path33 = __toESM(require("path"));
12037
12724
  addHelperCommand = {
12038
12725
  name: "add",
12039
- description: "Generate a typed helper (secrets, config, state, notifications) into src/helpers/",
12726
+ description: "Generate a typed helper (secrets, config, state, notifications, cache, colorize) into src/helpers/",
12040
12727
  params: [
12041
12728
  {
12042
12729
  name: "kind",
@@ -12082,6 +12769,15 @@ var init_add18 = __esm(() => {
12082
12769
  import { createCache } from '../helpers/cache';
12083
12770
  const cache = createCache<User>({ ttlMs: 60_000, max: 200 });
12084
12771
  const u = await cache.wrap('user:' + id, () => orm(User).findById(id));
12772
+ `);
12773
+ } else if (args.kind === "colorize") {
12774
+ console.log(`
12775
+ Usage (auto-apply scoped token colors on activate):
12776
+ import { applyTokenColors } from '../helpers/colorize';
12777
+ await applyTokenColors('source.mylang', [
12778
+ { scope: 'entity.name.section.mylang', settings: { foreground: '#e6c07b', fontStyle: 'bold' } },
12779
+ ]);
12780
+ // add a "<prefix>.colorize" boolean to contributes.configuration to opt out
12085
12781
  `);
12086
12782
  } else {
12087
12783
  console.log("");
@@ -12389,9 +13085,20 @@ var init_init4 = __esm(() => {
12389
13085
  });
12390
13086
 
12391
13087
  // src/commands/model/add.ts
12392
- async function promptFieldsLoop() {
13088
+ function existingModels(projectRoot) {
13089
+ const dir = path38.join(projectRoot, "src", "models");
13090
+ if (!fs25.existsSync(dir))
13091
+ return [];
13092
+ return fs25.readdirSync(dir).filter((f) => /\.ts$/.test(f) && !f.endsWith(".crud.ts")).map((f) => f.replace(/\.ts$/, "")).sort();
13093
+ }
13094
+ async function promptFieldsLoop(projectRoot) {
12393
13095
  console.log("\n Field syntax: `name:type` — flags: `!` (primary) `@` (indexed) `?` after name (optional)");
12394
13096
  console.log(" Examples: `id:string!`, `email?:string@`, `score:number`");
13097
+ console.log(" Relation: `category:ref(Category)` — FK + dropdown of the related model.");
13098
+ const models = existingModels(projectRoot);
13099
+ if (models.length) {
13100
+ console.log(` Models you can relate to: ${models.join(", ")}`);
13101
+ }
12395
13102
  console.log(` Empty line finishes.
12396
13103
  `);
12397
13104
  const fields = [];
@@ -12414,24 +13121,26 @@ function pascal4(s) {
12414
13121
  function plural(s) {
12415
13122
  return `${pascal4(s)}s`;
12416
13123
  }
12417
- var import_cli_maker17, path38, FIELD_HELP, addModelCommand, add_default10;
13124
+ var import_cli_maker17, fs25, path38, FIELD_HELP, addModelCommand, add_default10;
12418
13125
  var init_add21 = __esm(() => {
12419
13126
  init_add5();
12420
13127
  init_init();
12421
13128
  init_findProject();
12422
13129
  import_cli_maker17 = __toESM(require_dist(), 1);
13130
+ fs25 = __toESM(require("fs"));
12423
13131
  path38 = __toESM(require("path"));
12424
13132
  FIELD_HELP = [
12425
13133
  "",
12426
13134
  "Interactive field loop: enter `name:type` per line, empty line to finish.",
12427
13135
  " Examples:",
12428
- " id:string! — `!` after type = primary key",
12429
- " name:string — required field",
12430
- " email?:string — `?` after name = optional",
12431
- " createdAt:number — number type",
12432
- ' role:"a"|"b" — literal union',
12433
- " tag:string@ — `@` after type = indexed",
12434
- " score:number!@ — primary key + indexed",
13136
+ " id:string! — `!` after type = primary key",
13137
+ " name:string — required field",
13138
+ " email?:string — `?` after name = optional",
13139
+ " createdAt:number — number type",
13140
+ ' role:"a"|"b" — literal union',
13141
+ " tag:string@ — `@` after type = indexed",
13142
+ " score:number!@ — primary key + indexed",
13143
+ " category:ref(Category) — relation → FK categoryId + dropdown of Category",
12435
13144
  "",
12436
13145
  "If no `!` is set, `id` (or first field) becomes the primary key."
12437
13146
  ].join(`
@@ -12471,7 +13180,7 @@ var init_add21 = __esm(() => {
12471
13180
  if (args.fields) {
12472
13181
  fields = parseFieldsSpec(String(args.fields));
12473
13182
  } else {
12474
- fields = await promptFieldsLoop();
13183
+ fields = await promptFieldsLoop(projectRoot);
12475
13184
  }
12476
13185
  if (fields.length === 0) {
12477
13186
  throw new Error("At least one field is required.");
@@ -12490,6 +13199,12 @@ var init_add21 = __esm(() => {
12490
13199
  `);
12491
13200
  for (const f of result.created)
12492
13201
  console.log(` + ${rel(f)}`);
13202
+ if (result.relations.length) {
13203
+ console.log("");
13204
+ for (const r of result.relations) {
13205
+ console.log(` ↪ ${r.field} → ${r.model}${r.label ? ` (label: ${r.label})` : ""} — crud will render a dropdown`);
13206
+ }
13207
+ }
12493
13208
  console.log(`
12494
13209
  Usage:
12495
13210
  import { ${plural(args.name)}Repo } from '../models/${pascal4(args.name)}';
@@ -12508,9 +13223,9 @@ var init_add21 = __esm(() => {
12508
13223
 
12509
13224
  // src/lib/crud/parseModel.ts
12510
13225
  function parseModelFile(file) {
12511
- if (!fs25.existsSync(file))
13226
+ if (!fs26.existsSync(file))
12512
13227
  throw new Error(`Model file not found: ${file}`);
12513
- const src = fs25.readFileSync(file, "utf8");
13228
+ const src = fs26.readFileSync(file, "utf8");
12514
13229
  const ifaceMatch = /export\s+interface\s+([A-Z][A-Za-z0-9_]*)\s*\{([\s\S]*?)\n\}/.exec(src);
12515
13230
  if (!ifaceMatch) {
12516
13231
  throw new Error(`Model "${path39.basename(file)}" does not declare \`export interface\`.`);
@@ -12541,7 +13256,26 @@ function parseModelFile(file) {
12541
13256
  indexes.push(m[1]);
12542
13257
  }
12543
13258
  const id = path39.basename(file).replace(/\.(ts|tsx)$/, "");
12544
- return { name, id, plural: plural2, collection, primaryKey: pk, indexes, fields, path: file };
13259
+ const relations = parseRelations(src, name);
13260
+ for (const f of fields) {
13261
+ const r = relations[f.name];
13262
+ if (r)
13263
+ f.relation = r;
13264
+ }
13265
+ return { name, id, plural: plural2, collection, primaryKey: pk, indexes, fields, relations, path: file };
13266
+ }
13267
+ function parseRelations(src, name) {
13268
+ const out = {};
13269
+ const block = new RegExp(`export\\s+const\\s+${name}Relations\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as\\s+const`, "m").exec(src);
13270
+ if (!block)
13271
+ return out;
13272
+ const body = block[1];
13273
+ const re = /([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\{\s*model\s*:\s*['"`]([^'"`]+)['"`]\s*(?:,\s*label\s*:\s*['"`]([^'"`]+)['"`]\s*)?\}/g;
13274
+ let m;
13275
+ while (m = re.exec(body)) {
13276
+ out[m[1]] = { field: m[1], model: m[2], label: m[3] };
13277
+ }
13278
+ return out;
12545
13279
  }
12546
13280
  function parseInterfaceBody(body) {
12547
13281
  const fields = [];
@@ -12585,19 +13319,19 @@ function inferInputSpec(type) {
12585
13319
  return { kind: "text" };
12586
13320
  return { kind: "text" };
12587
13321
  }
12588
- var fs25, path39;
13322
+ var fs26, path39;
12589
13323
  var init_parseModel = __esm(() => {
12590
- fs25 = __toESM(require("fs"));
13324
+ fs26 = __toESM(require("fs"));
12591
13325
  path39 = __toESM(require("path"));
12592
13326
  });
12593
13327
 
12594
13328
  // src/lib/crud/crudConfig.ts
12595
13329
  function readCrudConfig(projectRoot, modelName) {
12596
13330
  const file = path40.join(projectRoot, "src", "models", `${modelName}.crud.ts`);
12597
- if (!fs26.existsSync(file))
13331
+ if (!fs27.existsSync(file))
12598
13332
  return {};
12599
13333
  try {
12600
- const src = fs26.readFileSync(file, "utf8");
13334
+ const src = fs27.readFileSync(file, "utf8");
12601
13335
  return parseConfigSource(src);
12602
13336
  } catch {
12603
13337
  return {};
@@ -12632,9 +13366,9 @@ function parseConfigSource(src) {
12632
13366
  return {};
12633
13367
  }
12634
13368
  }
12635
- var fs26, path40;
13369
+ var fs27, path40;
12636
13370
  var init_crudConfig = __esm(() => {
12637
- fs26 = __toESM(require("fs"));
13371
+ fs27 = __toESM(require("fs"));
12638
13372
  path40 = __toESM(require("path"));
12639
13373
  });
12640
13374
 
@@ -12665,7 +13399,7 @@ function addCrud(opts) {
12665
13399
  assertNoOverwrite(opts.projectRoot, formPanelPath, "Form panel");
12666
13400
  assertNoOverwrite(opts.projectRoot, listWebDir, "List webview dir");
12667
13401
  assertNoOverwrite(opts.projectRoot, formWebDir, "Form webview dir");
12668
- const pkg = JSON.parse(fs27.readFileSync(path41.join(opts.projectRoot, "package.json"), "utf8"));
13402
+ const pkg = JSON.parse(fs28.readFileSync(path41.join(opts.projectRoot, "package.json"), "utf8"));
12669
13403
  const prefix = pkg.vsceasy?.commandPrefix ?? pkg.name?.replace(/^@[^/]+\//, "").replace(/[^a-zA-Z0-9]+/g, "") ?? "ext";
12670
13404
  const baseVars = {
12671
13405
  Name: model.name,
@@ -12678,13 +13412,27 @@ function addCrud(opts) {
12678
13412
  formId,
12679
13413
  prefix
12680
13414
  };
13415
+ const modelsDir = path41.dirname(modelFile);
13416
+ const relations = visible.filter((f) => f.relation).map((f) => {
13417
+ const r = f.relation;
13418
+ const related = parseModelFile(path41.join(modelsDir, `${r.model}.ts`));
13419
+ const labelField = r.label ?? firstStringField(related) ?? related.primaryKey;
13420
+ return {
13421
+ field: f.name,
13422
+ model: r.model,
13423
+ plural: related.plural,
13424
+ pk: related.primaryKey,
13425
+ labelField
13426
+ };
13427
+ });
13428
+ const relVars = buildRelationVars(relations);
12681
13429
  const created = [];
12682
13430
  const modified = [];
12683
13431
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "service.ts.tpl"), servicePath, baseVars, created);
12684
13432
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formNav.ts.tpl"), path41.join(opts.projectRoot, "src", "services", `${camelLower(model.name)}FormNav.ts`), baseVars, created);
12685
13433
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "listPanel.ts.tpl"), listPanelPath, baseVars, created);
12686
- writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, baseVars, created);
12687
- fs27.mkdirSync(listWebDir, { recursive: true });
13434
+ writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, { ...baseVars, ...relVars }, created);
13435
+ fs28.mkdirSync(listWebDir, { recursive: true });
12688
13436
  const listVars = {
12689
13437
  ...baseVars,
12690
13438
  listHeaderCells: visible.map((f) => ` <th style={{ padding: '6px 8px' }}>${escapeJsx(label(f, cfg))}</th>`).join(`
@@ -12695,14 +13443,14 @@ function addCrud(opts) {
12695
13443
  };
12696
13444
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "listApp.tsx.tpl"), path41.join(listWebDir, "App.tsx"), listVars, created);
12697
13445
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path41.join(listWebDir, "main.tsx"), baseVars, created);
12698
- fs27.mkdirSync(formWebDir, { recursive: true });
13446
+ fs28.mkdirSync(formWebDir, { recursive: true });
12699
13447
  const formInputs = visible.filter((f) => !cfg.fields?.[f.name]?.hideInForm).map((f) => renderInput(f, cfg.fields?.[f.name])).join(`
12700
13448
  `);
12701
13449
  const emptyLit = buildEmptyFormLiteral(visible, cfg);
12702
- writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path41.join(formWebDir, "App.tsx"), { ...baseVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
13450
+ writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path41.join(formWebDir, "App.tsx"), { ...baseVars, ...relVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
12703
13451
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path41.join(formWebDir, "main.tsx"), baseVars, created);
12704
13452
  appendApi3(apiPath, listApiName, model, created, modified);
12705
- appendApiForm(apiPath, formApiName, model, created, modified);
13453
+ appendApiForm(apiPath, formApiName, model, created, modified, relations.length > 0);
12706
13454
  let menuInfo;
12707
13455
  if (opts.menu && opts.menu !== "none") {
12708
13456
  menuInfo = wireMenu(opts, model, cfg, listId, formId);
@@ -12713,10 +13461,10 @@ function addCrud(opts) {
12713
13461
  return { created, modified, menu: menuInfo, genRan };
12714
13462
  }
12715
13463
  function writeFromTpl(tpl, target, vars, created) {
12716
- if (!fs27.existsSync(tpl))
13464
+ if (!fs28.existsSync(tpl))
12717
13465
  throw new Error(`CRUD template missing: ${tpl}`);
12718
- fs27.mkdirSync(path41.dirname(target), { recursive: true });
12719
- fs27.writeFileSync(target, substitute(fs27.readFileSync(tpl, "utf8"), vars));
13466
+ fs28.mkdirSync(path41.dirname(target), { recursive: true });
13467
+ fs28.writeFileSync(target, substitute(fs28.readFileSync(tpl, "utf8"), vars));
12720
13468
  created.push(target);
12721
13469
  }
12722
13470
  function orderFields(fields, order) {
@@ -12756,6 +13504,14 @@ function renderInput(field, override) {
12756
13504
  ` + ` <span style={{ opacity: 0.8 }}>${labelText}</span>
12757
13505
  ` + `${input}
12758
13506
  ` + ` </label>`;
13507
+ if (field.relation) {
13508
+ return wrap(` <select${required} value={(form.${name} as any) ?? ''} onChange={(e) => onChange('${name}', e.target.value as any)}>
13509
+ ` + ` <option value=""></option>
13510
+ ` + ` {(relOptions['${name}'] ?? []).map((o) => (
13511
+ ` + ` <option key={o.value} value={o.value}>{o.label}</option>
13512
+ ` + ` ))}
13513
+ ` + ` </select>`);
13514
+ }
12759
13515
  switch (spec.kind) {
12760
13516
  case "number":
12761
13517
  return wrap(` <input type="number"${placeholderAttr}${required} value={form.${name} as any ?? ''} onChange={(e) => onChange('${name}', e.target.value === '' ? undefined : Number(e.target.value))} />`);
@@ -12806,45 +13562,47 @@ export interface ${apiName} {
12806
13562
  ensureImport(apiPath, model.name);
12807
13563
  appendIfMissing(apiPath, apiName, sig, created, modified);
12808
13564
  }
12809
- function appendApiForm(apiPath, apiName, model, created, modified) {
13565
+ function appendApiForm(apiPath, apiName, model, created, modified, hasRelations) {
13566
+ const optionsLine = hasRelations ? ` options(): Promise<Record<string, { value: string; label: string }[]>>;
13567
+ ` : "";
12810
13568
  const sig = `
12811
13569
  export interface ${apiName} {
12812
13570
  ` + ` pendingId(): Promise<${model.name}['${model.primaryKey}'] | null>;
12813
13571
  ` + ` get(id: ${model.name}['${model.primaryKey}'] | null): Promise<${model.name} | null>;
12814
13572
  ` + ` save(row: ${model.name}): Promise<${model.name}>;
12815
- ` + ` cancel(): Promise<void>;
13573
+ ` + optionsLine + ` cancel(): Promise<void>;
12816
13574
  ` + `}
12817
13575
  `;
12818
13576
  ensureImport(apiPath, model.name);
12819
13577
  appendIfMissing(apiPath, apiName, sig, created, modified);
12820
13578
  }
12821
13579
  function ensureImport(apiPath, modelName) {
12822
- if (!fs27.existsSync(apiPath)) {
12823
- fs27.mkdirSync(path41.dirname(apiPath), { recursive: true });
12824
- fs27.writeFileSync(apiPath, `import type { ${modelName} } from '../models/${modelName}';
13580
+ if (!fs28.existsSync(apiPath)) {
13581
+ fs28.mkdirSync(path41.dirname(apiPath), { recursive: true });
13582
+ fs28.writeFileSync(apiPath, `import type { ${modelName} } from '../models/${modelName}';
12825
13583
  `);
12826
13584
  return;
12827
13585
  }
12828
- let src = fs27.readFileSync(apiPath, "utf8");
13586
+ let src = fs28.readFileSync(apiPath, "utf8");
12829
13587
  if (new RegExp(`from\\s+['"]\\.\\./models/${modelName}['"]`).test(src))
12830
13588
  return;
12831
13589
  src = `import type { ${modelName} } from '../models/${modelName}';
12832
13590
  ` + src;
12833
- fs27.writeFileSync(apiPath, src);
13591
+ fs28.writeFileSync(apiPath, src);
12834
13592
  }
12835
13593
  function appendIfMissing(apiPath, apiName, block, created, modified) {
12836
- if (!fs27.existsSync(apiPath)) {
12837
- fs27.writeFileSync(apiPath, block.trimStart());
13594
+ if (!fs28.existsSync(apiPath)) {
13595
+ fs28.writeFileSync(apiPath, block.trimStart());
12838
13596
  created.push(apiPath);
12839
13597
  return;
12840
13598
  }
12841
- const src = fs27.readFileSync(apiPath, "utf8");
13599
+ const src = fs28.readFileSync(apiPath, "utf8");
12842
13600
  if (new RegExp(`\\bexport\\s+interface\\s+${apiName}\\b`).test(src))
12843
13601
  return;
12844
13602
  const sep = src.endsWith(`
12845
13603
  `) ? "" : `
12846
13604
  `;
12847
- fs27.writeFileSync(apiPath, src + sep + block);
13605
+ fs28.writeFileSync(apiPath, src + sep + block);
12848
13606
  if (!modified.includes(apiPath))
12849
13607
  modified.push(apiPath);
12850
13608
  }
@@ -12900,7 +13658,31 @@ function runGen11(cwd) {
12900
13658
  function which12(cmd) {
12901
13659
  return import_child_process13.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
12902
13660
  }
12903
- var fs27, path41, import_child_process13;
13661
+ function firstStringField(model) {
13662
+ return model.fields.find((f) => f.type.trim() === "string" && f.name !== model.primaryKey)?.name;
13663
+ }
13664
+ function buildRelationVars(relations) {
13665
+ if (relations.length === 0) {
13666
+ return { relationImports: "", relationOptionsHandler: "", relationOptionsState: "", relationOptionsLoad: "" };
13667
+ }
13668
+ const uniqueRepos = [...new Map(relations.map((r) => [r.plural, r])).values()];
13669
+ const relationImports = uniqueRepos.map((r) => `import { ${r.plural}Repo } from '../models/${r.model}';`).join(`
13670
+ `) + `
13671
+ `;
13672
+ const handlerLines = relations.map((r) => ` ${r.field}: (await ${r.plural}Repo().findMany()).map((x) => ({ value: String(x.${r.pk}), label: String(x.${r.labelField}) })),`).join(`
13673
+ `);
13674
+ const relationOptionsHandler = ` async options() {
13675
+ ` + ` return {
13676
+ ` + `${handlerLines}
13677
+ ` + ` };
13678
+ ` + ` },
13679
+ `;
13680
+ const relationOptionsState = ` const [relOptions, setRelOptions] = useState<Record<string, { value: string; label: string }[]>>({});`;
13681
+ const relationOptionsLoad = `
13682
+ useEffect(() => { void api.options().then(setRelOptions); }, []);`;
13683
+ return { relationImports, relationOptionsHandler, relationOptionsState, relationOptionsLoad };
13684
+ }
13685
+ var fs28, path41, import_child_process13;
12904
13686
  var init_add22 = __esm(() => {
12905
13687
  init_scaffold();
12906
13688
  init_validate();
@@ -12908,20 +13690,20 @@ var init_add22 = __esm(() => {
12908
13690
  init_crudConfig();
12909
13691
  init_add7();
12910
13692
  init_edit();
12911
- fs27 = __toESM(require("fs"));
13693
+ fs28 = __toESM(require("fs"));
12912
13694
  path41 = __toESM(require("path"));
12913
13695
  import_child_process13 = require("child_process");
12914
13696
  });
12915
13697
 
12916
13698
  // src/commands/crud/add.ts
12917
- var import_cli_maker18, path42, fs28, NONE_SENTINEL2 = "(no menu)", NEW_SENTINEL = "(create new menu)", addCrudCommand, add_default11;
13699
+ var import_cli_maker18, path42, fs29, NONE_SENTINEL2 = "(no menu)", NEW_SENTINEL = "(create new menu)", addCrudCommand, add_default11;
12918
13700
  var init_add23 = __esm(() => {
12919
13701
  init_add22();
12920
13702
  init_edit();
12921
13703
  init_findProject();
12922
13704
  import_cli_maker18 = __toESM(require_dist(), 1);
12923
13705
  path42 = __toESM(require("path"));
12924
- fs28 = __toESM(require("fs"));
13706
+ fs29 = __toESM(require("fs"));
12925
13707
  addCrudCommand = {
12926
13708
  name: "add",
12927
13709
  description: "Generate full CRUD (service + list panel + form panel + RPC + optional menu) for an existing model. Rails-style scaffolding.",
@@ -12933,10 +13715,10 @@ var init_add23 = __esm(() => {
12933
13715
  type: import_cli_maker18.ParamType.List,
12934
13716
  optionsLoader: () => {
12935
13717
  const dir = path42.join(findProjectRoot(), "src", "models");
12936
- if (!fs28.existsSync(dir)) {
13718
+ if (!fs29.existsSync(dir)) {
12937
13719
  throw new Error("No models found. Run `vsceasy db init && vsceasy model add --name User` first.");
12938
13720
  }
12939
- const names = fs28.readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".crud.ts")).map((f) => f.replace(/\.ts$/, "")).sort();
13721
+ const names = fs29.readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".crud.ts")).map((f) => f.replace(/\.ts$/, "")).sort();
12940
13722
  if (names.length === 0) {
12941
13723
  throw new Error("No models found in src/models/. Run `vsceasy model add --name User` first.");
12942
13724
  }
@@ -13066,14 +13848,14 @@ function addStore(opts) {
13066
13848
  const storeTs = path44.join(opts.projectRoot, "src", "stores", `${name}.ts`);
13067
13849
  assertNoOverwrite(opts.projectRoot, storeTs, "Store");
13068
13850
  const tplPath = path44.join(opts.templatesRoot, "_generators", "store", "store.ts.tpl");
13069
- const body = substitute(fs29.readFileSync(tplPath, "utf8"), {
13851
+ const body = substitute(fs30.readFileSync(tplPath, "utf8"), {
13070
13852
  name,
13071
13853
  type: TS_TYPE[type],
13072
13854
  initial,
13073
13855
  example: EXAMPLE[type]
13074
13856
  });
13075
- fs29.mkdirSync(path44.dirname(storeTs), { recursive: true });
13076
- fs29.writeFileSync(storeTs, body);
13857
+ fs30.mkdirSync(path44.dirname(storeTs), { recursive: true });
13858
+ fs30.writeFileSync(storeTs, body);
13077
13859
  return { created: [storeTs] };
13078
13860
  }
13079
13861
  function normalizeCamel9(s) {
@@ -13082,11 +13864,11 @@ function normalizeCamel9(s) {
13082
13864
  return "";
13083
13865
  return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
13084
13866
  }
13085
- var fs29, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
13867
+ var fs30, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
13086
13868
  var init_add25 = __esm(() => {
13087
13869
  init_scaffold();
13088
13870
  init_validate();
13089
- fs29 = __toESM(require("fs"));
13871
+ fs30 = __toESM(require("fs"));
13090
13872
  path44 = __toESM(require("path"));
13091
13873
  DEFAULT_INITIAL = {
13092
13874
  number: "0",
@@ -13242,7 +14024,7 @@ var init_cli = __esm(() => {
13242
14024
  import_cli_maker21 = __toESM(require_dist(), 1);
13243
14025
  cli = new import_cli_maker21.CLI("vsceasy", "Build VS Code extensions fast — React UI + typed RPC bridge + zero-config build.", {
13244
14026
  interactive: true,
13245
- version: "0.1.8",
14027
+ version: "0.1.10",
13246
14028
  introAnimation: {
13247
14029
  enabled: true,
13248
14030
  preset: "retro-space",