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"] }