diff --git a/docs/man_pages/project/hooks/hooks.md b/docs/man_pages/project/hooks/hooks.md new file mode 100644 index 0000000000..0c952aedc7 --- /dev/null +++ b/docs/man_pages/project/hooks/hooks.md @@ -0,0 +1,35 @@ +<% if (isJekyll) { %>--- +title: ns hooks +position: 1 +---<% } %> + +# ns create + +### Description + +Manages lifecycle hooks from installed plugins. + +### Commands + +Usage | Synopsis +---------|--------- +Install | `$ ns hooks install` +List | `$ ns hooks list` +Lock | `$ ns hooks lock` +Verify | `$ ns hooks verify` + +#### Install + +Installs hooks from each installed plugin dependency. + +#### List + +Lists the plugins which have hooks and which scripts they install + +#### Lock + +Generates a `hooks-lock.json` containing the hooks that are in the current versions of the plugins. + +#### Verify + +Verifies that the hooks contained in the installed plugins match those listed in the `hooks-lock.json` file. diff --git a/docs/man_pages/start.md b/docs/man_pages/start.md index 83b9da83e8..646952bb68 100644 --- a/docs/man_pages/start.md +++ b/docs/man_pages/start.md @@ -51,6 +51,7 @@ Command | Description [plugin](lib-management/plugin.html) | Lets you manage the plugins for your project. [open](project/configuration/open.md) | Opens the native project in Xcode/Android Studio. [widget ios](project/configuration/widget.md) | Adds a new iOS widget to the project. +[hooks](project/hooks/hooks.html) | Installs lifecycle hooks from plugins. ## Publishing Commands Command | Description ---|--- diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index ad181a1062..3281d84930 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -263,6 +263,15 @@ injector.requireCommand("plugin|update", "./commands/plugin/update-plugin"); injector.requireCommand("plugin|build", "./commands/plugin/build-plugin"); injector.requireCommand("plugin|create", "./commands/plugin/create-plugin"); +injector.requireCommand( + ["hooks|*list", "hooks|install"], + "./commands/hooks/hooks", +); +injector.requireCommand( + ["hooks|lock", "hooks|verify"], + "./commands/hooks/hooks-lock", +); + injector.require("doctorService", "./services/doctor-service"); injector.require("xcprojService", "./services/xcproj-service"); injector.require("versionsService", "./services/versions-service"); diff --git a/lib/commands/hooks/common.ts b/lib/commands/hooks/common.ts new file mode 100644 index 0000000000..4532a73326 --- /dev/null +++ b/lib/commands/hooks/common.ts @@ -0,0 +1,118 @@ +import * as _ from "lodash"; +import { IProjectData } from "../../definitions/project"; +import { IPluginData } from "../../definitions/plugins"; +import { ICommandParameter } from "../../common/definitions/commands"; +import { IErrors, IFileSystem } from "../../common/declarations"; +import path = require("path"); +import * as crypto from "crypto"; + +export const LOCK_FILE_NAME = "nativescript-lock.json"; +export interface OutputHook { + type: string; + hash: string; +} + +export interface OutputPlugin { + name: string; + hooks: OutputHook[]; +} + +export class HooksVerify { + public allowedParameters: ICommandParameter[] = []; + + constructor( + protected $projectData: IProjectData, + protected $errors: IErrors, + protected $fs: IFileSystem, + protected $logger: ILogger, + ) { + this.$projectData.initializeProjectData(); + } + + protected async verifyHooksLock( + plugins: IPluginData[], + hooksLockPath: string, + ): Promise { + let lockFileContent: string; + let hooksLock: OutputPlugin[]; + + try { + lockFileContent = this.$fs.readText(hooksLockPath, "utf8"); + hooksLock = JSON.parse(lockFileContent); + } catch (err) { + this.$errors.fail( + `❌ Failed to read or parse ${LOCK_FILE_NAME} at ${hooksLockPath}`, + ); + } + + const lockMap = new Map>(); // pluginName -> hookType -> hash + + for (const plugin of hooksLock) { + const hookMap = new Map(); + for (const hook of plugin.hooks) { + hookMap.set(hook.type, hook.hash); + } + lockMap.set(plugin.name, hookMap); + } + + let isValid = true; + + for (const plugin of plugins) { + const pluginLockHooks = lockMap.get(plugin.name); + + if (!pluginLockHooks) { + this.$logger.error( + `❌ Plugin '${plugin.name}' not found in ${LOCK_FILE_NAME}`, + ); + isValid = false; + continue; + } + + for (const hook of plugin.nativescript?.hooks || []) { + const expectedHash = pluginLockHooks.get(hook.type); + + if (!expectedHash) { + this.$logger.error( + `❌ Missing hook '${hook.type}' for plugin '${plugin.name}' in ${LOCK_FILE_NAME}`, + ); + isValid = false; + continue; + } + + let fileContent: string | Buffer; + + try { + fileContent = this.$fs.readFile( + path.join(plugin.fullPath, hook.script), + ); + } catch (err) { + this.$logger.error( + `❌ Cannot read script file '${hook.script}' for hook '${hook.type}' in plugin '${plugin.name}'`, + ); + isValid = false; + continue; + } + + const actualHash = crypto + .createHash("sha256") + .update(fileContent) + .digest("hex"); + + if (actualHash !== expectedHash) { + this.$logger.error( + `❌ Hash mismatch for '${hook.script}' (${hook.type} in ${plugin.name}):`, + ); + this.$logger.error(` Expected: ${expectedHash}`); + this.$logger.error(` Actual: ${actualHash}`); + isValid = false; + } + } + } + + if (isValid) { + this.$logger.info("✅ All hooks verified successfully. No issues found."); + } else { + this.$errors.fail("❌ One or more hooks failed verification."); + } + } +} diff --git a/lib/commands/hooks/hooks-lock.ts b/lib/commands/hooks/hooks-lock.ts new file mode 100644 index 0000000000..27399ac622 --- /dev/null +++ b/lib/commands/hooks/hooks-lock.ts @@ -0,0 +1,135 @@ +import { IProjectData } from "../../definitions/project"; +import { IPluginsService, IPluginData } from "../../definitions/plugins"; +import { ICommand, ICommandParameter } from "../../common/definitions/commands"; +import { IErrors, IFileSystem } from "../../common/declarations"; +import { injector } from "../../common/yok"; +import path = require("path"); +import * as crypto from "crypto"; +import { + HooksVerify, + LOCK_FILE_NAME, + OutputHook, + OutputPlugin, +} from "./common"; + +export class HooksLockPluginCommand implements ICommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + private $pluginsService: IPluginsService, + private $projectData: IProjectData, + private $errors: IErrors, + private $fs: IFileSystem, + private $logger: ILogger, + ) { + this.$projectData.initializeProjectData(); + } + + public async execute(): Promise { + const plugins: IPluginData[] = + await this.$pluginsService.getAllInstalledPlugins(this.$projectData); + if (plugins && plugins.length > 0) { + const pluginsWithHooks: IPluginData[] = []; + for (const plugin of plugins) { + if (plugin.nativescript?.hooks?.length > 0) { + pluginsWithHooks.push(plugin); + } + } + + await this.writeHooksLockFile( + pluginsWithHooks, + this.$projectData.projectDir, + ); + } else { + this.$logger.info("No plugins with hooks found."); + } + } + + public async canExecute(args: string[]): Promise { + return true; + } + + private async writeHooksLockFile( + plugins: IPluginData[], + outputDir: string, + ): Promise { + const output: OutputPlugin[] = []; + + for (const plugin of plugins) { + const hooks: OutputHook[] = []; + + for (const hook of plugin.nativescript?.hooks || []) { + try { + const fileContent = this.$fs.readFile( + path.join(plugin.fullPath, hook.script), + ); + const hash = crypto + .createHash("sha256") + .update(fileContent) + .digest("hex"); + + hooks.push({ + type: hook.type, + hash, + }); + } catch (err) { + this.$logger.warn( + `Warning: Failed to read script '${hook.script}' for plugin '${plugin.name}'. Skipping this hook.`, + ); + continue; + } + } + + output.push({ name: plugin.name, hooks }); + } + + const filePath = path.resolve(outputDir, LOCK_FILE_NAME); + + try { + this.$fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); + this.$logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); + } catch (err) { + this.$errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); + } + } +} + +export class HooksVerifyPluginCommand extends HooksVerify implements ICommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + private $pluginsService: IPluginsService, + $projectData: IProjectData, + $errors: IErrors, + $fs: IFileSystem, + $logger: ILogger, + ) { + super($projectData, $errors, $fs, $logger); + } + + public async execute(): Promise { + const plugins: IPluginData[] = + await this.$pluginsService.getAllInstalledPlugins(this.$projectData); + if (plugins && plugins.length > 0) { + const pluginsWithHooks: IPluginData[] = []; + for (const plugin of plugins) { + if (plugin.nativescript?.hooks?.length > 0) { + pluginsWithHooks.push(plugin); + } + } + await this.verifyHooksLock( + pluginsWithHooks, + path.join(this.$projectData.projectDir, LOCK_FILE_NAME), + ); + } else { + this.$logger.info("No plugins with hooks found."); + } + } + + public async canExecute(args: string[]): Promise { + return true; + } +} + +injector.registerCommand(["hooks|lock"], HooksLockPluginCommand); +injector.registerCommand(["hooks|verify"], HooksVerifyPluginCommand); diff --git a/lib/commands/hooks/hooks.ts b/lib/commands/hooks/hooks.ts new file mode 100644 index 0000000000..4971c648cf --- /dev/null +++ b/lib/commands/hooks/hooks.ts @@ -0,0 +1,104 @@ +import { IProjectData } from "../../definitions/project"; +import { IPluginsService, IPluginData } from "../../definitions/plugins"; +import { ICommand, ICommandParameter } from "../../common/definitions/commands"; +import { injector } from "../../common/yok"; +import { IErrors, IFileSystem } from "../../common/declarations"; +import path = require("path"); +import { HOOKS_DIR_NAME } from "../../constants"; +import { createTable } from "../../common/helpers"; +import nsHooks = require("@nativescript/hook"); +import { HooksVerify, LOCK_FILE_NAME } from "./common"; + +export class HooksPluginCommand extends HooksVerify implements ICommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + private $pluginsService: IPluginsService, + $projectData: IProjectData, + $errors: IErrors, + $fs: IFileSystem, + $logger: ILogger, + ) { + super($projectData, $errors, $fs, $logger); + } + + public async execute(args: string[]): Promise { + const isList: boolean = + args.length > 0 && args[0] === "list" ? true : false; + const plugins: IPluginData[] = + await this.$pluginsService.getAllInstalledPlugins(this.$projectData); + if (plugins && plugins.length > 0) { + const hooksDir = path.join(this.$projectData.projectDir, HOOKS_DIR_NAME); + const pluginsWithHooks: IPluginData[] = []; + for (const plugin of plugins) { + if (plugin.nativescript?.hooks?.length > 0) { + pluginsWithHooks.push(plugin); + } + } + + if (isList) { + const headers: string[] = ["Plugin", "HookName", "HookPath"]; + const hookDataData: string[][] = pluginsWithHooks.flatMap((plugin) => + plugin.nativescript.hooks.map( + (hook: { type: string; script: string }) => { + return [plugin.name, hook.type, hook.script]; + }, + ), + ); + const hookDataTable: any = createTable(headers, hookDataData); + this.$logger.info("Hooks:"); + this.$logger.info(hookDataTable.toString()); + } else { + if ( + this.$fs.exists( + path.join(this.$projectData.projectDir, LOCK_FILE_NAME), + ) + ) { + await this.verifyHooksLock( + pluginsWithHooks, + path.join(this.$projectData.projectDir, LOCK_FILE_NAME), + ); + } + + if (pluginsWithHooks.length === 0) { + if (!this.$fs.exists(hooksDir)) { + this.$fs.createDirectory(hooksDir); + } + } + for (const plugin of pluginsWithHooks) { + nsHooks(plugin.fullPath).postinstall(); + } + } + } + } + + public async canExecute(args: string[]): Promise { + if (args.length > 0 && args[0] !== "list") { + this.$errors.failWithHelp( + `Invalid argument ${args[0]}. Supported argument is "list".`, + ); + } + return true; + } +} + +export class HooksListPluginCommand extends HooksPluginCommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + $pluginsService: IPluginsService, + $projectData: IProjectData, + $errors: IErrors, + $fs: IFileSystem, + $logger: ILogger, + ) { + super($pluginsService, $projectData, $errors, $fs, $logger); + } + + public async execute(): Promise { + await super.execute(["list"]); + } +} + +injector.registerCommand(["hooks|install"], HooksPluginCommand); +injector.registerCommand(["hooks|*list"], HooksListPluginCommand); diff --git a/lib/definitions/hooks.d.ts b/lib/definitions/hooks.d.ts new file mode 100644 index 0000000000..c2c3932fc7 --- /dev/null +++ b/lib/definitions/hooks.d.ts @@ -0,0 +1 @@ +declare module "@nativescript/hook"; diff --git a/lib/services/plugins-service.ts b/lib/services/plugins-service.ts index e9b5e6a54c..2fadc8fe16 100644 --- a/lib/services/plugins-service.ts +++ b/lib/services/plugins-service.ts @@ -624,7 +624,7 @@ This framework comes from ${dependencyName} plugin, which is installed multiple projectDir: string ): IPluginData { try { - const pluginData: any = {}; + const pluginData: IPluginData = {}; pluginData.name = cacheData.name; pluginData.version = cacheData.version; pluginData.fullPath = @@ -648,6 +648,7 @@ This framework comes from ${dependencyName} plugin, which is installed multiple if (pluginData.isPlugin) { pluginData.platformsData = data.platforms; pluginData.pluginVariables = data.variables; + pluginData.nativescript = data; } return pluginData; } catch (err) { diff --git a/package-lock.json b/package-lock.json index 8b404d2676..6c1ee9bdb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,22 @@ { "name": "nativescript", - "version": "9.0.0", + "version": "9.0.0-alpha.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nativescript", - "version": "9.0.0", + "version": "9.0.0-alpha.12", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@foxt/js-srp": "^0.0.3-patch2", + "@foxt/js-srp": "0.0.3-patch2", "@nativescript/doctor": "2.0.17", - "@npmcli/arborist": "^9.1.4", + "@nativescript/hook": "3.0.4", + "@npmcli/arborist": "9.1.4", "@nstudio/trapezedev-project": "7.2.3", "@rigor789/resolve-package-path": "1.0.7", - "archiver": "^7.0.1", + "archiver": "7.0.1", "axios": "1.11.0", "byline": "5.0.0", "chokidar": "4.0.3", @@ -922,6 +923,88 @@ "yauzl": "3.2.0" } }, + "node_modules/@nativescript/hook": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@nativescript/hook/-/hook-3.0.4.tgz", + "integrity": "sha512-oahiN7V0D+fgl9o8mjGRgExujTpgSBB0DAFr3eX91qdlJZV8ywJ6mnvtHZyEI2j46yPgAE8jmNIw/Z/d3aWetw==", + "license": "Apache-2.0", + "dependencies": { + "glob": "^11.0.0", + "mkdirp": "^3.0.1" + } + }, + "node_modules/@nativescript/hook/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@nativescript/hook/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@nativescript/hook/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@nativescript/hook/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@nativescript/hook/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 65303a9c66..58d96496a1 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,10 @@ "dependencies": { "@foxt/js-srp": "0.0.3-patch2", "@nativescript/doctor": "2.0.17", + "@nativescript/hook": "3.0.4", "@npmcli/arborist": "9.1.4", - "@rigor789/resolve-package-path": "1.0.7", "@nstudio/trapezedev-project": "7.2.3", + "@rigor789/resolve-package-path": "1.0.7", "archiver": "7.0.1", "axios": "1.11.0", "byline": "5.0.0", diff --git a/test/plugins-service.ts b/test/plugins-service.ts index 0ea539bfdd..85bc40641d 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -891,6 +891,7 @@ describe("Plugins service", () => { fullPath: pluginDir, isPlugin: true, platformsData: { android: "6.0.0", ios: "6.0.0" }, + nativescript: { platforms: { android: "6.0.0", ios: "6.0.0" } }, pluginVariables: undefined, }); }); @@ -1000,6 +1001,12 @@ describe("Plugins service", () => { android: "5.0.0", ios: "5.0.0", }, + nativescript: { + platforms: { + android: "5.0.0", + ios: "5.0.0", + }, + }, version: "6.3.2", }, { @@ -1011,6 +1018,12 @@ describe("Plugins service", () => { android: "6.2.0", ios: "6.2.0", }, + nativescript: { + platforms: { + android: "6.2.0", + ios: "6.2.0", + }, + }, version: "2.2.1", }, ], @@ -1072,6 +1085,12 @@ describe("Plugins service", () => { android: "6.0.0", ios: "6.0.0", }, + nativescript: { + platforms: { + android: "6.0.0", + ios: "6.0.0", + }, + }, version: "8.0.1", }, { @@ -1083,6 +1102,12 @@ describe("Plugins service", () => { android: "6.0.0", ios: "6.0.0", }, + nativescript: { + platforms: { + android: "6.0.0", + ios: "6.0.0", + }, + }, version: "4.0.0", }, ],