@defold-typescript/cli 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import type { ScriptHookName } from "@defold-typescript/types";
5
+ import { DEBUG_LAUNCHER_SOURCE, debugLaunchConfig, VSCODE_LAUNCH_CONTENT } from "./debug-launcher";
4
6
  import { CURRENT_STABLE_DEFOLD_VERSION } from "./defold-version";
7
+ import { mergeMiseToml } from "./mise-scaffold";
5
8
  import {
6
9
  detectScriptKinds,
7
10
  type ScriptKind,
@@ -73,7 +76,7 @@ const BIOME_JSON_CONTENT = {
73
76
  };
74
77
 
75
78
  const VSCODE_EXTENSIONS_CONTENT = {
76
- recommendations: ["sumneko.lua", "astronachos.defold"],
79
+ recommendations: ["sumneko.lua", "astronachos.defold", "tomblind.local-lua-debugger-vscode"],
77
80
  unwantedRecommendations: ["johnnymorganz.luau-lsp"],
78
81
  };
79
82
 
@@ -81,6 +84,144 @@ const VSCODE_SETTINGS_CONTENT = {
81
84
  "Lua.workspace.ignoreDir": ["src"],
82
85
  };
83
86
 
87
+ interface VscodeSnippet {
88
+ scope: string;
89
+ prefix: string;
90
+ body: string[];
91
+ description: string;
92
+ }
93
+
94
+ // One learn-more comment and one parameter list per lifecycle hook, keyed by
95
+ // `ScriptHookName` so a hook added to the types fails to compile here until both
96
+ // maps gain an entry (`satisfies` exhaustiveness — the type is derived from the
97
+ // canonical `SCRIPT_HOOK_NAMES`). The hook list is read off these keys rather
98
+ // than imported as a runtime value: the types package is type-only and not
99
+ // node-ESM-runnable, so the CLI bundle must not resolve it at runtime. `init` is
100
+ // special-cased by the body builders (it carries the return placeholder, not a
101
+ // `self` param), so its signature entry is unused but still required for
102
+ // exhaustiveness.
103
+ const HOOK_COMMENTS = {
104
+ init: "Initialize the component and return its state.",
105
+ update: "Update the component every frame; `dt` is the time step.",
106
+ fixed_update: "Update at the fixed physics time step.",
107
+ late_update: "Update every frame after `update`.",
108
+ on_message: "Handle an incoming message.",
109
+ on_input: "Handle input once input focus is acquired.",
110
+ final: "Clean up when the component is deleted.",
111
+ on_reload: "React to a hot reload of this script.",
112
+ } satisfies Record<ScriptHookName, string>;
113
+
114
+ const HOOK_SIGNATURES = {
115
+ init: "",
116
+ update: "self, dt",
117
+ fixed_update: "self, dt",
118
+ late_update: "self, dt",
119
+ on_message: "self, message_id, message, sender",
120
+ on_input: "self, action_id, action",
121
+ final: "self",
122
+ on_reload: "self",
123
+ } satisfies Record<ScriptHookName, string>;
124
+
125
+ const SNIPPET_HOOK_ORDER = Object.keys(HOOK_SIGNATURES) as ScriptHookName[];
126
+
127
+ // Emit every hook except `init` (the caller writes it with its return
128
+ // placeholder) as a commented `name(sig) {$N},` line. Render scripts pass
129
+ // includeOnInput=false because `RenderScriptHooks` omits `on_input`. Tab stops
130
+ // run sequentially from `startTabStop` across the hooks actually emitted.
131
+ function hookLines(includeOnInput: boolean, startTabStop: number): string[] {
132
+ const lines: string[] = [];
133
+ let tabStop = startTabStop;
134
+ for (const hook of SNIPPET_HOOK_ORDER) {
135
+ if (hook === "init") {
136
+ continue;
137
+ }
138
+ if (hook === "on_input" && !includeOnInput) {
139
+ continue;
140
+ }
141
+ lines.push(` // ${HOOK_COMMENTS[hook]}`);
142
+ lines.push(` ${hook}(${HOOK_SIGNATURES[hook]}) {$${tabStop}},`);
143
+ tabStop += 1;
144
+ }
145
+ return lines;
146
+ }
147
+
148
+ // Whole-file TS scaffolds mirroring the Defold editor's empty script/gui/render
149
+ // templates over the lifecycle factories. Two self-typing variants per kind:
150
+ // inline-self (TSelf inferred from `init`'s return) and typed-self (an explicit
151
+ // dummy `Self` placeholder). Hook order mirrors the Lua templates; render omits
152
+ // `on_input` because `RenderScriptHooks` does. The final `$0` lands inside `init`.
153
+ function inlineSnippetBody(factory: string, includeOnInput: boolean): string[] {
154
+ return [
155
+ `import { ${factory} } from "@defold-typescript/types";`,
156
+ "",
157
+ `export const script = ${factory}({`,
158
+ ` // ${HOOK_COMMENTS.init}`,
159
+ " init() {",
160
+ " return { $0 };",
161
+ " },",
162
+ ...hookLines(includeOnInput, 1),
163
+ "});",
164
+ ];
165
+ }
166
+
167
+ function typedSnippetBody(factory: string, includeOnInput: boolean): string[] {
168
+ return [
169
+ `import { ${factory} } from "@defold-typescript/types";`,
170
+ "",
171
+ "type Self = {",
172
+ " // Your script's state goes here.",
173
+ " $1",
174
+ "};",
175
+ "",
176
+ `export const script = ${factory}<Self>({`,
177
+ ` // ${HOOK_COMMENTS.init}`,
178
+ " init(): Self {",
179
+ " return { $0 };",
180
+ " },",
181
+ ...hookLines(includeOnInput, 2),
182
+ "});",
183
+ ];
184
+ }
185
+
186
+ const VSCODE_SNIPPETS_CONTENT: Record<string, VscodeSnippet> = {
187
+ "Defold script (inferred self)": {
188
+ scope: "typescript",
189
+ prefix: "defold-script",
190
+ body: inlineSnippetBody("defineScript", true),
191
+ description: "Empty Defold script; state inferred from init's return.",
192
+ },
193
+ "Defold script (typed self)": {
194
+ scope: "typescript",
195
+ prefix: "defold-script-typed",
196
+ body: typedSnippetBody("defineScript", true),
197
+ description: "Empty Defold script with an explicit Self type.",
198
+ },
199
+ "Defold GUI script (inferred self)": {
200
+ scope: "typescript",
201
+ prefix: "defold-gui",
202
+ body: inlineSnippetBody("defineGuiScript", true),
203
+ description: "Empty Defold GUI script; state inferred from init's return.",
204
+ },
205
+ "Defold GUI script (typed self)": {
206
+ scope: "typescript",
207
+ prefix: "defold-gui-typed",
208
+ body: typedSnippetBody("defineGuiScript", true),
209
+ description: "Empty Defold GUI script with an explicit Self type.",
210
+ },
211
+ "Defold render script (inferred self)": {
212
+ scope: "typescript",
213
+ prefix: "defold-render",
214
+ body: inlineSnippetBody("defineRenderScript", false),
215
+ description: "Empty Defold render script; state inferred from init's return.",
216
+ },
217
+ "Defold render script (typed self)": {
218
+ scope: "typescript",
219
+ prefix: "defold-render-typed",
220
+ body: typedSnippetBody("defineRenderScript", false),
221
+ description: "Empty Defold render script with an explicit Self type.",
222
+ },
223
+ };
224
+
84
225
  const MAIN_TS_CONTENT = `export function init(): void {
85
226
  const start = vmath.vector3(0, 0, 0);
86
227
  msg.post("main:/hero", "spawn", { start });
@@ -181,6 +322,13 @@ function writeBiome(cwd: string, written: string[]): void {
181
322
  written.push("biome.json");
182
323
  }
183
324
 
325
+ function writeMiseTasks(cwd: string, written: string[]): void {
326
+ const misePath = path.join(cwd, "mise.toml");
327
+ const existing = existsSync(misePath) ? readFileSync(misePath, "utf8") : undefined;
328
+ writeFileSync(misePath, mergeMiseToml(existing));
329
+ written.push("mise.toml");
330
+ }
331
+
184
332
  // Strip `//` line comments, `/* */` block comments, and trailing commas so a
185
333
  // hand-edited JSONC `.vscode` file parses with `JSON.parse`. The walk tracks
186
334
  // string state so a `//` or comma inside a value (e.g. a URL) is preserved.
@@ -301,6 +449,62 @@ function writeVscodeSettings(cwd: string, written: string[]): void {
301
449
  written.push(".vscode/settings.json");
302
450
  }
303
451
 
452
+ function writeVscodeSnippets(cwd: string, written: string[]): void {
453
+ const dir = path.join(cwd, ".vscode");
454
+ const filePath = path.join(dir, "defold-typescript.code-snippets");
455
+ if (existsSync(filePath)) {
456
+ const existing = readVscodeJson(filePath);
457
+ if (existing === null) {
458
+ return;
459
+ }
460
+ for (const [key, snippet] of Object.entries(VSCODE_SNIPPETS_CONTENT)) {
461
+ if (!(key in existing)) {
462
+ existing[key] = snippet;
463
+ }
464
+ }
465
+ writeJson(filePath, existing);
466
+ return;
467
+ }
468
+ mkdirSync(dir, { recursive: true });
469
+ writeJson(filePath, VSCODE_SNIPPETS_CONTENT);
470
+ written.push(".vscode/defold-typescript.code-snippets");
471
+ }
472
+
473
+ function writeVscodeLaunch(cwd: string, written: string[]): void {
474
+ const dir = path.join(cwd, ".vscode");
475
+ const filePath = path.join(dir, "launch.json");
476
+ const ours = debugLaunchConfig();
477
+ if (existsSync(filePath)) {
478
+ const existing = readVscodeJson(filePath);
479
+ if (existing === null) {
480
+ return;
481
+ }
482
+ const configs = Array.isArray(existing.configurations) ? [...existing.configurations] : [];
483
+ const names = new Set(configs.map((c) => (isJsonObject(c) ? c.name : undefined)));
484
+ if (!names.has(ours.name)) {
485
+ configs.push(ours);
486
+ }
487
+ existing.configurations = configs;
488
+ existing.version ??= VSCODE_LAUNCH_CONTENT.version;
489
+ writeJson(filePath, existing);
490
+ return;
491
+ }
492
+ mkdirSync(dir, { recursive: true });
493
+ writeJson(filePath, VSCODE_LAUNCH_CONTENT);
494
+ written.push(".vscode/launch.json");
495
+ }
496
+
497
+ function writeVscodeDebugLauncher(cwd: string, written: string[]): void {
498
+ const dir = path.join(cwd, ".vscode");
499
+ const filePath = path.join(dir, "defold-debug.ts");
500
+ if (existsSync(filePath)) {
501
+ return;
502
+ }
503
+ mkdirSync(dir, { recursive: true });
504
+ writeFileSync(filePath, DEBUG_LAUNCHER_SOURCE);
505
+ written.push(".vscode/defold-debug.ts");
506
+ }
507
+
304
508
  function writeTsSurface(cwd: string, written: string[], force = false): ScriptKind | null {
305
509
  mkdirSync(path.join(cwd, "src"), { recursive: true });
306
510
  writeFileSync(path.join(cwd, "src", "main.ts"), MAIN_TS_CONTENT);
@@ -346,9 +550,13 @@ function writeTsSurface(cwd: string, written: string[], force = false): ScriptKi
346
550
  written.push(".gitignore");
347
551
 
348
552
  writeBiome(cwd, written);
553
+ writeMiseTasks(cwd, written);
349
554
 
350
555
  writeVscodeExtensions(cwd, written);
351
556
  writeVscodeSettings(cwd, written);
557
+ writeVscodeSnippets(cwd, written);
558
+ writeVscodeLaunch(cwd, written);
559
+ writeVscodeDebugLauncher(cwd, written);
352
560
 
353
561
  return selectScriptKind(kinds);
354
562
  }
@@ -65,8 +65,8 @@ export function materializeApiSurface(
65
65
  // surface silently drops those globals. Skipped when the source has no
66
66
  // sibling `src/` (e.g. synthetic test fixtures).
67
67
  const srcDir = path.resolve(sourceGeneratedDir, "..", "src");
68
- const overloads = ["msg-overloads.d.ts", "go-overloads.d.ts"].filter((file) =>
69
- existsSync(path.join(srcDir, file)),
68
+ const overloads = ["msg-overloads.d.ts", "message-guard.d.ts", "go-overloads.d.ts"].filter(
69
+ (file) => existsSync(path.join(srcDir, file)),
70
70
  );
71
71
  const coreTypesSrc = path.join(srcDir, "core-types.ts");
72
72
  const includeCoreTypes = overloads.length > 0 && existsSync(coreTypesSrc);
@@ -148,12 +148,21 @@ export function ensureMaterializedReference(cwd: string, materializedDir: string
148
148
  compilerOptions?: Record<string, unknown>;
149
149
  [key: string]: unknown;
150
150
  };
151
- tsconfig.compilerOptions = {
152
- ...(tsconfig.compilerOptions ?? {}),
153
- typeRoots: [MATERIALIZED_ROOT],
154
- types: [surfaceId],
155
- };
156
- writeJson(tsconfigPath, tsconfig);
151
+ const current = tsconfig.compilerOptions ?? {};
152
+ // Skip the write when already repointed so the file keeps its existing
153
+ // formatting (a consumer's Biome/Prettier shape) instead of churning to
154
+ // JSON.stringify's layout on every build.
155
+ const alreadyRepointed =
156
+ JSON.stringify(current.typeRoots) === JSON.stringify([MATERIALIZED_ROOT]) &&
157
+ JSON.stringify(current.types) === JSON.stringify([surfaceId]);
158
+ if (!alreadyRepointed) {
159
+ tsconfig.compilerOptions = {
160
+ ...current,
161
+ typeRoots: [MATERIALIZED_ROOT],
162
+ types: [surfaceId],
163
+ };
164
+ writeJson(tsconfigPath, tsconfig);
165
+ }
157
166
  }
158
167
 
159
168
  ensureGitignoreLine(cwd, `${MATERIALIZED_ROOT}/`);
@@ -0,0 +1,62 @@
1
+ // The repo has no TOML parser and must not add one for three tasks, so the
2
+ // managed block is emitted as a literal string and merged line-aware. Every
3
+ // managed task is fronted by this marker so a re-merge can locate and refresh
4
+ // the block without disturbing user-authored `[tools]`/`[tasks.*]` content.
5
+ const MANAGED_MARKER = "# managed by @defold-typescript";
6
+
7
+ // `bunx --no-install` resolves only the locally installed binary and never
8
+ // fetches from the registry — that is the installed-version contract the types
9
+ // pin upholds, while staying cross-platform (the bare `node_modules/.bin` path
10
+ // is `.cmd`-shimmed on Windows). `:upgrade` is the deliberate `@latest` pull
11
+ // that re-pins `@defold-typescript/types` via `init --force` + reinstall.
12
+ export const MISE_TASKS_TOML = `${MANAGED_MARKER}
13
+ [tasks."defold-typescript:build"]
14
+ description = "Build the TypeScript sources with the installed defold-typescript CLI"
15
+ run = "bunx --no-install defold-typescript build"
16
+
17
+ ${MANAGED_MARKER}
18
+ [tasks."defold-typescript:watch"]
19
+ description = "Watch and rebuild the TypeScript sources with the installed defold-typescript CLI"
20
+ run = "bunx --no-install defold-typescript watch"
21
+
22
+ ${MANAGED_MARKER}
23
+ [tasks."defold-typescript:upgrade"]
24
+ description = "Upgrade the defold-typescript CLI to its latest release and re-pin the types dependency"
25
+ run = ["bunx @defold-typescript/cli@latest init --force", "bun install"]
26
+ `;
27
+
28
+ // Drop every managed block (marker line through the next blank line or EOF),
29
+ // leaving all other lines byte-identical, so a refresh strips the stale block
30
+ // before the fresh one is re-appended.
31
+ function stripManagedBlocks(text: string): string {
32
+ const lines = text.split("\n");
33
+ const out: string[] = [];
34
+ let i = 0;
35
+ while (i < lines.length) {
36
+ const line = lines[i] ?? "";
37
+ if (line.trim() === MANAGED_MARKER) {
38
+ i += 1;
39
+ while (i < lines.length && (lines[i] ?? "").trim() !== "") {
40
+ i += 1;
41
+ }
42
+ if (i < lines.length) {
43
+ i += 1;
44
+ }
45
+ continue;
46
+ }
47
+ out.push(line);
48
+ i += 1;
49
+ }
50
+ return out.join("\n");
51
+ }
52
+
53
+ export function mergeMiseToml(existing?: string): string {
54
+ if (existing === undefined) {
55
+ return MISE_TASKS_TOML;
56
+ }
57
+ const userContent = stripManagedBlocks(existing).replace(/\s*$/, "");
58
+ if (userContent === "") {
59
+ return MISE_TASKS_TOML;
60
+ }
61
+ return `${userContent}\n\n${MISE_TASKS_TOML}`;
62
+ }