@defold-typescript/cli 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1414 -395
- package/dist/bob-command.d.ts +31 -0
- package/dist/bob.d.ts +19 -0
- package/dist/boot-path.d.ts +5 -0
- package/dist/build-output.d.ts +7 -1
- package/dist/debug-launcher.d.ts +7 -1
- package/dist/directory-walls.d.ts +23 -0
- package/dist/dispatch.d.ts +6 -0
- package/dist/index.js +1268 -248
- package/dist/init.d.ts +1 -2
- package/dist/install-reminder.d.ts +1 -0
- package/dist/json-output.d.ts +26 -2
- package/dist/materialize.d.ts +0 -3
- package/dist/mise-scaffold.d.ts +1 -1
- package/dist/scan.d.ts +1 -0
- package/dist/script-kind.d.ts +2 -1
- package/dist/setup-debug.d.ts +40 -0
- package/dist/wall-interactive.d.ts +16 -0
- package/dist/wall.d.ts +4 -0
- package/dist/watch.d.ts +1 -0
- package/package.json +4 -3
- package/src/bob-command.ts +137 -0
- package/src/bob.ts +59 -0
- package/src/boot-path.ts +113 -0
- package/src/build-output.ts +67 -3
- package/src/build-session.ts +31 -11
- package/src/build.ts +6 -5
- package/src/debug-launcher.ts +36 -12
- package/src/directory-walls.ts +214 -0
- package/src/dispatch.ts +264 -18
- package/src/init.ts +83 -38
- package/src/install-reminder.ts +18 -0
- package/src/json-output.ts +52 -7
- package/src/materialize.ts +14 -12
- package/src/mise-scaffold.ts +16 -10
- package/src/scan.ts +7 -1
- package/src/script-kind.ts +31 -19
- package/src/setup-debug.ts +422 -0
- package/src/wall-interactive.ts +60 -0
- package/src/wall.ts +71 -0
- package/src/watch.ts +15 -3
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { resolveBootPathScripts } from "./boot-path";
|
|
4
|
+
import { normalizeScannedPath, scanFilesSync } from "./scan";
|
|
5
|
+
import { isSkipped } from "./script-kind";
|
|
6
|
+
|
|
7
|
+
// Our pinned release URL, mirrored from `docs/guide/debugging.md`. The
|
|
8
|
+
// `lldebugger-url` leak guard forbids the upstream ts-defold URL, so this must
|
|
9
|
+
// stay equal to the guide's snapshot URL.
|
|
10
|
+
export const LLDEBUGGER_URL =
|
|
11
|
+
"https://github.com/king8fisher/defold-typescript/releases/download/lldebugger-v1/lldebugger.zip";
|
|
12
|
+
|
|
13
|
+
// The prior step shipped a single skip-marker block with the `declare module`
|
|
14
|
+
// inline. That form fails `tsc` under `moduleResolution: Bundler` (Bug 08), so
|
|
15
|
+
// the declaration moved to an ambient `.d.ts`. The marker is kept only as the
|
|
16
|
+
// legacy-detection needle for the upgrade path below.
|
|
17
|
+
export const LEGACY_BOOTSTRAP_MARKER =
|
|
18
|
+
"// lldebugger-bootstrap: debug entry, inert in release builds";
|
|
19
|
+
|
|
20
|
+
// The ambient `.d.ts` regenerated by `setup-debug`. `@noResolution` keeps TSTL
|
|
21
|
+
// from rewriting the literal module path so the emit stays
|
|
22
|
+
// `require("lldebugger.debug")`; in a `.d.ts` the declaration type-checks clean
|
|
23
|
+
// (the inline form in a `.ts` did not — Bug 08).
|
|
24
|
+
export const AMBIENT_DTS_REL = "src/lldebugger.debug.d.ts";
|
|
25
|
+
|
|
26
|
+
export const AMBIENT_DECLARATION = `/** @noResolution */
|
|
27
|
+
declare module "lldebugger.debug" {
|
|
28
|
+
export function start(): void;
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
// Managed-block sentinels. A re-run locates the single BEGIN…END pair and
|
|
33
|
+
// refreshes the enclosed text if it drifted, so wording changes self-heal.
|
|
34
|
+
// They are `//` line comments, stripped by TSTL, so they never reach the
|
|
35
|
+
// emitted `.ts.script`.
|
|
36
|
+
export const BLOCK_BEGIN = "// defold-typescript:setup-debug BEGIN — managed block, do not edit";
|
|
37
|
+
export const BLOCK_END = "// defold-typescript:setup-debug END";
|
|
38
|
+
|
|
39
|
+
// The canonical managed block: import + gated start, marker-free, no
|
|
40
|
+
// `declare module` (that lives in the ambient `.d.ts`). Mirrors the snippet in
|
|
41
|
+
// `docs/guide/debugging.md` exactly; a guide-comparison test locks the two.
|
|
42
|
+
// The blank line before END keeps Biome's organizeImports assist from gluing
|
|
43
|
+
// the END comment onto a following import as its leading comment (which would
|
|
44
|
+
// then demand a blank line before the whole group). With blank lines on both
|
|
45
|
+
// sides of END, an import injected above the user's own imports stays clean.
|
|
46
|
+
const MANAGED_BLOCK = `${BLOCK_BEGIN}
|
|
47
|
+
import * as lldebugger from "lldebugger.debug";
|
|
48
|
+
|
|
49
|
+
if (sys.get_engine_info().is_debug) {
|
|
50
|
+
lldebugger.start();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
${BLOCK_END}`;
|
|
54
|
+
|
|
55
|
+
const FACTORY_NAMES = ["defineScript", "defineGuiScript", "defineRenderScript"] as const;
|
|
56
|
+
|
|
57
|
+
const MANUAL_STEPS: readonly string[] = [
|
|
58
|
+
"Install the Local Lua Debugger extension (tomblind.local-lua-debugger-vscode) in VS Code.",
|
|
59
|
+
"Run Project -> Fetch Libraries in the Defold editor to download the lldebugger module.",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function isProjectHeader(line: string): boolean {
|
|
63
|
+
return line.trim() === "[project]";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isSectionHeader(line: string): boolean {
|
|
67
|
+
return /^\[.+\]\s*$/.test(line.trim());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Line-aware INI edit: find the max `dependencies#N` under `[project]` and
|
|
71
|
+
// append `#(N+1)`; skip when the URL already appears. Deliberately no TOML/INI
|
|
72
|
+
// parser — `game.project` is a flat INI the engine writes itself.
|
|
73
|
+
export function addLldebuggerDependency(gameProjectText: string): string {
|
|
74
|
+
const lines = gameProjectText.split("\n");
|
|
75
|
+
if (!lines.some(isProjectHeader)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"defold-typescript setup-debug: game.project has no [project] section; this is not a Defold project.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (gameProjectText.includes(LLDEBUGGER_URL)) {
|
|
81
|
+
return gameProjectText;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let inProject = false;
|
|
85
|
+
let maxIndex = -1;
|
|
86
|
+
let lastDepLine = -1;
|
|
87
|
+
let lastProjectLine = -1;
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i] ?? "";
|
|
90
|
+
if (isSectionHeader(line)) {
|
|
91
|
+
inProject = isProjectHeader(line);
|
|
92
|
+
if (inProject) {
|
|
93
|
+
lastProjectLine = i;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (inProject && line.trim() !== "") {
|
|
98
|
+
lastProjectLine = i;
|
|
99
|
+
const dep = line.match(/^dependencies#(\d+)\s*=/);
|
|
100
|
+
if (dep?.[1] !== undefined) {
|
|
101
|
+
const n = Number(dep[1]);
|
|
102
|
+
if (n > maxIndex) {
|
|
103
|
+
maxIndex = n;
|
|
104
|
+
}
|
|
105
|
+
lastDepLine = i;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const newLine = `dependencies#${maxIndex + 1} = ${LLDEBUGGER_URL}`;
|
|
111
|
+
const insertAt = (lastDepLine >= 0 ? lastDepLine : lastProjectLine) + 1;
|
|
112
|
+
lines.splice(insertAt, 0, newLine);
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The exact text the prior step prepended (snippet + its trailing newline);
|
|
117
|
+
// matched verbatim so a legacy block is upgraded in place rather than stacked.
|
|
118
|
+
const LEGACY_BLOCK = `${LEGACY_BOOTSTRAP_MARKER}
|
|
119
|
+
/** @noResolution */
|
|
120
|
+
declare module "lldebugger.debug" {
|
|
121
|
+
export function start(): void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
import * as lldebugger from "lldebugger.debug";
|
|
125
|
+
|
|
126
|
+
if (sys.get_engine_info().is_debug) {
|
|
127
|
+
lldebugger.start();
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
export type FileAction = "injected" | "refreshed" | "unchanged" | "removed";
|
|
132
|
+
|
|
133
|
+
export interface UpsertResult {
|
|
134
|
+
readonly text: string;
|
|
135
|
+
readonly action: FileAction;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function occurrences(haystack: string, needle: string): number {
|
|
139
|
+
return haystack.split(needle).length - 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Locate the single managed BEGIN…END pair and act on its three states:
|
|
143
|
+
// absent (inject or legacy-upgrade), canonical (no-op), drifted (splice-
|
|
144
|
+
// replace). A malformed region (lone or duplicate / out-of-order sentinels)
|
|
145
|
+
// is refused so the tool never deletes user code by guessing the extent.
|
|
146
|
+
export function upsertManagedBlock(source: string): UpsertResult {
|
|
147
|
+
const begins = occurrences(source, BLOCK_BEGIN);
|
|
148
|
+
const ends = occurrences(source, BLOCK_END);
|
|
149
|
+
|
|
150
|
+
if (begins !== ends || begins > 1) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"defold-typescript setup-debug: malformed managed block (mismatched, duplicate, or out-of-order sentinels); refusing to edit.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (begins === 1) {
|
|
157
|
+
const beginAt = source.indexOf(BLOCK_BEGIN);
|
|
158
|
+
const endAt = source.indexOf(BLOCK_END);
|
|
159
|
+
if (endAt < beginAt) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
"defold-typescript setup-debug: malformed managed block (END before BEGIN); refusing to edit.",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const regionEnd = endAt + BLOCK_END.length;
|
|
165
|
+
const region = source.slice(beginAt, regionEnd);
|
|
166
|
+
if (region === MANAGED_BLOCK) {
|
|
167
|
+
return { text: source, action: "unchanged" };
|
|
168
|
+
}
|
|
169
|
+
const text = source.slice(0, beginAt) + MANAGED_BLOCK + source.slice(regionEnd);
|
|
170
|
+
return { text, action: "refreshed" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (source.includes(LEGACY_BLOCK)) {
|
|
174
|
+
return { text: source.replace(LEGACY_BLOCK, `${MANAGED_BLOCK}\n`), action: "refreshed" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { text: `${MANAGED_BLOCK}\n\n${source}`, action: "injected" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function injectDebugBootstrap(source: string): string {
|
|
181
|
+
return upsertManagedBlock(source).text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface StripResult {
|
|
185
|
+
readonly text: string;
|
|
186
|
+
readonly removed: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Remove the single managed BEGIN…END pair (and the blank lines the inject
|
|
190
|
+
// prepended) so the block survives in exactly the chosen entry script. A file
|
|
191
|
+
// with no block is left untouched; a malformed region is refused, mirroring
|
|
192
|
+
// `upsertManagedBlock`, so the tool never guesses the extent and deletes code.
|
|
193
|
+
export function stripManagedBlock(source: string): StripResult {
|
|
194
|
+
const begins = occurrences(source, BLOCK_BEGIN);
|
|
195
|
+
const ends = occurrences(source, BLOCK_END);
|
|
196
|
+
|
|
197
|
+
if (begins === 0 && ends === 0) {
|
|
198
|
+
return { text: source, removed: false };
|
|
199
|
+
}
|
|
200
|
+
if (begins !== ends || begins > 1) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
"defold-typescript setup-debug: malformed managed block (mismatched, duplicate, or out-of-order sentinels); refusing to edit.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const beginAt = source.indexOf(BLOCK_BEGIN);
|
|
207
|
+
const endAt = source.indexOf(BLOCK_END);
|
|
208
|
+
if (endAt < beginAt) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"defold-typescript setup-debug: malformed managed block (END before BEGIN); refusing to edit.",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const regionEnd = endAt + BLOCK_END.length;
|
|
214
|
+
const after = source.slice(regionEnd).replace(/^\n{1,2}/, "");
|
|
215
|
+
return { text: source.slice(0, beginAt) + after, removed: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasFactoryCall(text: string): boolean {
|
|
219
|
+
return FACTORY_NAMES.some((name) => text.includes(`${name}(`));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type ProjectScanner = (cwd: string, pattern: string) => string[];
|
|
223
|
+
|
|
224
|
+
export function findEntryScriptCandidates(
|
|
225
|
+
cwd: string,
|
|
226
|
+
scanner: ProjectScanner = scanFilesSync,
|
|
227
|
+
): string[] {
|
|
228
|
+
const srcDir = path.join(cwd, "src");
|
|
229
|
+
if (!existsSync(srcDir)) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return scanner(cwd, "src/**/*.ts")
|
|
233
|
+
.map(normalizeScannedPath)
|
|
234
|
+
.filter((rel) => !isSkipped(rel))
|
|
235
|
+
.filter((rel) => hasFactoryCall(readFileSync(path.join(cwd, rel), "utf8")))
|
|
236
|
+
.sort();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface SetupDebugOptions {
|
|
240
|
+
readonly cwd: string;
|
|
241
|
+
readonly script?: string;
|
|
242
|
+
readonly json?: boolean;
|
|
243
|
+
readonly chooseScript?: (candidates: string[]) => Promise<string>;
|
|
244
|
+
readonly scanFiles?: ProjectScanner;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface SetupDebugResult {
|
|
248
|
+
readonly ok: boolean;
|
|
249
|
+
readonly written: string[];
|
|
250
|
+
readonly actions: Record<string, FileAction>;
|
|
251
|
+
readonly manualSteps: readonly string[];
|
|
252
|
+
readonly error?: string;
|
|
253
|
+
readonly addedTo?: string;
|
|
254
|
+
readonly removedFrom?: string[];
|
|
255
|
+
readonly bootPath?: string[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ResolvedTarget {
|
|
259
|
+
readonly target: string;
|
|
260
|
+
readonly bootPath: string[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function failure(error: string): SetupDebugResult {
|
|
264
|
+
return { ok: false, written: [], actions: {}, manualSteps: MANUAL_STEPS, error };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function defaultChooseScript(candidates: string[]): Promise<string> {
|
|
268
|
+
const { select } = await import("@inquirer/prompts");
|
|
269
|
+
return select({
|
|
270
|
+
message: "Select the entry script to receive the debugger bootstrap:",
|
|
271
|
+
choices: candidates.map((candidate) => ({ name: candidate, value: candidate })),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function pickCandidate(
|
|
276
|
+
candidates: string[],
|
|
277
|
+
opts: SetupDebugOptions,
|
|
278
|
+
): Promise<string | SetupDebugResult> {
|
|
279
|
+
if (candidates.length === 1) {
|
|
280
|
+
return candidates[0] as string;
|
|
281
|
+
}
|
|
282
|
+
const chooser = opts.chooseScript ?? (opts.json ? undefined : defaultChooseScript);
|
|
283
|
+
if (chooser === undefined) {
|
|
284
|
+
return failure(
|
|
285
|
+
`defold-typescript setup-debug: multiple entry scripts found (${candidates.join(", ")}); pass --script to choose one.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return chooser(candidates);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Prefer a script on the Defold boot path (game.project -> collections ->
|
|
292
|
+
// `.ts.script`); fall back to today's `src/` factory-call scan only when the
|
|
293
|
+
// boot path reaches no script. `--script` always wins, carrying the boot-path
|
|
294
|
+
// trace for the report when the named file is itself on the path.
|
|
295
|
+
async function resolveTargetScript(
|
|
296
|
+
opts: SetupDebugOptions,
|
|
297
|
+
): Promise<ResolvedTarget | SetupDebugResult> {
|
|
298
|
+
const { cwd } = opts;
|
|
299
|
+
const script = opts.script === undefined ? undefined : normalizeScannedPath(opts.script);
|
|
300
|
+
const bootCandidates = resolveBootPathScripts(cwd).filter((entry) =>
|
|
301
|
+
existsSync(path.join(cwd, entry.candidate)),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
if (script !== undefined) {
|
|
305
|
+
if (!existsSync(path.join(cwd, script))) {
|
|
306
|
+
return failure(`defold-typescript setup-debug: script not found: ${script}`);
|
|
307
|
+
}
|
|
308
|
+
const onPath = bootCandidates.find((entry) => entry.candidate === script);
|
|
309
|
+
return { target: script, bootPath: onPath?.trace ?? [] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (bootCandidates.length > 0) {
|
|
313
|
+
const picked = await pickCandidate(
|
|
314
|
+
bootCandidates.map((entry) => entry.candidate),
|
|
315
|
+
opts,
|
|
316
|
+
);
|
|
317
|
+
if (typeof picked !== "string") {
|
|
318
|
+
return picked;
|
|
319
|
+
}
|
|
320
|
+
const onPath = bootCandidates.find((entry) => entry.candidate === picked);
|
|
321
|
+
return { target: picked, bootPath: onPath?.trace ?? [] };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const scanned = findEntryScriptCandidates(cwd, opts.scanFiles);
|
|
325
|
+
if (scanned.length === 0) {
|
|
326
|
+
return failure(
|
|
327
|
+
"defold-typescript setup-debug: no entry script with a lifecycle factory call found under src/.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const picked = await pickCandidate(scanned, opts);
|
|
331
|
+
if (typeof picked !== "string") {
|
|
332
|
+
return picked;
|
|
333
|
+
}
|
|
334
|
+
return { target: picked, bootPath: [] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function runSetupDebug(opts: SetupDebugOptions): Promise<SetupDebugResult> {
|
|
338
|
+
const { cwd } = opts;
|
|
339
|
+
const gameProjectPath = path.join(cwd, "game.project");
|
|
340
|
+
if (!existsSync(gameProjectPath)) {
|
|
341
|
+
return failure(
|
|
342
|
+
"defold-typescript setup-debug: no game.project here; this is not a Defold project.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const resolved = await resolveTargetScript(opts);
|
|
347
|
+
if (!("target" in resolved)) {
|
|
348
|
+
return resolved;
|
|
349
|
+
}
|
|
350
|
+
const { target, bootPath } = resolved;
|
|
351
|
+
|
|
352
|
+
const written: string[] = [];
|
|
353
|
+
const actions: Record<string, FileAction> = {};
|
|
354
|
+
|
|
355
|
+
const dtsPath = path.join(cwd, AMBIENT_DTS_REL);
|
|
356
|
+
mkdirSync(path.dirname(dtsPath), { recursive: true });
|
|
357
|
+
const existingDts = existsSync(dtsPath) ? readFileSync(dtsPath, "utf8") : null;
|
|
358
|
+
if (existingDts === null) {
|
|
359
|
+
writeFileSync(dtsPath, AMBIENT_DECLARATION);
|
|
360
|
+
actions[AMBIENT_DTS_REL] = "injected";
|
|
361
|
+
} else if (existingDts !== AMBIENT_DECLARATION) {
|
|
362
|
+
writeFileSync(dtsPath, AMBIENT_DECLARATION);
|
|
363
|
+
actions[AMBIENT_DTS_REL] = "refreshed";
|
|
364
|
+
} else {
|
|
365
|
+
actions[AMBIENT_DTS_REL] = "unchanged";
|
|
366
|
+
}
|
|
367
|
+
written.push(AMBIENT_DTS_REL);
|
|
368
|
+
|
|
369
|
+
const scriptPath = path.join(cwd, target);
|
|
370
|
+
const source = readFileSync(scriptPath, "utf8");
|
|
371
|
+
const { text: injected, action: scriptAction } = upsertManagedBlock(source);
|
|
372
|
+
if (injected !== source) {
|
|
373
|
+
writeFileSync(scriptPath, injected);
|
|
374
|
+
}
|
|
375
|
+
actions[target] = scriptAction;
|
|
376
|
+
written.push(target);
|
|
377
|
+
|
|
378
|
+
const gameProjectText = readFileSync(gameProjectPath, "utf8");
|
|
379
|
+
const updated = addLldebuggerDependency(gameProjectText);
|
|
380
|
+
if (updated !== gameProjectText) {
|
|
381
|
+
writeFileSync(gameProjectPath, updated);
|
|
382
|
+
actions["game.project"] = "injected";
|
|
383
|
+
} else {
|
|
384
|
+
actions["game.project"] = "unchanged";
|
|
385
|
+
}
|
|
386
|
+
written.push("game.project");
|
|
387
|
+
|
|
388
|
+
// Keep the bootstrap in exactly one script: strip a stale managed block from
|
|
389
|
+
// every other `src/` script. A malformed block elsewhere is left alone (the
|
|
390
|
+
// strip refuses it) rather than aborting the wiring of the chosen target.
|
|
391
|
+
const removedFrom: string[] = [];
|
|
392
|
+
for (const scannedRel of (opts.scanFiles ?? scanFilesSync)(cwd, "src/**/*.ts")) {
|
|
393
|
+
const rel = normalizeScannedPath(scannedRel);
|
|
394
|
+
if (isSkipped(rel) || rel === target) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const otherPath = path.join(cwd, rel);
|
|
398
|
+
const otherSource = readFileSync(otherPath, "utf8");
|
|
399
|
+
let stripped: StripResult;
|
|
400
|
+
try {
|
|
401
|
+
stripped = stripManagedBlock(otherSource);
|
|
402
|
+
} catch {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (stripped.removed) {
|
|
406
|
+
writeFileSync(otherPath, stripped.text);
|
|
407
|
+
actions[rel] = "removed";
|
|
408
|
+
removedFrom.push(rel);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
removedFrom.sort();
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
written,
|
|
416
|
+
actions,
|
|
417
|
+
manualSteps: MANUAL_STEPS,
|
|
418
|
+
addedTo: target,
|
|
419
|
+
removedFrom,
|
|
420
|
+
bootPath,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { DirectoryWall } from "./directory-walls";
|
|
2
|
+
import { groupSourceScriptKindsByDirectory } from "./directory-walls";
|
|
3
|
+
import { selectScriptKind } from "./script-kind";
|
|
4
|
+
import { applyWallSelection, currentWalledDirs } from "./wall";
|
|
5
|
+
|
|
6
|
+
export interface WallChoice {
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly checked?: boolean;
|
|
10
|
+
readonly disabled?: string | false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Injected so the dispatch route and tests can substitute a fake; the default is
|
|
14
|
+
// `@inquirer/prompts`'s `checkbox`, imported lazily so the prompt module never
|
|
15
|
+
// loads on the non-interactive paths.
|
|
16
|
+
export type CheckboxPrompt = (opts: {
|
|
17
|
+
message: string;
|
|
18
|
+
choices: WallChoice[];
|
|
19
|
+
}) => Promise<string[]>;
|
|
20
|
+
|
|
21
|
+
export interface WallInteractiveDeps {
|
|
22
|
+
readonly checkbox?: CheckboxPrompt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One choice per source directory: a single-kind dir is selectable and
|
|
26
|
+
// pre-checked to its current walled state; a mixed-kind dir is shown disabled
|
|
27
|
+
// with its competing kinds, since no single narrowing applies.
|
|
28
|
+
export function buildWallChoices(cwd: string): WallChoice[] {
|
|
29
|
+
const current = new Set(currentWalledDirs(cwd));
|
|
30
|
+
const choices: WallChoice[] = [];
|
|
31
|
+
for (const [dir, kinds] of groupSourceScriptKindsByDirectory(cwd)) {
|
|
32
|
+
const kind = selectScriptKind(kinds);
|
|
33
|
+
if (kind === null) {
|
|
34
|
+
choices.push({ value: dir, name: dir, disabled: `mixed: ${[...kinds].sort().join(", ")}` });
|
|
35
|
+
} else {
|
|
36
|
+
choices.push({ value: dir, name: `${dir} (${kind})`, checked: current.has(dir) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return choices.sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function defaultCheckbox(): Promise<CheckboxPrompt> {
|
|
43
|
+
const { checkbox } = await import("@inquirer/prompts");
|
|
44
|
+
return (opts) => checkbox({ message: opts.message, choices: opts.choices });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Presentation only: the checkbox selection is the desired wall set, reconciled
|
|
48
|
+
// to disk through the slice E engine (check = add, uncheck = remove), so the
|
|
49
|
+
// interactive and flag paths can never diverge.
|
|
50
|
+
export async function runWallInteractive(
|
|
51
|
+
cwd: string,
|
|
52
|
+
deps: WallInteractiveDeps = {},
|
|
53
|
+
): Promise<DirectoryWall[]> {
|
|
54
|
+
const checkbox = deps.checkbox ?? (await defaultCheckbox());
|
|
55
|
+
const selection = await checkbox({
|
|
56
|
+
message: "Select the source directories to wall (space toggles, enter confirms):",
|
|
57
|
+
choices: buildWallChoices(cwd),
|
|
58
|
+
});
|
|
59
|
+
return applyWallSelection(cwd, selection);
|
|
60
|
+
}
|
package/src/wall.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type DirectoryWall,
|
|
5
|
+
planSourceDirectoryWalls,
|
|
6
|
+
wireWallReferences,
|
|
7
|
+
writeDirectoryWallTsconfigs,
|
|
8
|
+
} from "./directory-walls";
|
|
9
|
+
|
|
10
|
+
function sortDirs<T extends { dir: string }>(items: T[]): T[] {
|
|
11
|
+
return items.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The directories the root tsconfig currently walls — its managed `references`
|
|
15
|
+
// set, which `wireWallReferences` keeps in lockstep with the walls on disk.
|
|
16
|
+
export function currentWalledDirs(cwd: string): string[] {
|
|
17
|
+
const rootPath = path.join(cwd, "tsconfig.json");
|
|
18
|
+
if (!existsSync(rootPath)) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(rootPath, "utf8")) as {
|
|
23
|
+
references?: Array<{ path: string }>;
|
|
24
|
+
};
|
|
25
|
+
return (parsed.references ?? [])
|
|
26
|
+
.map((ref) => ref.path)
|
|
27
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The single-kind source directories that can be walled, each with its detected
|
|
34
|
+
// kind. Mixed-kind directories are absent (no single narrowing applies).
|
|
35
|
+
export function eligibleWalls(cwd: string): DirectoryWall[] {
|
|
36
|
+
return planSourceDirectoryWalls(cwd);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reconcile the on-disk wall set to `desiredDirs`: write a composite tsconfig for
|
|
40
|
+
// each desired wall, delete the child tsconfig of every currently-walled dir not
|
|
41
|
+
// desired, and re-wire the root references/exclude/files to exactly the desired
|
|
42
|
+
// set. Idempotent and total — the same engine backs `wall <dir...>`,
|
|
43
|
+
// `wall --remove <dir...>`, and the interactive checkbox.
|
|
44
|
+
export function applyWallSelection(cwd: string, desiredDirs: readonly string[]): DirectoryWall[] {
|
|
45
|
+
const byDir = new Map(eligibleWalls(cwd).map((wall) => [wall.dir, wall]));
|
|
46
|
+
const desired: DirectoryWall[] = [];
|
|
47
|
+
const desiredSet = new Set<string>();
|
|
48
|
+
for (const dir of desiredDirs) {
|
|
49
|
+
if (desiredSet.has(dir)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const wall = byDir.get(dir);
|
|
53
|
+
if (wall === undefined) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`defold-typescript wall: ${dir} is not a single-kind source directory that can be walled`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
desired.push(wall);
|
|
59
|
+
desiredSet.add(dir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const dir of currentWalledDirs(cwd)) {
|
|
63
|
+
if (!desiredSet.has(dir)) {
|
|
64
|
+
rmSync(path.join(cwd, dir, "tsconfig.json"), { force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeDirectoryWallTsconfigs(cwd, desired);
|
|
69
|
+
wireWallReferences(cwd, desired);
|
|
70
|
+
return sortDirs(desired);
|
|
71
|
+
}
|
package/src/watch.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, watch as fsWatch } from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isTranspilerSource, toPosix } from "./build-output";
|
|
4
4
|
import { type BuildSession, createBuildSession } from "./build-session";
|
|
5
|
+
import { renderWatchEvent } from "./json-output";
|
|
5
6
|
import { isComponentPath, isSkipped } from "./script-kind";
|
|
6
7
|
|
|
7
8
|
export interface WatchEvent {
|
|
@@ -23,6 +24,7 @@ export interface RunWatchOptions {
|
|
|
23
24
|
readonly watcherFactory?: WatcherFactory;
|
|
24
25
|
readonly syncSurface?: () => void;
|
|
25
26
|
readonly componentWatcherFactory?: WatcherFactory;
|
|
27
|
+
readonly json?: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface RunWatchHandle {
|
|
@@ -69,7 +71,9 @@ export function runWatch(opts: RunWatchOptions): RunWatchHandle {
|
|
|
69
71
|
opts.syncSurface?.();
|
|
70
72
|
session = createBuildSession({ cwd });
|
|
71
73
|
const { written } = session.buildAll();
|
|
72
|
-
stdout.write(
|
|
74
|
+
stdout.write(
|
|
75
|
+
opts.json ? renderWatchEvent({ event: "build", written }) : formatBuildLine(written),
|
|
76
|
+
);
|
|
73
77
|
} catch (err) {
|
|
74
78
|
rejectDone(rewrapInitError(err));
|
|
75
79
|
return {
|
|
@@ -112,10 +116,18 @@ export function runWatch(opts: RunWatchOptions): RunWatchHandle {
|
|
|
112
116
|
}
|
|
113
117
|
try {
|
|
114
118
|
const { written } = session.applyEvents(changed, removed);
|
|
115
|
-
stdout.write(
|
|
119
|
+
stdout.write(
|
|
120
|
+
opts.json
|
|
121
|
+
? renderWatchEvent({ event: "rebuild", written, changed, removed })
|
|
122
|
+
: formatBuildLine(written),
|
|
123
|
+
);
|
|
116
124
|
} catch (err) {
|
|
117
125
|
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
-
|
|
126
|
+
if (opts.json) {
|
|
127
|
+
stdout.write(renderWatchEvent({ event: "rebuild", error: message }));
|
|
128
|
+
} else {
|
|
129
|
+
stderr.write(`${message}\n`);
|
|
130
|
+
}
|
|
119
131
|
}
|
|
120
132
|
rebuildBusy = false;
|
|
121
133
|
notifyIdle();
|