From ea24c4d5284a7a0ec661de499ca5929d8985bdb8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 28 Jul 2026 17:31:15 -0300 Subject: [PATCH] build: emit to dist/ and ship generated declarations Compiling next to the sources has cost us repeatedly: a deleted spec left its .js behind and kept running for ten months, prepack silently undid its own release compile because both wrote to the same place, and .gitignore needs a blanket *.js plus a growing list of negations to tell source from output apart. dist/ is assembled as a complete package root rather than just compiled output. lib/ resolves its siblings through __dirname - ../package.json, ../docs/helpers, ../../config, ../../vendor/gradle-plugin - so resources, docs, config, vendor, bin and setup are mirrored alongside it and all 46 of those paths keep working untouched. The published tarball has the same internal layout as before; only where it is built from changed. - tsconfig gains rootDir/outDir; tsconfig.release.json builds lib only, with declarations, and is what gets packed - scripts/copy-assets.js mirrors assets and writes dist/package.json - packing is npm run pack (npm pack ./dist); prepack/postpack are gone and a guard refuses to pack the root, which would nest everything a level deeper - the GA id is now set inside dist, so a failed pack can no longer leave a checkout configured to report as production A .js with a sibling .ts is compiler output, so the copy step skips it - without that it would overwrite what tsc just emitted with whatever the old in-place build left behind. Hand-written .d.ts are copied too: tsc treats them as inputs and never emits them, and 115 generated declarations import from them. ProjectBackupService.Backup is annotated because declaration emit cannot describe an anonymous class expression that has private members. 1513 passing, unchanged. Verified by packing and installing the tarball into a clean consumer: the CLI runs, help renders, the sibling paths resolve, and the shipped config carries the live GA id while the working tree keeps dev. --- .github/workflows/npm_release_cli.yml | 2 +- .gitignore | 4 +- lib/services/project-backup-service.ts | 23 ++-- package.json | 29 ++--- scripts/clean.js | 16 ++- scripts/copy-assets.js | 150 +++++++++++++++++++++++++ scripts/guard-root-pack.js | 16 +++ scripts/set-ga-id.js | 15 ++- test/.mocharc.yml | 10 +- tsconfig.json | 4 + tsconfig.release.json | 7 +- 11 files changed, 232 insertions(+), 44 deletions(-) create mode 100644 scripts/copy-assets.js create mode 100644 scripts/guard-root-pack.js diff --git a/.github/workflows/npm_release_cli.yml b/.github/workflows/npm_release_cli.yml index 43345790a5..bb56be5521 100644 --- a/.github/workflows/npm_release_cli.yml +++ b/.github/workflows/npm_release_cli.yml @@ -105,7 +105,7 @@ jobs: echo IS_RELEASE=$IS_RELEASE >> $GITHUB_OUTPUT - name: Build nativescript - run: npm pack + run: npm run pack - name: Upload npm package artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/.gitignore b/.gitignore index 97f829c89f..c12b729bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,6 @@ lib/common/test-reports.xml !lib/common/test-scripts/** !lib/common/scripts/** config/test-deps-versions-generated.json -!scripts/*.js \ No newline at end of file +!scripts/*.js +# build output +/dist diff --git a/lib/services/project-backup-service.ts b/lib/services/project-backup-service.ts index b265d8dd00..0ea21be44c 100644 --- a/lib/services/project-backup-service.ts +++ b/lib/services/project-backup-service.ts @@ -8,7 +8,7 @@ export class ProjectBackupService implements IProjectBackupService { constructor( protected $fs: IFileSystem, protected $logger: ILogger, - protected $projectHelper: IProjectHelper + protected $projectHelper: IProjectHelper, ) {} getBackup(backupName: string): IBackup { @@ -25,12 +25,19 @@ export class ProjectBackupService implements IProjectBackupService { return backup.restore(); } - static Backup = class Backup implements IBackup { + // annotated so declaration emit has a nameable type: an anonymous class + // expression with private members cannot be described in a .d.ts + static Backup: new ( + $super: ProjectBackupService, + name: string, + pathsToBackup?: string[], + basePath?: string, + ) => IBackup = class Backup implements IBackup { constructor( private $super: ProjectBackupService, private name: string, private pathsToBackup: string[] = [], - private basePath: string = $super.$projectHelper.projectDir + private basePath: string = $super.$projectHelper.projectDir, ) {} get backupDir() { @@ -49,7 +56,7 @@ export class ProjectBackupService implements IProjectBackupService { const targetPath = path.resolve(this.backupDir, pathToBackup); if (this.$super.$fs.exists(sourcePath)) { this.$super.$logger.trace( - `BACKING UP ${color.cyan(sourcePath)} -> ${color.green(targetPath)}` + `BACKING UP ${color.cyan(sourcePath)} -> ${color.green(targetPath)}`, ); this.$super.$fs.copyFile(sourcePath, targetPath); backedUpPaths.push(pathToBackup); @@ -76,7 +83,7 @@ export class ProjectBackupService implements IProjectBackupService { const sourcePath = path.resolve(this.backupDir, pathToBackup); const targetPath = path.resolve(this.basePath, pathToBackup); this.$super.$logger.trace( - `RESTORING ${color.green(sourcePath)} -> ${color.cyan(targetPath)}` + `RESTORING ${color.green(sourcePath)} -> ${color.cyan(targetPath)}`, ); if (this.$super.$fs.exists(sourcePath)) { this.$super.$fs.copyFile(sourcePath, targetPath); @@ -110,7 +117,7 @@ export class ProjectBackupService implements IProjectBackupService { remove() { if (!this.$super.$fs.exists(this.backupDir)) { this.$super.$logger.trace( - `No backup named ${this.name} could be found.` + `No backup named ${this.name} could be found.`, ); return; } @@ -135,7 +142,7 @@ export class ProjectBackupService implements IProjectBackupService { private getBackupData(): { name: string; paths: string[] } { if (!this.$super.$fs.exists(this.backupDir)) { this.$super.$logger.trace( - `No backup named ${this.name} could be found.` + `No backup named ${this.name} could be found.`, ); return; } @@ -143,7 +150,7 @@ export class ProjectBackupService implements IProjectBackupService { if (!this.$super.$fs.exists(backupJSONPath)) { this.$super.$logger.trace( - `The backup ${this.name} does not contain a _backup.json.` + `The backup ${this.name} does not contain a _backup.json.`, ); return; } diff --git a/package.json b/package.json index cd49a8ba19..23cf047ba4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nativescript", - "main": "./lib/nativescript-cli-lib.js", + "main": "./dist/lib/nativescript-cli-lib.js", "version": "9.1.0-alpha.15", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", @@ -11,40 +11,27 @@ "ns": "./bin/tns" }, "files": [ - "bin/*", - "config", - "docs", - "!docs/html", - "lib", - "!lib/**/*.ts", - "lib/**/*.d.ts", - "!lib/**/*.js.map", - "!lib/common/test", - "!lib/common/docs/fonts", - "resources", - "setup", - "vendor", - "postinstall.js", - "preuninstall.js" + "dist" ], "scripts": { "clean": "npx rimraf node_modules package-lock.json && npm run setup", "clean.build": "node scripts/clean.js", - "build": "npm run tsc && node scripts/generate-test-deps.js", + "build": "node scripts/clean.js --dist-only && npm run tsc && node scripts/generate-test-deps.js && node scripts/copy-assets.js", "build.all": "npm test", "dev": "tsc --watch", "setup": "npm i --ignore-scripts && npx husky", - "test": "npm run tsc && mocha --config=test/.mocharc.yml", + "test": "npm run build && mocha --config=test/.mocharc.yml", "postinstall": "node postinstall.js", "preuninstall": "node preuninstall.js", - "prepack": "npm run clean.build && node scripts/generate-test-deps.js && npm test && tsc -p tsconfig.release.json && node scripts/set-ga-id.js live && node scripts/set-ga-id.js verify", - "postpack": "node scripts/set-ga-id.js dev", + "prepack": "node scripts/guard-root-pack.js", "docs-jekyll": "node scripts/build-docs.js", "mocha": "mocha", "tsc": "tsc", "test-watch": "node ./dev/tsc-to-mocha-watch.js", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", - "prettier": "prettier --write ./lib/**/*{.ts,.d.ts} ./test/**/*{.ts,.d.ts}" + "prettier": "prettier --write ./lib/**/*{.ts,.d.ts} ./test/**/*{.ts,.d.ts}", + "build.release": "npm run clean.build && tsc -p tsconfig.release.json && node scripts/generate-test-deps.js && node scripts/copy-assets.js --release && node scripts/set-ga-id.js live --dir dist && node scripts/set-ga-id.js verify --dir dist", + "pack": "npm run build.release && npm pack ./dist" }, "repository": { "type": "git", diff --git a/scripts/clean.js b/scripts/clean.js index 3c25b9e5f9..4879322f88 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -4,9 +4,19 @@ const path = require("path"); const rootDir = path.join(__dirname, ".."); -// .gitignore is the source of truth for which files under lib/ and test/ are -// compiler output: its negations already protect the vendored, hook and fixture -// .js files that must survive a clean. +fs.rmSync(path.join(rootDir, "dist"), { recursive: true, force: true }); + +// tsc never removes output whose source is gone, so every build starts from an +// empty dist - otherwise a deleted file keeps being compiled-in and tested +// against, which is the failure this whole layout exists to prevent. +if (process.argv.includes("--dist-only")) { + process.exit(0); +} + +// Builds used to emit next to each source file, so a tree that predates dist/ +// still has hundreds of stale .js lying around. .gitignore is the source of +// truth for which of those are compiler output - its negations protect the +// vendored, hook and fixture .js that must survive. const result = child_process.spawnSync("git", ["clean", "-Xdf", "lib", "test"], { cwd: rootDir, stdio: "inherit", diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js new file mode 100644 index 0000000000..6c57e5f0f6 --- /dev/null +++ b/scripts/copy-assets.js @@ -0,0 +1,150 @@ +const fs = require("fs"); +const path = require("path"); + +// dist/ is assembled as a complete package root, not just compiled output: +// lib/ resolves siblings through __dirname (../package.json, ../docs/helpers, +// ../../vendor/gradle-plugin, ...), so those directories have to sit next to it +// exactly as they do in the repo. + +const rootDir = path.join(__dirname, ".."); +const distDir = path.join(rootDir, "dist"); +const release = process.argv.includes("--release"); + +// bundleDependencies are resolved from node_modules next to the manifest being +// packed, so they have to be mirrored into dist for `npm pack` to bundle them +const BUNDLED = ["universal-analytics", "debug", "ms", "uuid"]; + +const SIBLING_DIRS = ["resources", "docs", "config", "vendor", "bin", "setup"]; +// npm picks README/LICENSE/CHANGELOG up from the directory being packed, so +// they have to exist inside dist or they silently drop out of the tarball +const ROOT_FILES = [ + "postinstall.js", + "preuninstall.js", + "README.md", + "LICENSE", + "CHANGELOG.md", +]; + +// paths (relative to the repo root) that never ship +const RELEASE_EXCLUDES = [ + path.join("docs", "html"), + path.join("lib", "common", "docs", "fonts"), + path.join("lib", "common", "test"), +]; + +function isExcluded(relPath) { + if (!release) { + return false; + } + return RELEASE_EXCLUDES.some( + (excluded) => relPath === excluded || relPath.startsWith(excluded + path.sep) + ); +} + +let copied = 0; +let skipped = 0; + +function copyFile(sourcePath, targetPath) { + const source = fs.statSync(sourcePath); + if (fs.existsSync(targetPath)) { + const target = fs.statSync(targetPath); + // vendor/ alone is ~31MB; re-copying it on every build is pure waste + if (target.mtimeMs >= source.mtimeMs && target.size === source.size) { + skipped++; + return; + } + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); + copied++; +} + +function copyTree(relDir, filter) { + const sourceDir = path.join(rootDir, relDir); + if (!fs.existsSync(sourceDir)) { + return; + } + + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const relPath = path.join(relDir, entry.name); + if (isExcluded(relPath)) { + continue; + } + if (entry.isDirectory()) { + copyTree(relPath, filter); + } else if (!filter || filter(relPath)) { + copyFile(path.join(rootDir, relPath), path.join(distDir, relPath)); + } + } +} + +// Everything under lib/ that is not TypeScript is an asset: vendored scripts, +// hooks, platform-tools binaries, docs helpers and test fixtures. A .js with a +// sibling .ts is compiler output instead - either left over from the old +// in-place build or freshly emitted into dist - and copying it would overwrite +// what tsc just produced. +function isCompilerOutput(relPath) { + const stem = relPath.replace(/\.js\.map$/, "").replace(/\.js$/, ""); + return ( + (relPath.endsWith(".js") || relPath.endsWith(".js.map")) && + fs.existsSync(path.join(rootDir, stem + ".ts")) + ); +} + +// Hand-written .d.ts come along too. tsc treats them as inputs and never emits +// them, but the generated declarations import from them, so leaving them behind +// ships types with dangling references. +copyTree( + "lib", + (relPath) => + (!relPath.endsWith(".ts") || relPath.endsWith(".d.ts")) && + !isCompilerOutput(relPath) +); + +for (const dir of SIBLING_DIRS) { + copyTree(dir); +} + +if (!release) { + // fixtures the compiled tests read relative to their own location + copyTree(path.join("test", "files")); +} + +for (const file of ROOT_FILES) { + copyFile(path.join(rootDir, file), path.join(distDir, file)); +} + +for (const dep of BUNDLED) { + copyTree(path.join("node_modules", dep)); +} + +writeManifest(); + +function writeManifest() { + const pkg = JSON.parse( + fs.readFileSync(path.join(rootDir, "package.json"), "utf8") + ); + + // dist is the package root once published, so entrypoints lose the dist/ + // prefix they carry in the source manifest + pkg.main = pkg.main.replace(/^\.\/dist\//, "./"); + + delete pkg.devDependencies; + delete pkg.files; + delete pkg.overrides; + delete pkg["lint-staged"]; + + pkg.scripts = { + postinstall: pkg.scripts.postinstall, + preuninstall: pkg.scripts.preuninstall, + }; + + fs.writeFileSync( + path.join(distDir, "package.json"), + JSON.stringify(pkg, null, 2) + "\n" + ); +} + +console.log( + `assets: ${copied} copied, ${skipped} up to date${release ? " (release)" : ""}` +); diff --git a/scripts/guard-root-pack.js b/scripts/guard-root-pack.js new file mode 100644 index 0000000000..29bdd8c221 --- /dev/null +++ b/scripts/guard-root-pack.js @@ -0,0 +1,16 @@ +// The published package is assembled in dist/ and packed from there, so packing +// the repository root would produce a tarball with everything nested one level +// deeper - every path the CLI resolves through __dirname would break, silently. +// npm pack ./dist runs dist's own manifest, so this guard does not fire for it. +console.error( + [ + "Refusing to pack the repository root.", + "", + "The published package is assembled in dist/. Use:", + " npm run pack", + "", + "which builds dist/ and runs `npm pack ./dist`.", + ].join("\n") +); + +process.exit(1); diff --git a/scripts/set-ga-id.js b/scripts/set-ga-id.js index a2b2b7dd2f..b1e867305a 100644 --- a/scripts/set-ga-id.js +++ b/scripts/set-ga-id.js @@ -8,7 +8,14 @@ const GA_TRACKING_IDS = { }; const GA_KEY = "GA_TRACKING_ID"; -const configPath = path.join(__dirname, "..", "config", "config.json"); +const rootDir = path.join(__dirname, ".."); + +// Releases flip the id inside dist/ rather than in the working tree, so a failed +// pack cannot leave a checkout configured to report as production. +const dirIndex = process.argv.indexOf("--dir"); +const baseDir = + dirIndex === -1 ? rootDir : path.resolve(rootDir, process.argv[dirIndex + 1]); +const configPath = path.join(baseDir, "config", "config.json"); function readConfig() { return JSON.parse(fs.readFileSync(configPath, "utf8")); @@ -18,13 +25,15 @@ const mode = process.argv[2]; if (mode === "verify") { if (readConfig()[GA_KEY] !== GA_TRACKING_IDS.live) { - throw new Error("Google Analytics id is not configured correctly."); + throw new Error(`Google Analytics id is not configured correctly in ${configPath}`); } } else if (mode === "live" || mode === "dev") { const config = readConfig(); config[GA_KEY] = GA_TRACKING_IDS[mode]; fs.writeFileSync(configPath, JSON.stringify(config, null, "\t") + EOL); } else { - console.error("Usage: node scripts/set-ga-id.js "); + console.error( + "Usage: node scripts/set-ga-id.js [--dir ]" + ); process.exit(1); } diff --git a/test/.mocharc.yml b/test/.mocharc.yml index 86b280d432..e07dbbf891 100644 --- a/test/.mocharc.yml +++ b/test/.mocharc.yml @@ -39,13 +39,13 @@ inline-diffs: false recursive: true reporter: 'spec' require: - - 'test/test-bootstrap.js' + - 'dist/test/test-bootstrap.js' retries: 1 slow: 500 sort: false spec: - - 'test/**/*.js' - - 'lib/common/test/unit-tests/**/*.js' + - 'dist/test/**/*.js' + - 'dist/lib/common/test/unit-tests/**/*.js' timeout: 150000 # same as "no-timeout: true" or "timeout: 0" # node flags @@ -55,7 +55,7 @@ ui: 'bdd' v8-stack-trace-limit: 100 # V8 flags are prepended with "v8-" watch: false watch-files: - - 'test/**/*.js' - - 'lib/common/test/unit-tests/**/*.js' + - 'dist/test/**/*.js' + - 'dist/lib/common/test/unit-tests/**/*.js' # watch-ignore: # - 'lib/vendor' diff --git a/tsconfig.json b/tsconfig.json index 72a247f2c9..1e9b8deda9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,10 @@ "compilerOptions": { "target": "ES2018", "module": "commonjs", + // rootDir is explicit so output keeps the lib/ and test/ layout; without + // it tsc infers the common source root and flattens everything + "rootDir": ".", + "outDir": "dist", "sourceMap": true, "declaration": false, "removeComments": false, diff --git a/tsconfig.release.json b/tsconfig.release.json index 065b45aa74..5d7991c1e0 100644 --- a/tsconfig.release.json +++ b/tsconfig.release.json @@ -2,6 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "sourceMap": false, - "removeComments": true - } + "removeComments": true, + "declaration": true + }, + "include": ["lib/"], + "exclude": ["lib/common/test"] }