@defold-typescript/cli 0.5.2 → 0.5.4

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
 
@@ -88,43 +91,81 @@ interface VscodeSnippet {
88
91
  description: string;
89
92
  }
90
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
+
91
148
  // Whole-file TS scaffolds mirroring the Defold editor's empty script/gui/render
92
149
  // templates over the lifecycle factories. Two self-typing variants per kind:
93
150
  // inline-self (TSelf inferred from `init`'s return) and typed-self (an explicit
94
151
  // dummy `Self` placeholder). Hook order mirrors the Lua templates; render omits
95
152
  // `on_input` because `RenderScriptHooks` does. The final `$0` lands inside `init`.
96
153
  function inlineSnippetBody(factory: string, includeOnInput: boolean): string[] {
97
- const lines = [
154
+ return [
98
155
  `import { ${factory} } from "@defold-typescript/types";`,
99
156
  "",
100
157
  `export const script = ${factory}({`,
101
- " // Initialize the component and return its state.",
158
+ ` // ${HOOK_COMMENTS.init}`,
102
159
  " init() {",
103
160
  " return { $0 };",
104
161
  " },",
105
- " // Update the component every frame; `dt` is the time step.",
106
- " update(self, dt) {$1},",
107
- " // Update at the fixed physics time step.",
108
- " fixed_update(self, dt) {$2},",
109
- " // Update every frame after `update`.",
110
- " late_update(self, dt) {$3},",
111
- " // Handle an incoming message.",
112
- " on_message(self, message_id, message, sender) {$4},",
162
+ ...hookLines(includeOnInput, 1),
163
+ "});",
113
164
  ];
114
- if (includeOnInput) {
115
- lines.push(" // Handle input once input focus is acquired.");
116
- lines.push(" on_input(self, action_id, action) {$5},");
117
- }
118
- lines.push(" // Clean up when the component is deleted.");
119
- lines.push(" final(self) {$6},");
120
- lines.push(" // React to a hot reload of this script.");
121
- lines.push(" on_reload(self) {$7},");
122
- lines.push("});");
123
- return lines;
124
165
  }
125
166
 
126
167
  function typedSnippetBody(factory: string, includeOnInput: boolean): string[] {
127
- const lines = [
168
+ return [
128
169
  `import { ${factory} } from "@defold-typescript/types";`,
129
170
  "",
130
171
  "type Self = {",
@@ -133,29 +174,13 @@ function typedSnippetBody(factory: string, includeOnInput: boolean): string[] {
133
174
  "};",
134
175
  "",
135
176
  `export const script = ${factory}<Self>({`,
136
- " // Initialize the component and return its state.",
177
+ ` // ${HOOK_COMMENTS.init}`,
137
178
  " init(): Self {",
138
179
  " return { $0 };",
139
180
  " },",
140
- " // Update the component every frame; `dt` is the time step.",
141
- " update(self, dt) {$2},",
142
- " // Update at the fixed physics time step.",
143
- " fixed_update(self, dt) {$3},",
144
- " // Update every frame after `update`.",
145
- " late_update(self, dt) {$4},",
146
- " // Handle an incoming message.",
147
- " on_message(self, message_id, message, sender) {$5},",
181
+ ...hookLines(includeOnInput, 2),
182
+ "});",
148
183
  ];
149
- if (includeOnInput) {
150
- lines.push(" // Handle input once input focus is acquired.");
151
- lines.push(" on_input(self, action_id, action) {$6},");
152
- }
153
- lines.push(" // Clean up when the component is deleted.");
154
- lines.push(" final(self) {$7},");
155
- lines.push(" // React to a hot reload of this script.");
156
- lines.push(" on_reload(self) {$8},");
157
- lines.push("});");
158
- return lines;
159
184
  }
160
185
 
161
186
  const VSCODE_SNIPPETS_CONTENT: Record<string, VscodeSnippet> = {
@@ -244,27 +269,31 @@ function typesVersionSpec(): string {
244
269
  }
245
270
  }
246
271
 
247
- // Only @defold-typescript/types ships into the consumer (type-only, for the
248
- // editor). The transpiler is a dependency of the CLI itself, pulled in when the
249
- // user runs `build`/`watch`; the scaffold must not duplicate it. Pin types to
250
- // this CLI's own version so the coordinated-release set stays in lockstep.
251
- const SCAFFOLD_DEV_DEPS: Record<string, string> = {
272
+ // @defold-typescript/types (type-only, for the editor) and @defold-typescript/cli
273
+ // (the local bin the managed `bunx --no-install defold-typescript` mise tasks
274
+ // resolve) both ship into the consumer. The transpiler must NOT be a direct
275
+ // consumer dep it arrives transitively through the CLI. Pin both managed deps
276
+ // to this CLI's own version so the coordinated-release set stays in lockstep.
277
+ export const SCAFFOLD_DEV_DEPS: Record<string, string> = {
252
278
  "@defold-typescript/types": typesVersionSpec(),
279
+ "@defold-typescript/cli": typesVersionSpec(),
253
280
  "@biomejs/biome": "^2.2.0",
254
281
  };
255
282
 
256
- // Older scaffolds wrote both managed `@defold-typescript/*` devDeps as
283
+ // Older scaffolds wrote the managed `@defold-typescript/*` devDeps as
257
284
  // `workspace:*`, which only resolves inside this monorepo and breaks
258
285
  // `bun install` in consumers. The additive merge in `writeTsSurface` never
259
286
  // repairs an entry it didn't itself create, so repair them explicitly: the
260
287
  // transpiler is CLI-internal and must not be a consumer dep at all, and a
261
- // `workspace:` types pin must become a concrete published version. A concrete
262
- // user-chosen types pin is left alone unless `force` is set, the explicit
263
- // opt-in to refresh the managed pin (and only that pin) to the CLI's version.
288
+ // `workspace:` types/cli pin must become a concrete published version. A
289
+ // concrete user-chosen pin is left alone unless `force` is set, the explicit
290
+ // opt-in to refresh the managed pins (and only those) to the CLI's version.
264
291
  function repairManagedDevDeps(devDeps: Record<string, string>, force = false): void {
265
292
  delete devDeps["@defold-typescript/transpiler"];
266
- if (force || devDeps["@defold-typescript/types"]?.startsWith("workspace:")) {
267
- devDeps["@defold-typescript/types"] = typesVersionSpec();
293
+ for (const name of ["@defold-typescript/types", "@defold-typescript/cli"]) {
294
+ if (force || devDeps[name]?.startsWith("workspace:")) {
295
+ devDeps[name] = typesVersionSpec();
296
+ }
268
297
  }
269
298
  }
270
299
 
@@ -297,6 +326,13 @@ function writeBiome(cwd: string, written: string[]): void {
297
326
  written.push("biome.json");
298
327
  }
299
328
 
329
+ function writeMiseTasks(cwd: string, written: string[]): void {
330
+ const misePath = path.join(cwd, "mise.toml");
331
+ const existing = existsSync(misePath) ? readFileSync(misePath, "utf8") : undefined;
332
+ writeFileSync(misePath, mergeMiseToml(existing));
333
+ written.push("mise.toml");
334
+ }
335
+
300
336
  // Strip `//` line comments, `/* */` block comments, and trailing commas so a
301
337
  // hand-edited JSONC `.vscode` file parses with `JSON.parse`. The walk tracks
302
338
  // string state so a `//` or comma inside a value (e.g. a URL) is preserved.
@@ -438,6 +474,41 @@ function writeVscodeSnippets(cwd: string, written: string[]): void {
438
474
  written.push(".vscode/defold-typescript.code-snippets");
439
475
  }
440
476
 
477
+ function writeVscodeLaunch(cwd: string, written: string[]): void {
478
+ const dir = path.join(cwd, ".vscode");
479
+ const filePath = path.join(dir, "launch.json");
480
+ const ours = debugLaunchConfig();
481
+ if (existsSync(filePath)) {
482
+ const existing = readVscodeJson(filePath);
483
+ if (existing === null) {
484
+ return;
485
+ }
486
+ const configs = Array.isArray(existing.configurations) ? [...existing.configurations] : [];
487
+ const names = new Set(configs.map((c) => (isJsonObject(c) ? c.name : undefined)));
488
+ if (!names.has(ours.name)) {
489
+ configs.push(ours);
490
+ }
491
+ existing.configurations = configs;
492
+ existing.version ??= VSCODE_LAUNCH_CONTENT.version;
493
+ writeJson(filePath, existing);
494
+ return;
495
+ }
496
+ mkdirSync(dir, { recursive: true });
497
+ writeJson(filePath, VSCODE_LAUNCH_CONTENT);
498
+ written.push(".vscode/launch.json");
499
+ }
500
+
501
+ function writeVscodeDebugLauncher(cwd: string, written: string[]): void {
502
+ const dir = path.join(cwd, ".vscode");
503
+ const filePath = path.join(dir, "defold-debug.ts");
504
+ if (existsSync(filePath)) {
505
+ return;
506
+ }
507
+ mkdirSync(dir, { recursive: true });
508
+ writeFileSync(filePath, DEBUG_LAUNCHER_SOURCE);
509
+ written.push(".vscode/defold-debug.ts");
510
+ }
511
+
441
512
  function writeTsSurface(cwd: string, written: string[], force = false): ScriptKind | null {
442
513
  mkdirSync(path.join(cwd, "src"), { recursive: true });
443
514
  writeFileSync(path.join(cwd, "src", "main.ts"), MAIN_TS_CONTENT);
@@ -483,10 +554,13 @@ function writeTsSurface(cwd: string, written: string[], force = false): ScriptKi
483
554
  written.push(".gitignore");
484
555
 
485
556
  writeBiome(cwd, written);
557
+ writeMiseTasks(cwd, written);
486
558
 
487
559
  writeVscodeExtensions(cwd, written);
488
560
  writeVscodeSettings(cwd, written);
489
561
  writeVscodeSnippets(cwd, written);
562
+ writeVscodeLaunch(cwd, written);
563
+ writeVscodeDebugLauncher(cwd, written);
490
564
 
491
565
  return selectScriptKind(kinds);
492
566
  }
@@ -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
+ }