@bobfrankston/npmglobalize 1.0.194 → 1.0.196
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/README.md +44 -2
- package/cli.js +18 -7
- package/lib.d.ts +58 -1
- package/lib.js +588 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -454,6 +454,19 @@ Workspace mode is auto-detected when run from a root with `"private": true` and
|
|
|
454
454
|
target. Fixes arborist "Cannot read properties of null"
|
|
455
455
|
crashes caused by sibling file: deps with nested
|
|
456
456
|
node_modules. Suggested automatically when the error hits.
|
|
457
|
+
-ts7-report, -deprecation-report
|
|
458
|
+
Report-only: scan this package and its file: deps for
|
|
459
|
+
compilerOptions removed in TypeScript 7 and list a migration
|
|
460
|
+
to-do. Writes nothing.
|
|
461
|
+
-tsfix, -ts7-fix
|
|
462
|
+
One-off utility, NOT part of the main flow: apply the TS7
|
|
463
|
+
tsconfig migration to a package (and its file: deps) and exit —
|
|
464
|
+
no build, no commit, no push, no publish. Intended as a
|
|
465
|
+
temporary tool for fixing a local/subdirectory tsconfig in
|
|
466
|
+
place. The normal release flow already applies the same
|
|
467
|
+
migration automatically when a build hits a TS7 deprecation
|
|
468
|
+
error, so day-to-day you never need this flag.
|
|
469
|
+
npmglobalize <path> -tsfix
|
|
457
470
|
-show Show package.json dependency changes
|
|
458
471
|
-package, -pkg Update package.json scripts to use npmglobalize (see below)
|
|
459
472
|
-h, -help Show help
|
|
@@ -577,14 +590,43 @@ Before transforming or publishing anything, `npmglobalize` builds `file:` depend
|
|
|
577
590
|
|
|
578
591
|
For each project visited (the target and every transitive `file:` dep):
|
|
579
592
|
|
|
580
|
-
- If `tsconfig.json` is missing or has `"noEmit": true` → **skip** (not a TypeScript build).
|
|
581
|
-
- If `tsconfig.json` exists but `package.json` has no `"build"` script → **prompt** to add `"build": "tsc"
|
|
593
|
+
- If `tsconfig.json` is missing or has `"noEmit": true` → **skip** (not a TypeScript build), unless the `build` script runs `importgen` — a plain-JS browser app still needs its import map regenerated.
|
|
594
|
+
- If `tsconfig.json` exists but `package.json` has no `"build"` script → **prompt** to add `"build": "tsc"` (plus a `tsc -p <dir>` per sub-project — see below). Decline and that project is skipped.
|
|
582
595
|
- Otherwise → run `npm run build`. A failure halts the cascade unless `-force` is passed.
|
|
583
596
|
|
|
584
597
|
Cycle-safe via a shared visited set; each project is built at most once per run.
|
|
585
598
|
|
|
586
599
|
This complements the existing publish cascade (which ensures version refs are correct) by closing the build-freshness gap that `npm install` alone left open.
|
|
587
600
|
|
|
601
|
+
#### Import maps (`importgen`) as a build step
|
|
602
|
+
|
|
603
|
+
Browser projects that use [`importgen`](https://www.npmjs.com/package/@bobfrankston/importgen) have historically regenerated their import map from `.vscode/tasks.json`, which only runs when VS Code opens the folder — so a command-line or CI build could publish a stale map. `npmglobalize` treats the import map as a build product and moves the step into the package's own `build` script, where every build path picks it up.
|
|
604
|
+
|
|
605
|
+
Before building each project, it checks whether the project is an importgen project, in this order:
|
|
606
|
+
|
|
607
|
+
1. `.vscode/tasks.json` has a task whose `command` is `importgen` (the HTML file, if the task names one, is reused).
|
|
608
|
+
2. A root-level `.htm`/`.html` file already contains a `<script type="importmap">` block (`index.html`, `default.html`, `default.htm` are checked first, so a stray `temp.htm` doesn't win).
|
|
609
|
+
3. `importgen` is in `dependencies`/`devDependencies` **and** the project has one of those HTML files — the HTML requirement keeps packages that merely *use* importgen as a library from matching.
|
|
610
|
+
|
|
611
|
+
If a signal matches and the `build` script doesn't already run `importgen`, you're prompted to rewrite it — e.g. `"build": "tsc"` → `"build": "importgen default.htm && tsc"`. The HTML file is named explicitly so importgen doesn't have to guess. Decline and `"importgen": false` is written to `.globalize.json5`, which suppresses the prompt for good; `-noimportgen` does the same from the command line.
|
|
612
|
+
|
|
613
|
+
Once wired, the freshness check gains a second condition: a project whose build runs importgen is only considered up to date if the generated HTML is at least as new as `package.json`. Adding a dependency changes the import map without touching any `.ts` file, which the source-vs-output comparison alone would miss.
|
|
614
|
+
|
|
615
|
+
#### Sub-projects (a second `tsconfig.json` in a sub-directory)
|
|
616
|
+
|
|
617
|
+
A package can hold more than one TypeScript project. The common shape is a service worker in `Sw/` with its own `tsconfig.json` (`lib: ["WebWorker"]`, its own `outDir`) that the root `tsconfig.json` lists under `exclude` — so a bare `"build": "tsc"` compiles everything *except* the service worker, and the stale `sw2.js` ships. As with import maps, these have historically been built only by a second `.vscode/tasks.json` watcher, which runs on folder open and nowhere else.
|
|
618
|
+
|
|
619
|
+
Before building, `npmglobalize` looks for sub-projects, in this order:
|
|
620
|
+
|
|
621
|
+
1. `.vscode/tasks.json` has a task that runs `tsc` with `"options": { "cwd": "${workspaceFolder}/Sw" }` or with `-p`/`--project` naming a sub-directory (authoritative — it's how the project is actually built today; the task's `label` is quoted back in the prompt).
|
|
622
|
+
2. An immediate sub-directory containing its own `tsconfig.json`. Build output and vendored trees (`node_modules`, `prev`, `dist`, `built`, `out`, `wwwroot`, `coverage`, `temp`, `preflight`, dot-directories) are never scanned.
|
|
623
|
+
|
|
624
|
+
Nothing is detected when the root `tsconfig.json` uses project `references` — that build graph belongs to `tsc -b` and isn't second-guessed.
|
|
625
|
+
|
|
626
|
+
When the `build` script is `tsc`-driven and doesn't already compile a detected sub-project, you're prompted to append it — `"build": "tsc"` → `"build": "tsc && tsc -p Sw"`, or for an importgen project `"build": "importgen default.htm && tsc && tsc -p Sw"`. Already-wired scripts are recognized in any of their spellings (`tsc -p Sw`, `tsc --project ./Sw/tsconfig.json`, `cd Sw && tsc`), and the directory is emitted with its real on-disk casing so the script still works on WSL and CI. Decline and `"subProjects": false` is written to `.globalize.json5`, suppressing the prompt for good; a `"subProjects": ["Sw"]` array pins the list instead of detecting it.
|
|
627
|
+
|
|
628
|
+
Sub-projects the build actually compiles are also folded into the freshness check — the package rebuilds when a sub-project's sources are newer than its output. A sub-project that emits *outside* its own directory (`"outDir": ".."`, the usual service-worker case) always reports stale: comparing its sources against the whole package would prove nothing.
|
|
629
|
+
|
|
588
630
|
#### TypeScript 6 `types` auto-fix
|
|
589
631
|
|
|
590
632
|
TypeScript 6 dropped the legacy behavior of auto-including every installed `@types/*` package. A `tsconfig.json` with no explicit `compilerOptions.types` then loses the Node globals (`process`, `Buffer`, …) and the build fails with `TS2591`.
|
package/cli.js
CHANGED
|
@@ -56,6 +56,9 @@ Dependency Options:
|
|
|
56
56
|
public visibility to already-published deps in the cascade.
|
|
57
57
|
Use when a public consumer's deps weren't all marked public.
|
|
58
58
|
-no-prescan, -nps Skip upfront dep-graph prescan
|
|
59
|
+
-force-build, -fb Rebuild file: deps even when their output looks up
|
|
60
|
+
to date (freshness check compares .ts/tsconfig
|
|
61
|
+
mtimes against emitted .js/.d.ts)
|
|
59
62
|
-force-publish Republish dependencies even if version exists
|
|
60
63
|
-fix Run npm audit fix after transformation
|
|
61
64
|
-no-use-paths, -nup Declare this package standalone — file: deps shouldn't
|
|
@@ -70,7 +73,9 @@ Install Options:
|
|
|
70
73
|
-freeze Freeze node_modules (replace symlinks with real copies for network shares)
|
|
71
74
|
-nofreeze Disable freeze
|
|
72
75
|
-importgen Run importgen to update import maps before publishing
|
|
73
|
-
|
|
76
|
+
(auto-detected from .vscode/tasks.json or an import map in the
|
|
77
|
+
project's HTML — npmglobalize offers to add it to the build script)
|
|
78
|
+
-noimportgen Disable importgen (recorded in .globalize.json5, stops the prompt)
|
|
74
79
|
-once Don't persist flags to .globalize.json5
|
|
75
80
|
|
|
76
81
|
Mode Options:
|
|
@@ -122,10 +127,12 @@ Other Options:
|
|
|
122
127
|
moduleResolution, target es3, deprecated flags). Lists a
|
|
123
128
|
migration to-do list and exits. Writes nothing.
|
|
124
129
|
-tsfix, -ts7-fix
|
|
125
|
-
|
|
126
|
-
and its file: deps, then exit.
|
|
127
|
-
no build, no commit, no push,
|
|
128
|
-
|
|
130
|
+
One-off utility, not the main flow: apply the TS7 tsconfig
|
|
131
|
+
migration to this package and its file: deps, then exit.
|
|
132
|
+
Only tsconfig files change — no build, no commit, no push,
|
|
133
|
+
no publish. The release flow auto-applies the same migration
|
|
134
|
+
on a TS7 build error; use this only for a temporary in-place
|
|
135
|
+
fix of a local directory: npmglobalize <path> -tsfix
|
|
129
136
|
-show Show package.json dependency changes
|
|
130
137
|
-package, -pkg Update package.json scripts to use npmglobalize
|
|
131
138
|
-h, -help Show this help
|
|
@@ -243,6 +250,10 @@ function parseArgs(args) {
|
|
|
243
250
|
case '-force':
|
|
244
251
|
options.force = true;
|
|
245
252
|
break;
|
|
253
|
+
case '-force-build':
|
|
254
|
+
case '-fb':
|
|
255
|
+
options.forceBuild = true;
|
|
256
|
+
break;
|
|
246
257
|
case '-files':
|
|
247
258
|
options.files = true;
|
|
248
259
|
options.explicitKeys.add('files');
|
|
@@ -555,14 +566,14 @@ export async function main() {
|
|
|
555
566
|
// whose source has changed since their last build.
|
|
556
567
|
if (!cliOptions.cleanup) {
|
|
557
568
|
await ensureFileDepModules(cwd, !!cliOptions.verbose);
|
|
558
|
-
const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force });
|
|
569
|
+
const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
|
|
559
570
|
if (!depsOk && !cliOptions.force) {
|
|
560
571
|
printBuildSummary();
|
|
561
572
|
process.exit(1);
|
|
562
573
|
}
|
|
563
574
|
if (!depsOk)
|
|
564
575
|
console.log(colors.warn('Continuing with --force despite dep build failure...'));
|
|
565
|
-
const targetOk = await buildProject(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force });
|
|
576
|
+
const targetOk = await buildProject(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
|
|
566
577
|
if (!targetOk) {
|
|
567
578
|
if (!cliOptions.force) {
|
|
568
579
|
printBuildSummary();
|
package/lib.d.ts
CHANGED
|
@@ -101,6 +101,11 @@ export interface GlobalizeOptions {
|
|
|
101
101
|
once?: boolean;
|
|
102
102
|
/** Run importgen to update import maps before publishing */
|
|
103
103
|
importgen?: boolean;
|
|
104
|
+
/** Sub-directories with their own tsconfig.json that `build` must also compile
|
|
105
|
+
* (e.g. a service worker in `Sw/`). undefined: auto-detect from
|
|
106
|
+
* .vscode/tasks.json + nested tsconfigs. An array pins the list.
|
|
107
|
+
* false: never ask about sub-projects here. */
|
|
108
|
+
subProjects?: string[] | false;
|
|
104
109
|
/** Use filesystem paths for `file:` deps (default true). Set false to
|
|
105
110
|
* mark a package as publishable/installable even when sibling checkouts
|
|
106
111
|
* are absent. Currently declarative (recorded in config and displayed);
|
|
@@ -108,6 +113,8 @@ export interface GlobalizeOptions {
|
|
|
108
113
|
usePaths?: boolean;
|
|
109
114
|
/** Local install only — skip transform/publish, just npm install -g . */
|
|
110
115
|
local?: boolean;
|
|
116
|
+
/** Build every file: dep even when outputs look up to date (skips the freshness check) */
|
|
117
|
+
forceBuild?: boolean;
|
|
111
118
|
/** Freeze node_modules: replace symlinks/junctions with real copies for network share use */
|
|
112
119
|
freeze?: boolean;
|
|
113
120
|
/** Before `npm pack`, delete `node_modules/` inside each `file:` dep target.
|
|
@@ -311,13 +318,62 @@ export declare function missingDeps(pkgDir: string, pkg: any): string[];
|
|
|
311
318
|
* re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
|
|
312
319
|
* Cycle-safe via the shared `visited` set. */
|
|
313
320
|
export declare function ensureFileDepModules(cwd: string, verbose?: boolean, visited?: Set<string>): Promise<void>;
|
|
321
|
+
/** Cheap freshness check so the build cascade can skip packages whose output is
|
|
322
|
+
* already current. Returns true only when provably up to date:
|
|
323
|
+
* - outDir projects: newest source (.ts/.tsx/.mts/.cts + tsconfig*.json) must
|
|
324
|
+
* not be newer than the newest file under outDir.
|
|
325
|
+
* - side-by-side projects: every source must have an emitted sibling
|
|
326
|
+
* (.js/.jsx/.mjs/.cjs or declaration) at least as new.
|
|
327
|
+
* Conservative: project references, allowJs, missing outputs, or unreadable
|
|
328
|
+
* tsconfig all report stale (→ build). package.json mtime is deliberately
|
|
329
|
+
* ignored — npmglobalize itself rewrites it around every publish, which would
|
|
330
|
+
* otherwise force a rebuild on every run. */
|
|
331
|
+
export declare function isBuildUpToDate(cwd: string): boolean;
|
|
332
|
+
/** Detect whether a package is an importgen project — a browser app whose HTML
|
|
333
|
+
* carries a generated `<script type="importmap">`. Signals, in order:
|
|
334
|
+
* 1. `.vscode/tasks.json` runs `importgen` (how these projects are driven
|
|
335
|
+
* today: VS Code starts an importgen watcher on folder open),
|
|
336
|
+
* 2. a root HTML file already contains an import map,
|
|
337
|
+
* 3. importgen is a declared dependency.
|
|
338
|
+
* Returns the HTML file importgen should update plus the signal that matched,
|
|
339
|
+
* or null when this isn't an importgen project. */
|
|
340
|
+
export declare function detectImportgen(cwd: string): {
|
|
341
|
+
htmlFile?: string;
|
|
342
|
+
reason: string;
|
|
343
|
+
} | null;
|
|
344
|
+
/** Wire `importgen` into the package's own `build` script when the project turns
|
|
345
|
+
* out to be an importgen project. The import map is a build product, so it
|
|
346
|
+
* belongs in `npm run build` — that way it regenerates everywhere (VS Code, CI,
|
|
347
|
+
* a bare command line, npmglobalize) instead of only when VS Code happens to
|
|
348
|
+
* start its watcher on folder open. Declining records `importgen: false` in
|
|
349
|
+
* .globalize.json5 so it stops asking. */
|
|
350
|
+
export declare function ensureImportgenInBuild(cwd: string): Promise<void>;
|
|
351
|
+
/** Sub-directories that need their own `tsc -p <dir>` pass. A package can hold
|
|
352
|
+
* more than one tsconfig — a service worker compiled with `lib: WebWorker`, say,
|
|
353
|
+
* which the root tsconfig `exclude`s — and the root `tsc` silently skips them.
|
|
354
|
+
* Signals, in order:
|
|
355
|
+
* 1. `.vscode/tasks.json` runs tsc with `options.cwd` or `-p` pointing at a
|
|
356
|
+
* sub-dir (authoritative: it's how the developer actually builds today),
|
|
357
|
+
* 2. an immediate sub-directory holding its own tsconfig.json.
|
|
358
|
+
* Returns nothing when the root tsconfig uses project `references` — `tsc -b`
|
|
359
|
+
* owns that build graph and shouldn't be second-guessed. */
|
|
360
|
+
export declare function detectSubProjects(cwd: string): Array<{
|
|
361
|
+
dir: string;
|
|
362
|
+
reason: string;
|
|
363
|
+
}>;
|
|
364
|
+
/** Append `tsc -p <dir>` for every sub-project the existing build script misses.
|
|
365
|
+
* Only touches tsc-driven builds — a bundler-driven `build` has its own idea of
|
|
366
|
+
* what to compile. Declining records `subProjects: false` in .globalize.json5. */
|
|
367
|
+
export declare function ensureSubProjectsInBuild(cwd: string): Promise<void>;
|
|
314
368
|
/** Build a single project: detect tsconfig, prompt to add `build: tsc` if a
|
|
315
369
|
* TypeScript project lacks a build script, run `npm run build`, record
|
|
316
370
|
* failures. Returns true if build succeeded (or was skipped because no
|
|
317
|
-
* tsconfig / noEmit / no build script after declining the prompt
|
|
371
|
+
* tsconfig / noEmit / no build script after declining the prompt / output
|
|
372
|
+
* already up to date). Pass `forceBuild` to skip the freshness check. */
|
|
318
373
|
export declare function buildProject(cwd: string, opts?: {
|
|
319
374
|
verbose?: boolean;
|
|
320
375
|
force?: boolean;
|
|
376
|
+
forceBuild?: boolean;
|
|
321
377
|
}): Promise<boolean>;
|
|
322
378
|
/** Walk `file:` deps depth-first (deps before consumers) and build each one
|
|
323
379
|
* that has a tsconfig. Mirrors `ensureFileDepModules`'s traversal but invokes
|
|
@@ -327,6 +383,7 @@ export declare function buildProject(cwd: string, opts?: {
|
|
|
327
383
|
export declare function buildFileDepsTopologically(cwd: string, opts?: {
|
|
328
384
|
verbose?: boolean;
|
|
329
385
|
force?: boolean;
|
|
386
|
+
forceBuild?: boolean;
|
|
330
387
|
}, visited?: Set<string>): Promise<boolean>;
|
|
331
388
|
/** A single TS7-readiness problem found in a package's effective tsconfig. */
|
|
332
389
|
export interface Ts7Finding {
|
package/lib.js
CHANGED
|
@@ -299,6 +299,10 @@ export function writeConfig(dir, config, explicitKeys) {
|
|
|
299
299
|
comment = ' // Resolve file: deps from sibling checkouts (set false for standalone packages)';
|
|
300
300
|
else if (key === 'allowTs')
|
|
301
301
|
comment = ' // Keep .ts source (and *.map, tsconfig.json) in the published npm tarball';
|
|
302
|
+
else if (key === 'importgen')
|
|
303
|
+
comment = ' // Regenerate browser import maps (false = never ask about importgen here)';
|
|
304
|
+
else if (key === 'subProjects')
|
|
305
|
+
comment = ' // Sub-dirs with their own tsconfig that build must compile (false = never ask)';
|
|
302
306
|
lines.push(` "${key}": ${jsonValue}${comma}${comment}`);
|
|
303
307
|
});
|
|
304
308
|
lines.push('');
|
|
@@ -321,6 +325,8 @@ export function writeConfig(dir, config, explicitKeys) {
|
|
|
321
325
|
lines.push(' // "freeze": false // Freeze node_modules (replace symlinks with real copies)');
|
|
322
326
|
lines.push(' // "usePaths": true // Resolve file: deps from siblings; false = use latest npm version (standalone)');
|
|
323
327
|
lines.push(' // "allowTs": false // Include .ts source in npm tarball (auto-true for noEmit projects)');
|
|
328
|
+
lines.push(' // "importgen": auto // Detected from .vscode/tasks.json / import map in HTML; false = never ask');
|
|
329
|
+
lines.push(' // "subProjects": auto // Sub-dirs with their own tsconfig (e.g. ["Sw"]); detected from .vscode/tasks.json; false = never ask');
|
|
324
330
|
lines.push('}');
|
|
325
331
|
fs.writeFileSync(configPath, lines.join('\n') + '\n');
|
|
326
332
|
}
|
|
@@ -1720,6 +1726,41 @@ export function printPnpmSuggestionSummary() {
|
|
|
1720
1726
|
console.error(l);
|
|
1721
1727
|
console.error('');
|
|
1722
1728
|
}
|
|
1729
|
+
/** Run `npm install` in `dir`; on failure, offer to delete `node_modules/`
|
|
1730
|
+
* and `package-lock.json` and retry once — the standard fix for arborist's
|
|
1731
|
+
* null-`package` crash on stale lockfile / junction state — before falling
|
|
1732
|
+
* back to the pnpm suggestion banner. Returns true if install succeeded.
|
|
1733
|
+
* `rmSync` unlinks junctions without descending, so file: sibling targets
|
|
1734
|
+
* are never touched. */
|
|
1735
|
+
async function npmInstallWithCleanRetry(dir, verbose, context) {
|
|
1736
|
+
let r = await runCommandAsync('npm', ['install'], { cwd: dir, silent: !verbose });
|
|
1737
|
+
if (r.success)
|
|
1738
|
+
return true;
|
|
1739
|
+
console.error(colors.red(` ✗ npm install failed in ${dir}`));
|
|
1740
|
+
if (r.stderr)
|
|
1741
|
+
console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
|
|
1742
|
+
if (await confirm(` Delete node_modules/ and package-lock.json in ${dir} and retry npm install?`, true)) {
|
|
1743
|
+
try {
|
|
1744
|
+
fs.rmSync(path.join(dir, 'node_modules'), { recursive: true, force: true });
|
|
1745
|
+
}
|
|
1746
|
+
catch { /* best-effort */ }
|
|
1747
|
+
try {
|
|
1748
|
+
fs.rmSync(path.join(dir, 'package-lock.json'), { force: true });
|
|
1749
|
+
}
|
|
1750
|
+
catch { /* best-effort */ }
|
|
1751
|
+
console.log(colors.yellow(`↻ clean reinstall in ${dir}`));
|
|
1752
|
+
r = await runCommandAsync('npm', ['install'], { cwd: dir, silent: !verbose });
|
|
1753
|
+
if (r.success) {
|
|
1754
|
+
console.log(colors.green(` ✓ clean reinstall succeeded in ${dir}`));
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1757
|
+
console.error(colors.red(` ✗ npm install still failing in ${dir} after clean reinstall`));
|
|
1758
|
+
if (r.stderr)
|
|
1759
|
+
console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
|
|
1760
|
+
}
|
|
1761
|
+
await suggestPnpmOnInstallFailure(context);
|
|
1762
|
+
return false;
|
|
1763
|
+
}
|
|
1723
1764
|
/** Wait for a package version to appear on the npm registry.
|
|
1724
1765
|
* First-time publishes (brand-new package name) take much longer to
|
|
1725
1766
|
* propagate than version bumps — npm has no cached metadata to update,
|
|
@@ -1877,13 +1918,7 @@ export async function ensureFileDepModules(cwd, verbose = false, visited = new S
|
|
|
1877
1918
|
catch { /* best-effort */ }
|
|
1878
1919
|
}
|
|
1879
1920
|
console.log(colors.yellow(`↻ installing node_modules in ${pkg?.name || abs} (${formatMissingReason(cwdMissing)})`));
|
|
1880
|
-
|
|
1881
|
-
if (!r.success) {
|
|
1882
|
-
console.error(colors.red(` ✗ npm install failed in ${abs}`));
|
|
1883
|
-
if (r.stderr)
|
|
1884
|
-
console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
|
|
1885
|
-
await suggestPnpmOnInstallFailure(`ensureFileDepModules (cwd): ${abs}`);
|
|
1886
|
-
}
|
|
1921
|
+
await npmInstallWithCleanRetry(abs, verbose, `ensureFileDepModules (cwd): ${abs}`);
|
|
1887
1922
|
}
|
|
1888
1923
|
for (const key of ['dependencies', 'devDependencies']) {
|
|
1889
1924
|
const deps = pkg?.[key];
|
|
@@ -1915,13 +1950,7 @@ export async function ensureFileDepModules(cwd, verbose = false, visited = new S
|
|
|
1915
1950
|
catch { /* best-effort */ }
|
|
1916
1951
|
}
|
|
1917
1952
|
console.log(colors.yellow(`↻ restoring node_modules in ${name} (${target}) (${formatMissingReason(targetMissing)})`));
|
|
1918
|
-
|
|
1919
|
-
if (!r.success) {
|
|
1920
|
-
console.error(colors.red(` ✗ npm install failed in ${target}`));
|
|
1921
|
-
if (r.stderr)
|
|
1922
|
-
console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
|
|
1923
|
-
await suggestPnpmOnInstallFailure(`ensureFileDepModules (file: dep ${name}): ${target}`);
|
|
1924
|
-
}
|
|
1953
|
+
await npmInstallWithCleanRetry(target, verbose, `ensureFileDepModules (file: dep ${name}): ${target}`);
|
|
1925
1954
|
}
|
|
1926
1955
|
await ensureFileDepModules(target, verbose, visited);
|
|
1927
1956
|
}
|
|
@@ -2244,11 +2273,505 @@ function migrateTsconfigDeprecations(cwd) {
|
|
|
2244
2273
|
}
|
|
2245
2274
|
return changed;
|
|
2246
2275
|
}
|
|
2276
|
+
/** Newest mtime of any regular file under `dir` (recursive). Symlinks/junctions
|
|
2277
|
+
* are not followed — the file: dep webs here link back into sibling checkouts. */
|
|
2278
|
+
function newestMtimeUnder(dir) {
|
|
2279
|
+
let newest = 0;
|
|
2280
|
+
let entries;
|
|
2281
|
+
try {
|
|
2282
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2283
|
+
}
|
|
2284
|
+
catch {
|
|
2285
|
+
return 0;
|
|
2286
|
+
}
|
|
2287
|
+
for (const e of entries) {
|
|
2288
|
+
if (e.isSymbolicLink())
|
|
2289
|
+
continue;
|
|
2290
|
+
const full = path.join(dir, e.name);
|
|
2291
|
+
if (e.isDirectory()) {
|
|
2292
|
+
newest = Math.max(newest, newestMtimeUnder(full));
|
|
2293
|
+
}
|
|
2294
|
+
else if (e.isFile()) {
|
|
2295
|
+
try {
|
|
2296
|
+
newest = Math.max(newest, fs.statSync(full).mtimeMs);
|
|
2297
|
+
}
|
|
2298
|
+
catch { /* ignore */ }
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
return newest;
|
|
2302
|
+
}
|
|
2303
|
+
/** Cheap freshness check so the build cascade can skip packages whose output is
|
|
2304
|
+
* already current. Returns true only when provably up to date:
|
|
2305
|
+
* - outDir projects: newest source (.ts/.tsx/.mts/.cts + tsconfig*.json) must
|
|
2306
|
+
* not be newer than the newest file under outDir.
|
|
2307
|
+
* - side-by-side projects: every source must have an emitted sibling
|
|
2308
|
+
* (.js/.jsx/.mjs/.cjs or declaration) at least as new.
|
|
2309
|
+
* Conservative: project references, allowJs, missing outputs, or unreadable
|
|
2310
|
+
* tsconfig all report stale (→ build). package.json mtime is deliberately
|
|
2311
|
+
* ignored — npmglobalize itself rewrites it around every publish, which would
|
|
2312
|
+
* otherwise force a rebuild on every run. */
|
|
2313
|
+
export function isBuildUpToDate(cwd) {
|
|
2314
|
+
let tsconfig;
|
|
2315
|
+
try {
|
|
2316
|
+
tsconfig = JSON5.parse(fs.readFileSync(path.join(cwd, 'tsconfig.json'), 'utf-8'));
|
|
2317
|
+
}
|
|
2318
|
+
catch {
|
|
2319
|
+
return false;
|
|
2320
|
+
}
|
|
2321
|
+
const co = tsconfig.compilerOptions || {};
|
|
2322
|
+
if (tsconfig.references || co.allowJs || co.checkJs)
|
|
2323
|
+
return false;
|
|
2324
|
+
const outDir = typeof co.outDir === 'string' ? path.resolve(cwd, co.outDir) : null;
|
|
2325
|
+
// tsconfig "exclude" dirs aren't compiled, so their .ts files never get
|
|
2326
|
+
// outputs — don't count them as sources. Match on the literal path prefix
|
|
2327
|
+
// (glob tails like "tests/**" reduce to "tests").
|
|
2328
|
+
const excludes = (Array.isArray(tsconfig.exclude) ? tsconfig.exclude : [])
|
|
2329
|
+
.filter((x) => typeof x === 'string')
|
|
2330
|
+
.map((x) => x.replace(/^\.\//, '').replace(/[\\/]?\*.*$/, '').replace(/[\\/]+$/, '').toLowerCase())
|
|
2331
|
+
.filter((x) => x.length > 0 && !x.includes('*'));
|
|
2332
|
+
const sources = [];
|
|
2333
|
+
const collect = (dir, rel) => {
|
|
2334
|
+
let entries;
|
|
2335
|
+
try {
|
|
2336
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2337
|
+
}
|
|
2338
|
+
catch {
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
for (const e of entries) {
|
|
2342
|
+
if (e.isSymbolicLink())
|
|
2343
|
+
continue;
|
|
2344
|
+
const full = path.join(dir, e.name);
|
|
2345
|
+
const relPath = rel ? `${rel}/${e.name.toLowerCase()}` : e.name.toLowerCase();
|
|
2346
|
+
if (e.isDirectory()) {
|
|
2347
|
+
const lower = e.name.toLowerCase();
|
|
2348
|
+
if (lower === 'node_modules' || lower === 'prev' || e.name.startsWith('.'))
|
|
2349
|
+
continue;
|
|
2350
|
+
if (outDir && path.resolve(full) === outDir)
|
|
2351
|
+
continue;
|
|
2352
|
+
if (excludes.includes(relPath))
|
|
2353
|
+
continue;
|
|
2354
|
+
collect(full, relPath);
|
|
2355
|
+
}
|
|
2356
|
+
else if (/\.(ts|tsx|mts|cts)$/.test(e.name) && !/\.d\.(ts|mts|cts)$/.test(e.name)) {
|
|
2357
|
+
if (excludes.includes(relPath))
|
|
2358
|
+
continue;
|
|
2359
|
+
sources.push(full);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
collect(cwd, '');
|
|
2364
|
+
if (sources.length === 0)
|
|
2365
|
+
return true;
|
|
2366
|
+
const mtime = (p) => {
|
|
2367
|
+
try {
|
|
2368
|
+
return fs.statSync(p).mtimeMs;
|
|
2369
|
+
}
|
|
2370
|
+
catch {
|
|
2371
|
+
return -1;
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
// Any tsconfig*.json edit (own or extended base in the package) invalidates the build.
|
|
2375
|
+
let configTime = 0;
|
|
2376
|
+
try {
|
|
2377
|
+
for (const name of fs.readdirSync(cwd)) {
|
|
2378
|
+
if (/^tsconfig.*\.json$/.test(name))
|
|
2379
|
+
configTime = Math.max(configTime, mtime(path.join(cwd, name)));
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
catch { /* ignore */ }
|
|
2383
|
+
if (outDir) {
|
|
2384
|
+
const newestOut = newestMtimeUnder(outDir);
|
|
2385
|
+
if (newestOut === 0)
|
|
2386
|
+
return false;
|
|
2387
|
+
const newestSrc = Math.max(configTime, ...sources.map(mtime));
|
|
2388
|
+
return newestSrc <= newestOut;
|
|
2389
|
+
}
|
|
2390
|
+
// Side-by-side emit: each source needs an output sibling at least as new.
|
|
2391
|
+
let newestOut = 0;
|
|
2392
|
+
for (const src of sources) {
|
|
2393
|
+
const m = src.match(/\.(ts|tsx|mts|cts)$/);
|
|
2394
|
+
const base = src.slice(0, -m[0].length);
|
|
2395
|
+
const candidates = m[1] === 'mts' ? ['.mjs', '.d.mts']
|
|
2396
|
+
: m[1] === 'cts' ? ['.cjs', '.d.cts']
|
|
2397
|
+
: ['.js', '.jsx', '.d.ts'];
|
|
2398
|
+
let outTime = -1;
|
|
2399
|
+
for (const ext of candidates)
|
|
2400
|
+
outTime = Math.max(outTime, mtime(base + ext));
|
|
2401
|
+
if (outTime < 0)
|
|
2402
|
+
return false;
|
|
2403
|
+
if (mtime(src) > outTime)
|
|
2404
|
+
return false;
|
|
2405
|
+
newestOut = Math.max(newestOut, outTime);
|
|
2406
|
+
}
|
|
2407
|
+
return configTime <= newestOut;
|
|
2408
|
+
}
|
|
2409
|
+
/** HTML files importgen looks for when none is named, in its preference order. */
|
|
2410
|
+
const IMPORTGEN_HTML_CANDIDATES = ['index.html', 'default.html', 'default.htm'];
|
|
2411
|
+
/** Resolve the HTML file importgen would update in `cwd`. */
|
|
2412
|
+
function findImportgenHtml(cwd, preferred) {
|
|
2413
|
+
if (preferred && fs.existsSync(path.join(cwd, preferred)))
|
|
2414
|
+
return preferred;
|
|
2415
|
+
for (const name of IMPORTGEN_HTML_CANDIDATES) {
|
|
2416
|
+
if (fs.existsSync(path.join(cwd, name)))
|
|
2417
|
+
return name;
|
|
2418
|
+
}
|
|
2419
|
+
return undefined;
|
|
2420
|
+
}
|
|
2421
|
+
/** True when a script string invokes importgen (`importgen`, `npx importgen`,
|
|
2422
|
+
* `importgen default.htm`, …) — used to tell a wired build from an unwired one. */
|
|
2423
|
+
function scriptRunsImportgen(script) {
|
|
2424
|
+
return /(^|[\s/\\&|])importgen(\s|$)/.test(script);
|
|
2425
|
+
}
|
|
2426
|
+
/** Detect whether a package is an importgen project — a browser app whose HTML
|
|
2427
|
+
* carries a generated `<script type="importmap">`. Signals, in order:
|
|
2428
|
+
* 1. `.vscode/tasks.json` runs `importgen` (how these projects are driven
|
|
2429
|
+
* today: VS Code starts an importgen watcher on folder open),
|
|
2430
|
+
* 2. a root HTML file already contains an import map,
|
|
2431
|
+
* 3. importgen is a declared dependency.
|
|
2432
|
+
* Returns the HTML file importgen should update plus the signal that matched,
|
|
2433
|
+
* or null when this isn't an importgen project. */
|
|
2434
|
+
export function detectImportgen(cwd) {
|
|
2435
|
+
// 1. .vscode/tasks.json — JSON5 because VS Code allows comments/trailing commas
|
|
2436
|
+
try {
|
|
2437
|
+
const tasks = JSON5.parse(fs.readFileSync(path.join(cwd, '.vscode', 'tasks.json'), 'utf-8'));
|
|
2438
|
+
for (const task of Array.isArray(tasks?.tasks) ? tasks.tasks : []) {
|
|
2439
|
+
const command = typeof task?.command === 'string' ? task.command : '';
|
|
2440
|
+
const args = Array.isArray(task?.args) ? task.args.filter((a) => typeof a === 'string') : [];
|
|
2441
|
+
if (!scriptRunsImportgen(command))
|
|
2442
|
+
continue;
|
|
2443
|
+
const named = args.find(a => /\.html?$/i.test(a));
|
|
2444
|
+
return { htmlFile: findImportgenHtml(cwd, named), reason: '.vscode/tasks.json runs importgen' };
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
catch {
|
|
2448
|
+
// No tasks.json, or unparsable — fall through to the other signals
|
|
2449
|
+
}
|
|
2450
|
+
// 2. A root HTML file that already holds an import map. Check importgen's own
|
|
2451
|
+
// candidates first so `default.htm` wins over a stray `temp.htm` copy.
|
|
2452
|
+
let htmlFiles = [];
|
|
2453
|
+
try {
|
|
2454
|
+
htmlFiles = fs.readdirSync(cwd).filter(f => /\.html?$/i.test(f));
|
|
2455
|
+
}
|
|
2456
|
+
catch {
|
|
2457
|
+
// Unreadable directory — nothing to detect
|
|
2458
|
+
}
|
|
2459
|
+
htmlFiles.sort((a, b) => {
|
|
2460
|
+
const rank = (f) => {
|
|
2461
|
+
const i = IMPORTGEN_HTML_CANDIDATES.indexOf(f.toLowerCase());
|
|
2462
|
+
return i === -1 ? IMPORTGEN_HTML_CANDIDATES.length : i;
|
|
2463
|
+
};
|
|
2464
|
+
return rank(a) - rank(b) || a.localeCompare(b);
|
|
2465
|
+
});
|
|
2466
|
+
for (const name of htmlFiles) {
|
|
2467
|
+
try {
|
|
2468
|
+
const html = fs.readFileSync(path.join(cwd, name), 'utf-8');
|
|
2469
|
+
if (/<script\s+type=["']importmap["']/i.test(html)) {
|
|
2470
|
+
return { htmlFile: name, reason: `${name} contains an import map` };
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
catch {
|
|
2474
|
+
// Unreadable file — try the next one
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
// 3. importgen declared as a dependency AND an HTML file to inject into.
|
|
2478
|
+
// The HTML requirement matters: a package can depend on importgen as a
|
|
2479
|
+
// library (npmglobalize itself does) without being a browser app.
|
|
2480
|
+
const htmlFile = findImportgenHtml(cwd);
|
|
2481
|
+
if (htmlFile) {
|
|
2482
|
+
try {
|
|
2483
|
+
const pkg = readPackageJson(cwd);
|
|
2484
|
+
for (const key of ['dependencies', 'devDependencies']) {
|
|
2485
|
+
const deps = pkg?.[key];
|
|
2486
|
+
if (!deps || typeof deps !== 'object')
|
|
2487
|
+
continue;
|
|
2488
|
+
if (Object.keys(deps).some(n => n === 'importgen' || n.endsWith('/importgen'))) {
|
|
2489
|
+
return { htmlFile, reason: `importgen is in ${key}` };
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
catch {
|
|
2494
|
+
// No package.json — caller handles that
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
return null;
|
|
2498
|
+
}
|
|
2499
|
+
/** Wire `importgen` into the package's own `build` script when the project turns
|
|
2500
|
+
* out to be an importgen project. The import map is a build product, so it
|
|
2501
|
+
* belongs in `npm run build` — that way it regenerates everywhere (VS Code, CI,
|
|
2502
|
+
* a bare command line, npmglobalize) instead of only when VS Code happens to
|
|
2503
|
+
* start its watcher on folder open. Declining records `importgen: false` in
|
|
2504
|
+
* .globalize.json5 so it stops asking. */
|
|
2505
|
+
export async function ensureImportgenInBuild(cwd) {
|
|
2506
|
+
const config = readConfig(cwd);
|
|
2507
|
+
if (config.importgen === false)
|
|
2508
|
+
return;
|
|
2509
|
+
let pkg;
|
|
2510
|
+
try {
|
|
2511
|
+
pkg = readPackageJson(cwd);
|
|
2512
|
+
}
|
|
2513
|
+
catch {
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
const buildScript = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
|
|
2517
|
+
if (scriptRunsImportgen(buildScript))
|
|
2518
|
+
return; // already wired
|
|
2519
|
+
const detected = config.importgen === true
|
|
2520
|
+
? { htmlFile: findImportgenHtml(cwd), reason: 'importgen: true in .globalize.json5' }
|
|
2521
|
+
: detectImportgen(cwd);
|
|
2522
|
+
if (!detected)
|
|
2523
|
+
return;
|
|
2524
|
+
const name = pkg.name || path.basename(cwd);
|
|
2525
|
+
// Name the HTML file explicitly: importgen's own search order may pick a
|
|
2526
|
+
// different file than the one this project actually ships.
|
|
2527
|
+
const command = detected.htmlFile ? `importgen ${detected.htmlFile}` : 'importgen';
|
|
2528
|
+
const newScript = buildScript ? `${command} && ${buildScript}` : command;
|
|
2529
|
+
console.log(colors.yellow(`${name} looks like an importgen project (${detected.reason}) but its build script doesn't run importgen.`));
|
|
2530
|
+
const addIt = await confirm(`Set "build": "${newScript}" in ${name}'s package.json?`, true);
|
|
2531
|
+
if (!addIt) {
|
|
2532
|
+
writeConfig(cwd, { ...config, importgen: false }, new Set(['importgen']));
|
|
2533
|
+
console.log(colors.dim(` Recorded importgen: false in .globalize.json5 — won't ask again.`));
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
if (!pkg.scripts)
|
|
2537
|
+
pkg.scripts = {};
|
|
2538
|
+
pkg.scripts.build = newScript;
|
|
2539
|
+
writePackageJson(cwd, pkg);
|
|
2540
|
+
console.log(colors.green(`✓ Added importgen to ${name}'s build script`));
|
|
2541
|
+
}
|
|
2542
|
+
/** True when the generated import map is at least as new as the package.json it
|
|
2543
|
+
* is generated from. `isBuildUpToDate` only compares .ts sources to emitted
|
|
2544
|
+
* .js, so it can't see a dependency added to package.json — which is exactly
|
|
2545
|
+
* what changes an import map. */
|
|
2546
|
+
function isImportMapUpToDate(cwd) {
|
|
2547
|
+
const htmlFile = findImportgenHtml(cwd, detectImportgen(cwd)?.htmlFile);
|
|
2548
|
+
if (!htmlFile)
|
|
2549
|
+
return false; // can't tell which file — let the build run
|
|
2550
|
+
const mtime = (p) => { try {
|
|
2551
|
+
return fs.statSync(p).mtimeMs;
|
|
2552
|
+
}
|
|
2553
|
+
catch {
|
|
2554
|
+
return 0;
|
|
2555
|
+
} };
|
|
2556
|
+
const htmlTime = mtime(path.join(cwd, htmlFile));
|
|
2557
|
+
return htmlTime > 0 && htmlTime >= mtime(path.join(cwd, 'package.json'));
|
|
2558
|
+
}
|
|
2559
|
+
/** Directories never worth scanning for a nested tsconfig — build output, vendored
|
|
2560
|
+
* code, and the prev/ archive all carry stray tsconfigs that aren't sub-projects. */
|
|
2561
|
+
const SUBPROJECT_SKIP_DIRS = new Set([
|
|
2562
|
+
'node_modules', 'prev', 'built', 'build', 'dist', 'out', 'output',
|
|
2563
|
+
'wwwroot', 'coverage', 'temp', 'tmp', 'preflight'
|
|
2564
|
+
]);
|
|
2565
|
+
/** True when a script string invokes `tsc` (`tsc`, `npx tsc`, `tsc -p Sw`, …). */
|
|
2566
|
+
function scriptRunsTsc(script) {
|
|
2567
|
+
return /(^|[\s&|(])(npx\s+)?tsc(\s|$)/.test(script);
|
|
2568
|
+
}
|
|
2569
|
+
/** Normalize a path as written in a script/task (quotes, backslashes, `./`,
|
|
2570
|
+
* a trailing `tsconfig.json`) down to a comparable relative directory. */
|
|
2571
|
+
function normalizeProjectPath(raw) {
|
|
2572
|
+
return raw
|
|
2573
|
+
.replace(/^["']|["']$/g, '')
|
|
2574
|
+
.replace(/\$\{workspaceFolder\}/gi, '')
|
|
2575
|
+
.replace(/\\/g, '/')
|
|
2576
|
+
.replace(/^\.\//, '')
|
|
2577
|
+
.replace(/\/tsconfig\.json$/i, '')
|
|
2578
|
+
.replace(/^\/+|\/+$/g, '');
|
|
2579
|
+
}
|
|
2580
|
+
/** Match `rel` against what's actually on disk so the emitted script uses the
|
|
2581
|
+
* real casing (`Sw`, not `sw`) — Windows compares case-insensitively, but the
|
|
2582
|
+
* script also has to run on WSL/CI where it doesn't. Returns null if absent. */
|
|
2583
|
+
function resolveOnDiskCase(base, rel) {
|
|
2584
|
+
const parts = rel.split('/').filter(Boolean);
|
|
2585
|
+
let dir = base;
|
|
2586
|
+
const out = [];
|
|
2587
|
+
for (const part of parts) {
|
|
2588
|
+
let entries;
|
|
2589
|
+
try {
|
|
2590
|
+
entries = fs.readdirSync(dir);
|
|
2591
|
+
}
|
|
2592
|
+
catch {
|
|
2593
|
+
return null;
|
|
2594
|
+
}
|
|
2595
|
+
const match = entries.find(e => e.toLowerCase() === part.toLowerCase());
|
|
2596
|
+
if (!match)
|
|
2597
|
+
return null;
|
|
2598
|
+
out.push(match);
|
|
2599
|
+
dir = path.join(dir, match);
|
|
2600
|
+
}
|
|
2601
|
+
return out.length ? out.join('/') : null;
|
|
2602
|
+
}
|
|
2603
|
+
/** True when `script` already compiles the tsconfig in `dir` — `tsc -p Sw`,
|
|
2604
|
+
* `tsc --project ./Sw/tsconfig.json`, or `cd Sw && tsc`. */
|
|
2605
|
+
function scriptBuildsSubProject(script, dir) {
|
|
2606
|
+
const target = normalizeProjectPath(dir).toLowerCase();
|
|
2607
|
+
if (!target)
|
|
2608
|
+
return false;
|
|
2609
|
+
for (const re of [/(?:^|\s)(?:-p|--project)[\s=]+("[^"]+"|'[^']+'|\S+)/g,
|
|
2610
|
+
/(?:^|[\s&|(])cd\s+("[^"]+"|'[^']+'|\S+)/g]) {
|
|
2611
|
+
for (const m of script.matchAll(re)) {
|
|
2612
|
+
if (normalizeProjectPath(m[1]).toLowerCase() === target)
|
|
2613
|
+
return true;
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
return false;
|
|
2617
|
+
}
|
|
2618
|
+
/** Sub-directories that need their own `tsc -p <dir>` pass. A package can hold
|
|
2619
|
+
* more than one tsconfig — a service worker compiled with `lib: WebWorker`, say,
|
|
2620
|
+
* which the root tsconfig `exclude`s — and the root `tsc` silently skips them.
|
|
2621
|
+
* Signals, in order:
|
|
2622
|
+
* 1. `.vscode/tasks.json` runs tsc with `options.cwd` or `-p` pointing at a
|
|
2623
|
+
* sub-dir (authoritative: it's how the developer actually builds today),
|
|
2624
|
+
* 2. an immediate sub-directory holding its own tsconfig.json.
|
|
2625
|
+
* Returns nothing when the root tsconfig uses project `references` — `tsc -b`
|
|
2626
|
+
* owns that build graph and shouldn't be second-guessed. */
|
|
2627
|
+
export function detectSubProjects(cwd) {
|
|
2628
|
+
try {
|
|
2629
|
+
const root = JSON5.parse(fs.readFileSync(path.join(cwd, 'tsconfig.json'), 'utf-8'));
|
|
2630
|
+
if (Array.isArray(root?.references) && root.references.length > 0)
|
|
2631
|
+
return [];
|
|
2632
|
+
}
|
|
2633
|
+
catch {
|
|
2634
|
+
// No root tsconfig (or unparsable) — nested ones still count
|
|
2635
|
+
}
|
|
2636
|
+
const found = new Map(); // on-disk relative dir → reason
|
|
2637
|
+
const add = (raw, reason) => {
|
|
2638
|
+
const rel = normalizeProjectPath(raw);
|
|
2639
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
|
|
2640
|
+
return;
|
|
2641
|
+
if (rel.split('/').some(p => SUBPROJECT_SKIP_DIRS.has(p.toLowerCase()) || p.startsWith('.')))
|
|
2642
|
+
return;
|
|
2643
|
+
const onDisk = resolveOnDiskCase(cwd, rel);
|
|
2644
|
+
if (!onDisk)
|
|
2645
|
+
return;
|
|
2646
|
+
if (!fs.existsSync(path.join(cwd, onDisk, 'tsconfig.json')))
|
|
2647
|
+
return;
|
|
2648
|
+
if (!found.has(onDisk))
|
|
2649
|
+
found.set(onDisk, reason);
|
|
2650
|
+
};
|
|
2651
|
+
// 1. .vscode/tasks.json — JSON5 because VS Code allows comments/trailing commas
|
|
2652
|
+
try {
|
|
2653
|
+
const tasks = JSON5.parse(fs.readFileSync(path.join(cwd, '.vscode', 'tasks.json'), 'utf-8'));
|
|
2654
|
+
for (const task of Array.isArray(tasks?.tasks) ? tasks.tasks : []) {
|
|
2655
|
+
const command = typeof task?.command === 'string' ? task.command : '';
|
|
2656
|
+
const args = Array.isArray(task?.args) ? task.args.filter((a) => typeof a === 'string') : [];
|
|
2657
|
+
if (!scriptRunsTsc([command, ...args].join(' ')))
|
|
2658
|
+
continue;
|
|
2659
|
+
const label = typeof task?.label === 'string' ? task.label : 'a tsc task';
|
|
2660
|
+
const projIdx = args.findIndex(a => a === '-p' || a === '--project');
|
|
2661
|
+
if (projIdx !== -1 && args[projIdx + 1]) {
|
|
2662
|
+
add(args[projIdx + 1], `.vscode/tasks.json "${label}" builds it`);
|
|
2663
|
+
}
|
|
2664
|
+
else if (typeof task?.options?.cwd === 'string') {
|
|
2665
|
+
add(task.options.cwd, `.vscode/tasks.json "${label}" runs tsc there`);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
catch {
|
|
2670
|
+
// No tasks.json, or unparsable — fall through to the filesystem scan
|
|
2671
|
+
}
|
|
2672
|
+
// 2. Immediate sub-directories with their own tsconfig.json
|
|
2673
|
+
try {
|
|
2674
|
+
for (const e of fs.readdirSync(cwd, { withFileTypes: true })) {
|
|
2675
|
+
if (!e.isDirectory() || e.isSymbolicLink())
|
|
2676
|
+
continue;
|
|
2677
|
+
if (e.name.startsWith('.') || SUBPROJECT_SKIP_DIRS.has(e.name.toLowerCase()))
|
|
2678
|
+
continue;
|
|
2679
|
+
if (fs.existsSync(path.join(cwd, e.name, 'tsconfig.json'))) {
|
|
2680
|
+
add(e.name, `${e.name}/tsconfig.json`);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
catch {
|
|
2685
|
+
// Unreadable directory — nothing more to detect
|
|
2686
|
+
}
|
|
2687
|
+
return [...found].map(([dir, reason]) => ({ dir, reason }));
|
|
2688
|
+
}
|
|
2689
|
+
/** The sub-projects to wire, honoring `.globalize.json5`: `false` disables the
|
|
2690
|
+
* whole check, an array pins the list instead of detecting it. */
|
|
2691
|
+
function configuredSubProjects(cwd, config) {
|
|
2692
|
+
if (config.subProjects === false)
|
|
2693
|
+
return [];
|
|
2694
|
+
if (Array.isArray(config.subProjects)) {
|
|
2695
|
+
return config.subProjects
|
|
2696
|
+
.map(d => ({ dir: resolveOnDiskCase(cwd, normalizeProjectPath(d)) || d, reason: 'listed in .globalize.json5' }))
|
|
2697
|
+
.filter(s => fs.existsSync(path.join(cwd, s.dir, 'tsconfig.json')));
|
|
2698
|
+
}
|
|
2699
|
+
return detectSubProjects(cwd);
|
|
2700
|
+
}
|
|
2701
|
+
/** Quote a path for a package.json script only when it needs it. */
|
|
2702
|
+
function quoteScriptPath(p) {
|
|
2703
|
+
return /[\s&|<>]/.test(p) ? `"${p}"` : p;
|
|
2704
|
+
}
|
|
2705
|
+
/** Append `tsc -p <dir>` for every sub-project the existing build script misses.
|
|
2706
|
+
* Only touches tsc-driven builds — a bundler-driven `build` has its own idea of
|
|
2707
|
+
* what to compile. Declining records `subProjects: false` in .globalize.json5. */
|
|
2708
|
+
export async function ensureSubProjectsInBuild(cwd) {
|
|
2709
|
+
const config = readConfig(cwd);
|
|
2710
|
+
if (config.subProjects === false)
|
|
2711
|
+
return;
|
|
2712
|
+
let pkg;
|
|
2713
|
+
try {
|
|
2714
|
+
pkg = readPackageJson(cwd);
|
|
2715
|
+
}
|
|
2716
|
+
catch {
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const buildScript = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build.trim() : '';
|
|
2720
|
+
if (!buildScript || !scriptRunsTsc(buildScript))
|
|
2721
|
+
return;
|
|
2722
|
+
const missing = configuredSubProjects(cwd, config)
|
|
2723
|
+
.filter(s => !scriptBuildsSubProject(buildScript, s.dir));
|
|
2724
|
+
if (missing.length === 0)
|
|
2725
|
+
return;
|
|
2726
|
+
const name = pkg.name || path.basename(cwd);
|
|
2727
|
+
const newScript = [buildScript, ...missing.map(s => `tsc -p ${quoteScriptPath(s.dir)}`)].join(' && ');
|
|
2728
|
+
// Default to yes only when tasks.json says the developer already builds these
|
|
2729
|
+
// — a bare nested tsconfig (tests, examples) is a weaker signal, and this
|
|
2730
|
+
// prompt rewrites a build script that currently works.
|
|
2731
|
+
const strong = missing.every(s => s.reason.startsWith('.vscode/tasks.json') || s.reason.startsWith('listed in'));
|
|
2732
|
+
console.log(colors.yellow(`${name}'s build script doesn't compile ${missing.length === 1 ? 'a sub-project' : 'its sub-projects'}:`));
|
|
2733
|
+
for (const s of missing)
|
|
2734
|
+
console.log(colors.yellow(` ${s.dir}/ — ${s.reason}`));
|
|
2735
|
+
const addIt = await confirm(`Set "build": "${newScript}" in ${name}'s package.json?`, strong);
|
|
2736
|
+
if (!addIt) {
|
|
2737
|
+
writeConfig(cwd, { ...config, subProjects: false }, new Set(['subProjects']));
|
|
2738
|
+
console.log(colors.dim(` Recorded subProjects: false in .globalize.json5 — won't ask again.`));
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
pkg.scripts.build = newScript;
|
|
2742
|
+
writePackageJson(cwd, pkg);
|
|
2743
|
+
console.log(colors.green(`✓ Added ${missing.map(s => `tsc -p ${s.dir}`).join(' && ')} to ${name}'s build script`));
|
|
2744
|
+
}
|
|
2745
|
+
/** Freshness for the sub-projects the build script actually compiles. A
|
|
2746
|
+
* sub-project that emits outside its own directory (a service worker with
|
|
2747
|
+
* `outDir: ".."`, say) reports stale: `isBuildUpToDate` would then be comparing
|
|
2748
|
+
* its sources against the whole package, which proves nothing. */
|
|
2749
|
+
function areSubProjectsUpToDate(cwd, subs) {
|
|
2750
|
+
for (const s of subs) {
|
|
2751
|
+
const dir = path.join(cwd, s.dir);
|
|
2752
|
+
let co;
|
|
2753
|
+
try {
|
|
2754
|
+
co = JSON5.parse(fs.readFileSync(path.join(dir, 'tsconfig.json'), 'utf-8')).compilerOptions || {};
|
|
2755
|
+
}
|
|
2756
|
+
catch {
|
|
2757
|
+
return false;
|
|
2758
|
+
}
|
|
2759
|
+
if (typeof co.outDir === 'string' && path.relative(dir, path.resolve(dir, co.outDir)).startsWith('..'))
|
|
2760
|
+
return false;
|
|
2761
|
+
if (!isBuildUpToDate(dir))
|
|
2762
|
+
return false;
|
|
2763
|
+
}
|
|
2764
|
+
return true;
|
|
2765
|
+
}
|
|
2247
2766
|
/** Build a single project: detect tsconfig, prompt to add `build: tsc` if a
|
|
2248
2767
|
* TypeScript project lacks a build script, run `npm run build`, record
|
|
2249
2768
|
* failures. Returns true if build succeeded (or was skipped because no
|
|
2250
|
-
* tsconfig / noEmit / no build script after declining the prompt
|
|
2769
|
+
* tsconfig / noEmit / no build script after declining the prompt / output
|
|
2770
|
+
* already up to date). Pass `forceBuild` to skip the freshness check. */
|
|
2251
2771
|
export async function buildProject(cwd, opts = {}) {
|
|
2772
|
+
// Do this before the tsconfig check: an importgen project may be plain JS
|
|
2773
|
+
// with no tsconfig at all, and still need its import map regenerated.
|
|
2774
|
+
await ensureImportgenInBuild(cwd);
|
|
2252
2775
|
let shouldBuild = false;
|
|
2253
2776
|
try {
|
|
2254
2777
|
const tsconfigPath = path.join(cwd, 'tsconfig.json');
|
|
@@ -2259,24 +2782,63 @@ export async function buildProject(cwd, opts = {}) {
|
|
|
2259
2782
|
catch {
|
|
2260
2783
|
// No tsconfig — skip
|
|
2261
2784
|
}
|
|
2262
|
-
|
|
2785
|
+
let earlyPkg;
|
|
2786
|
+
try {
|
|
2787
|
+
earlyPkg = readPackageJson(cwd);
|
|
2788
|
+
}
|
|
2789
|
+
catch {
|
|
2263
2790
|
return true;
|
|
2264
|
-
|
|
2265
|
-
|
|
2791
|
+
}
|
|
2792
|
+
const runsImportgen = scriptRunsImportgen(typeof earlyPkg.scripts?.build === 'string' ? earlyPkg.scripts.build : '');
|
|
2793
|
+
// A build script that regenerates the import map is worth running even when
|
|
2794
|
+
// there's nothing for tsc to emit.
|
|
2795
|
+
if (!shouldBuild && !runsImportgen)
|
|
2796
|
+
return true;
|
|
2797
|
+
const pkg = earlyPkg;
|
|
2798
|
+
const existingBuild = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build.trim() : '';
|
|
2799
|
+
// A build script ensureImportgenInBuild just created for a package that had
|
|
2800
|
+
// none still needs its tsc pass — treat importgen-only as "no build yet".
|
|
2801
|
+
const importgenOnly = !!existingBuild && existingBuild.split(/\s*&&\s*/).every(scriptRunsImportgen);
|
|
2802
|
+
if (shouldBuild && (!existingBuild || importgenOnly)) {
|
|
2803
|
+
// Compile the sub-projects (service worker, …) in the same breath: the
|
|
2804
|
+
// root tsc excludes them, so a bare "tsc" would leave them unbuilt.
|
|
2805
|
+
const subs = configuredSubProjects(cwd, readConfig(cwd));
|
|
2806
|
+
const tscParts = ['tsc', ...subs.map(s => `tsc -p ${quoteScriptPath(s.dir)}`)];
|
|
2807
|
+
const newScript = [...(existingBuild ? [existingBuild] : []), ...tscParts].join(' && ');
|
|
2266
2808
|
console.log(colors.yellow(`TypeScript project has no "build" script in ${pkg.name || cwd}`));
|
|
2267
|
-
|
|
2809
|
+
for (const s of subs)
|
|
2810
|
+
console.log(colors.yellow(` sub-project ${s.dir}/ — ${s.reason}`));
|
|
2811
|
+
const addIt = await confirm(`Add "build": "${newScript}" to ${pkg.name || path.basename(cwd)}'s package.json?`, true);
|
|
2268
2812
|
if (addIt) {
|
|
2269
2813
|
if (!pkg.scripts)
|
|
2270
2814
|
pkg.scripts = {};
|
|
2271
|
-
pkg.scripts.build =
|
|
2815
|
+
pkg.scripts.build = newScript;
|
|
2272
2816
|
writePackageJson(cwd, pkg);
|
|
2273
|
-
console.log(colors.green(`✓ Added "build": "
|
|
2817
|
+
console.log(colors.green(`✓ Added "build": "${newScript}" to ${pkg.name || path.basename(cwd)}`));
|
|
2274
2818
|
}
|
|
2275
2819
|
else {
|
|
2276
2820
|
return true;
|
|
2277
2821
|
}
|
|
2278
2822
|
}
|
|
2823
|
+
else if (existingBuild) {
|
|
2824
|
+
await ensureSubProjectsInBuild(cwd);
|
|
2825
|
+
try {
|
|
2826
|
+
const refreshed = readPackageJson(cwd);
|
|
2827
|
+
if (typeof refreshed.scripts?.build === 'string')
|
|
2828
|
+
pkg.scripts.build = refreshed.scripts.build;
|
|
2829
|
+
}
|
|
2830
|
+
catch { /* keep the script we already have */ }
|
|
2831
|
+
}
|
|
2279
2832
|
ensureTsconfigNodeTypes(cwd);
|
|
2833
|
+
// Only the sub-projects the final build script really compiles count toward
|
|
2834
|
+
// freshness — one the user declined to wire in isn't this build's business.
|
|
2835
|
+
const finalBuild = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
|
|
2836
|
+
const wiredSubs = detectSubProjects(cwd).filter(s => scriptBuildsSubProject(finalBuild, s.dir));
|
|
2837
|
+
if (!opts.forceBuild && isBuildUpToDate(cwd) && areSubProjectsUpToDate(cwd, wiredSubs)
|
|
2838
|
+
&& (!runsImportgen || isImportMapUpToDate(cwd))) {
|
|
2839
|
+
console.log(colors.dim(`– Build up to date (${pkg.name || path.basename(cwd)})`));
|
|
2840
|
+
return true;
|
|
2841
|
+
}
|
|
2280
2842
|
console.log(`Building ${pkg.name || cwd}...`);
|
|
2281
2843
|
let buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
|
|
2282
2844
|
if (!buildResult.success) {
|
|
@@ -2549,13 +3111,7 @@ export async function ensureWorkspaceDepModules(rootDir, members, verbose = fals
|
|
|
2549
3111
|
return;
|
|
2550
3112
|
const list = [...allMissing];
|
|
2551
3113
|
console.log(colors.yellow(`↻ installing workspace node_modules in ${rootPkg?.name || path.basename(root)} (${formatMissingReason(list)})`));
|
|
2552
|
-
|
|
2553
|
-
if (!r.success) {
|
|
2554
|
-
console.error(colors.red(` ✗ npm install failed in ${root}`));
|
|
2555
|
-
if (r.stderr)
|
|
2556
|
-
console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
|
|
2557
|
-
await suggestPnpmOnInstallFailure(`ensureWorkspaceDepModules: ${root}`);
|
|
2558
|
-
}
|
|
3114
|
+
await npmInstallWithCleanRetry(root, verbose, `ensureWorkspaceDepModules: ${root}`);
|
|
2559
3115
|
}
|
|
2560
3116
|
/** Extract the lines a human needs from a failed npm command's output: the
|
|
2561
3117
|
* `npm error` lines minus stack frames and brace-only object-dump fragments,
|
|
@@ -5045,8 +5601,10 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
5045
5601
|
console.log(' [dry-run] Would run: npm run build');
|
|
5046
5602
|
}
|
|
5047
5603
|
}
|
|
5048
|
-
// Run importgen if enabled
|
|
5049
|
-
|
|
5604
|
+
// Run importgen if explicitly enabled and the build script isn't already
|
|
5605
|
+
// doing it (the CLI wires it into `build` for detected importgen projects).
|
|
5606
|
+
const buildRunsImportgen = scriptRunsImportgen(typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '');
|
|
5607
|
+
if (options.importgen && !buildRunsImportgen) {
|
|
5050
5608
|
try {
|
|
5051
5609
|
if (!dryRun) {
|
|
5052
5610
|
const igResult = runImportgen(cwd);
|