From b19b388bfeca246fde790b18a23a469674389632 Mon Sep 17 00:00:00 2001 From: amitb0ra Date: Sat, 17 Jan 2026 04:19:12 +0000 Subject: [PATCH 1/2] feat: optimize watch command with robust watching, caching, and debouncing --- package-lock.json | 193 ++++++++++++++++++++++++++++++++++-- package.json | 1 + src/commands/watch.ts | 127 +++++++++++++++++------- src/misc/appWatcher.ts | 170 +++++++++++++++++++++++++++++++ src/misc/deployHelpers.ts | 126 ++++++++++------------- src/misc/folderDetails.ts | 2 +- src/misc/index.ts | 2 + src/misc/unicodeSymbols.ts | 1 + test/commands/watch.test.ts | 120 +++++++++++++++++++++- 9 files changed, 620 insertions(+), 122 deletions(-) create mode 100644 src/misc/appWatcher.ts diff --git a/package-lock.json b/package-lock.json index 4069d83..a5604c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "inquirer": "^6.5.0", "inquirer-checkbox-plus-prompt": "^1.0.1", "open": "^6.4.0", + "p-limit": "^7.2.0", "pascal-case": "^2.0.1", "pascalcase": "^0.1.1", "querystring": "^0.2.0", @@ -2850,6 +2851,22 @@ "node": ">=6" } }, + "node_modules/find-cache-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-cache-dir/node_modules/p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -4806,6 +4823,22 @@ "node": ">=6" } }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nyc/node_modules/p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -4920,14 +4953,30 @@ } }, "node_modules/p-limit": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", - "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz", + "integrity": "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==", + "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^1.2.1" }, "engines": { - "node": ">=6" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { @@ -4942,10 +4991,27 @@ "node": ">=8" } }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", "engines": { "node": ">=6" } @@ -5125,6 +5191,21 @@ "node": ">=6" } }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pkg-up/node_modules/p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -5369,6 +5450,22 @@ "node": ">=6" } }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg-up/node_modules/p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -6706,6 +6803,22 @@ "node": ">=6" } }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yargs/node_modules/p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -8929,6 +9042,15 @@ "semver": "^5.6.0" } }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, "p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -10428,6 +10550,15 @@ "semver": "^5.6.0" } }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, "p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -10511,11 +10642,18 @@ "dev": true }, "p-limit": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", - "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz", + "integrity": "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==", "requires": { - "p-try": "^2.0.0" + "yocto-queue": "^1.2.1" + }, + "dependencies": { + "yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==" + } } }, "p-locate": { @@ -10525,6 +10663,17 @@ "dev": true, "requires": { "p-limit": "^2.2.0" + }, + "dependencies": { + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + } } }, "p-try": { @@ -10661,6 +10810,14 @@ "path-exists": "^3.0.0" } }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, "p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -10904,6 +11061,15 @@ "path-exists": "^3.0.0" } }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, "p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -11890,6 +12056,15 @@ "path-exists": "^3.0.0" } }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, "p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", diff --git a/package.json b/package.json index 0cffa8b..8603432 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "inquirer": "^6.5.0", "inquirer-checkbox-plus-prompt": "^1.0.1", "open": "^6.4.0", + "p-limit": "^7.2.0", "pascal-case": "^2.0.1", "pascalcase": "^0.1.1", "querystring": "^0.2.0", diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 4a90564..9042026 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,11 +1,11 @@ import { Command, flags } from '@oclif/command'; import chalk from 'chalk'; -import * as chokidar from 'chokidar'; import cli from 'cli-ux'; +import * as helper from 'fs-extra'; import { ICompilerDiagnostic } from '@rocket.chat/apps-compiler/definition'; -import { AppCompiler, FolderDetails, unicodeSymbols } from '../misc'; -import { checkUpload, getIgnoredFiles, getServerInfo, uploadApp } from '../misc/deployHelpers'; +import { AppCompiler, AppWatcher, FolderDetails, unicodeSymbols } from '../misc'; +import { checkUpload, getIgnoredFiles, getServerInfo, retrieveSession, uploadApp } from '../misc/deployHelpers'; export default class Watch extends Command { @@ -60,6 +60,8 @@ export default class Watch extends Command { this.error(chalk.bold.red(e && e.message ? e.message : e), {exit: 2}); } + let compiler = new AppCompiler(fd, flags['experimental-native-compiler']); + if (flags.i2fa) { flags.code = await cli.prompt('2FA code', { type: 'hide' }); } @@ -71,35 +73,50 @@ export default class Watch extends Command { this.error(chalk.bold.red(e && e.message ? e.message : e)); } - chokidar.watch(fd.folder, { - ignored: ignoredFiles, - awaitWriteFinish: true, - persistent: true, - interval: 300, - }).on('change', async () => { - tasks(this, fd, flags) - .catch((e) => { - this.log(chalk.bold.redBright( - ` ${unicodeSymbols.get('longRightwardsSquiggleArrow')} ${e && e.message ? e.message : e}`)); - }); - }).on('ready', async () => { - tasks(this, fd, flags) - .catch((e) => { - this.log(chalk.bold.redBright( - ` ${unicodeSymbols.get('longRightwardsSquiggleArrow')} ${e && e.message ? e.message : e}`)); - }); + let needsReload = false; + const watcher = new AppWatcher(fd, ignoredFiles, async () => { + if (needsReload) { + try { + await fd.readInfoFile(); + await fd.matchAppsEngineVersion(); + compiler = new AppCompiler(fd, flags['experimental-native-compiler']); + cachedServerInfo = undefined; + this.log(chalk.bold.magenta('Configuration reloaded.')); + needsReload = false; + } catch (e) { + this.log(chalk.bold.red(`Error reading app.json: ${e.message}`)); + return; + } } + await tasks(this, fd, flags, compiler); + }, async () => { + needsReload = true; + }, { + log: (msg) => this.log(msg), + error: (msg) => this.log(msg), + }); + + process.on('SIGINT', async () => { + await watcher.stop(); + process.exit(); }); + + await watcher.start(); } } function reportDiagnostics(command: Command, diag: Array): void { - diag.forEach((d) => command.error(d.message)); + diag.forEach((d) => command.log(chalk.red(d.message))); } -const tasks = async (command: Command, fd: FolderDetails, flags: Record): Promise => { +let cachedServerInfo: any; + +const tasks = async (command: Command, fd: FolderDetails, flags: Record, compiler: AppCompiler): + Promise => { try { + process.stdout.write('\x1Bc'); + + const start = Date.now(); cli.action.start(chalk.bold.greenBright(' Packaging the app')); - const compiler = new AppCompiler(fd, flags['experimental-native-compiler']); const result = await compiler.compile(); if (flags.verbose) { @@ -108,30 +125,66 @@ const tasks = async (command: Command, fd: FolderDetails, flags: Record { + const status = await checkUpload({...flags, ...serverInfo}, fd); + if (status) { + cli.action.start(chalk.bold.greenBright(' Updating App')); + await uploadApp({...serverInfo, update: true}, fd, zipName); + cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark'))); + } else { + cli.action.start(chalk.bold.greenBright(' Uploading App')); + await uploadApp(serverInfo, fd, zipName); + cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark'))); + } +}; diff --git a/src/misc/appWatcher.ts b/src/misc/appWatcher.ts new file mode 100644 index 0000000..dfb5a57 --- /dev/null +++ b/src/misc/appWatcher.ts @@ -0,0 +1,170 @@ +import chalk from 'chalk'; +import * as chokidar from 'chokidar'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import pLimit from 'p-limit'; +import * as path from 'path'; + +import { FolderDetails } from './folderDetails'; +import { unicodeSymbols } from './unicodeSymbols'; + +export class AppWatcher { + private fileHashes = new Map(); + private limit = pLimit(50); + private debounceTimer: NodeJS.Timeout; + private isCompiling = false; + private needsCompile = false; + private watcher: chokidar.FSWatcher; + + constructor( + private fd: FolderDetails, + private ignoredFiles: Array, + private onBuild: () => Promise, + private onConfigChange: () => void, + private logger: { log: (msg: string) => void, error: (msg: string) => void }, + ) {} + + public async stop(): Promise { + if (this.watcher) { + await this.watcher.close(); + } + } + + public async start(): Promise { + const initialScanPromises: Array> = []; + let ready = false; + + const watcher = chokidar.watch(this.fd.folder, { + ignored: this.ignoredFiles, + awaitWriteFinish: { + stabilityThreshold: 500, + }, + persistent: true, + interval: 300, + }); + this.watcher = watcher; + + watcher + .on('add', (eventPath) => { + const promise = this.limit(() => this.updateFileHash(eventPath)); + if (!ready) { + initialScanPromises.push(promise); + } else { + this.checkConfigChange(eventPath, 'add'); + this.logger.log(chalk.bold.blue(`File added: ${path.relative(this.fd.folder, eventPath)}`)); + this.debouncedCompile(); + } + }) + .on('change', async (eventPath) => { + const changed = await this.limit(() => this.updateFileHash(eventPath)); + if (!changed) { + // console.log(chalk.bold.yellow(`Content unchanged, skipping build for: ${eventPath}`)); + return; + } + + this.checkConfigChange(eventPath, 'change'); + this.logger.log(chalk.bold.blue(`Change detected in file: ${ + path.relative(this.fd.folder, eventPath) + }`)); + this.debouncedCompile(); + }) + .on('unlink', (eventPath) => { + this.fileHashes.delete(eventPath); + this.checkConfigChange(eventPath, 'unlink'); + this.logger.log(chalk.bold.blue(`File deleted: ${ + path.relative(this.fd.folder, eventPath) + }`)); + this.debouncedCompile(); + }) + .on('error', (error) => { + this.logger.error(chalk.bold.red(`Watcher error: ${error}`)); + }) + .on('ready', async () => { + await Promise.all(initialScanPromises); + ready = true; + this.triggerCompile(); + }); + } + + private async updateFileHash(eventPath: string): Promise { + try { + const hash = await new Promise((resolve, reject) => { + const hash = crypto.createHash('sha1'); + const stream = fs.createReadStream(eventPath); + stream.on('error', (err) => reject(err)); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); + + if (this.fileHashes.get(eventPath) === hash) { + return false; + } + + this.fileHashes.set(eventPath, hash); + return true; + } catch (e) { + // if (flags.verbose) { + // this.log(chalk.gray(`Could not hash file ${eventPath}: ${e.message}`)); + // } + return false; + } + } + + private debouncedCompile() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.triggerCompile(); + }, 1000); + } + + private async triggerCompile() { + if (this.isCompiling) { + this.needsCompile = true; + return; + } + + this.isCompiling = true; + try { + await this.onBuild(); + } catch (e) { + this.logger.error(chalk.bold.red(e)); + } finally { + this.isCompiling = false; + if (this.needsCompile) { + this.needsCompile = false; + this.triggerCompile(); + } else { + this.logger.log(chalk.green('Waiting for changes...')); + } + } + } + + private checkConfigChange(eventPath: string, type: 'add' | 'change' | 'unlink'): void { + const relativePath = path.relative(this.fd.folder, eventPath); + const isAppJson = relativePath === 'app.json'; + const isTsConfig = relativePath === 'tsconfig.json'; + const isPackage = relativePath === 'package.json'; + const isRcAppsConfig = relativePath === '.rcappsconfig'; + + if (isAppJson || isTsConfig) { + this.onConfigChange(); + } + + if (isRcAppsConfig && type !== 'unlink') { + this.onConfigChange(); + } + + if (isPackage) { + this.onConfigChange(); + if (type !== 'unlink') { + this.logger.log(chalk.bold.yellow( + `\n${unicodeSymbols.get('warning')} package.json ${ + type === 'add' ? 'added' : 'changed' + }, you may need to run "npm install"`, + )); + } + } + } +} diff --git a/src/misc/deployHelpers.ts b/src/misc/deployHelpers.ts index 8faeb9c..56a5e88 100644 --- a/src/misc/deployHelpers.ts +++ b/src/misc/deployHelpers.ts @@ -121,41 +121,15 @@ export const uploadApp = async (flags: { [key: string]: any }, fd: FolderDetails // tslint:disable-next-line:max-line-length export const checkUpload = async (flags: { [key: string]: any }, fd: FolderDetails): Promise => { - let authResult; - if (!flags.token) { - let credentials: { username: string, password: string, code?: string }; - credentials = { username: flags.username, password: flags.password }; - if (flags.code) { - credentials.code = flags.code; - } - authResult = await fetch(normalizeUrl(flags.url, '/api/v1/login'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - }).then((res: Response) => res.json()); - - if (authResult.status === 'error' || !authResult.data) { - throw new Error('Invalid username and password or missing 2FA code (if active)'); - } - } else { - const verificationResult = await fetch(normalizeUrl(flags.url, '/api/v1/me'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Auth-Token': flags.token, - 'X-User-Id': flags.userId, - }, - }).then((res: Response) => res.json()); - - if (!verificationResult.success) { - throw new Error('Invalid API token'); - } - - authResult = { data: { authToken: flags.token, userId: flags.userId } }; + let authResult; + try { + const session = await retrieveSession(flags); + authResult = { data: { authToken: session.authToken, userId: session.userId } }; + } catch (e) { + throw new Error(e.message || 'Authentication failed'); } + const endpoint = `/api/apps/${fd.info.id}`; const findApp = await fetch(normalizeUrl(flags.url, endpoint), { @@ -170,44 +144,8 @@ export const checkUpload = async (flags: { [key: string]: any }, fd: FolderDetai export const asyncSubmitData = async (data: FormData, flags: { [key: string]: any }, fd: FolderDetails): Promise => { - let authResult; - if (!flags.url) { - throw new Error('Url not found'); - } - if (!flags.token) { - let credentials: { username: string, password: string, code?: string }; - credentials = { username: flags.username, password: flags.password }; - if (flags.code) { - credentials.code = flags.code; - } - authResult = await fetch(normalizeUrl(flags.url, '/api/v1/login'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - }).then((res: Response) => res.json()); - - if (authResult.status === 'error' || !authResult.data) { - throw new Error('Invalid username and password or missing 2FA code (if active)'); - } - } else { - const verificationResult = await fetch(normalizeUrl(flags.url, '/api/v1/me'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Auth-Token': flags.token, - 'X-User-Id': flags.userId, - }, - }).then((res: Response) => res.json()); - - if (!verificationResult.success) { - throw new Error('Invalid API token'); - } - - authResult = { data: { authToken: flags.token, userId: flags.userId } }; - } + const session = await retrieveSession(flags); if (await checkUpload(flags, fd)) { cli.log(chalk.bold.greenBright(' App already exists - updating it.')); @@ -222,8 +160,8 @@ export const asyncSubmitData = async (data: FormData, flags: { [key: string]: an const deployResult = await fetch(normalizeUrl(flags.url, endpoint), { method: 'POST', headers: { - 'X-Auth-Token': authResult.data.authToken, - 'X-User-Id': authResult.data.userId, + 'X-Auth-Token': session.authToken, + 'X-User-Id': session.userId, }, body: data, }).then((res: Response) => res.json()); @@ -257,3 +195,47 @@ export const getIgnoredFiles = async (fd: FolderDetails): Promise> throw new Error(e && e.message ? e.message : e); } }; + +export const retrieveSession = async (flags: { [key: string]: any }): + Promise<{ authToken: string, userId: string }> => { + if (!flags.url) { + throw new Error('Url not found'); + } + + if (!flags.token) { + let credentials: { username: string, password: string, code?: string }; + credentials = { username: flags.username, password: flags.password }; + if (flags.code) { + credentials.code = flags.code; + } + + const authResult = await fetch(normalizeUrl(flags.url, '/api/v1/login'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + }).then((res: Response) => res.json()); + + if (authResult.status === 'error' || !authResult.data) { + throw new Error('Invalid username and password or missing 2FA code (if active)'); + } + + return { authToken: authResult.data.authToken, userId: authResult.data.userId }; + } else { + const verificationResult = await fetch(normalizeUrl(flags.url, '/api/v1/me'), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': flags.token, + 'X-User-Id': flags.userId, + }, + }).then((res: Response) => res.json()); + + if (!verificationResult.success) { + throw new Error('Invalid API token'); + } + + return { authToken: flags.token, userId: flags.userId }; + } +}; diff --git a/src/misc/folderDetails.ts b/src/misc/folderDetails.ts index aaa6f94..40a6554 100644 --- a/src/misc/folderDetails.ts +++ b/src/misc/folderDetails.ts @@ -82,7 +82,7 @@ export class FolderDetails { } try { - this.info = require(this.infoFile); + this.info = await fs.readJson(this.infoFile); } catch (e) { throw new Error('The "app.json" file is invalid.'); } diff --git a/src/misc/index.ts b/src/misc/index.ts index b76b78d..a87148d 100644 --- a/src/misc/index.ts +++ b/src/misc/index.ts @@ -2,6 +2,7 @@ import { AppCompiler } from './appCompiler'; import { AppCreator } from './appCreator'; import { appJsonSchema } from './appJsonSchema'; import { AppPackager } from './appPackager'; +import { AppWatcher } from './appWatcher'; import { compilerOptions } from './compilerOptions'; import { DiagnosticReport } from './diagnosticReport'; import { FolderDetails } from './folderDetails'; @@ -17,6 +18,7 @@ export { AppCreator, AppPackager, AppCompiler, + AppWatcher, compilerOptions, DiagnosticReport, FolderDetails, diff --git a/src/misc/unicodeSymbols.ts b/src/misc/unicodeSymbols.ts index 6cad7cf..7d4d32c 100644 --- a/src/misc/unicodeSymbols.ts +++ b/src/misc/unicodeSymbols.ts @@ -2,4 +2,5 @@ export const unicodeSymbols = new Map([ ['checkMark', '\u2713'], ['heavyMultiplicationX', '\u2716'], ['longRightwardsSquiggleArrow', '\u27ff'], + ['warning', '\u26a0'], ]); diff --git a/test/commands/watch.test.ts b/test/commands/watch.test.ts index c928807..a1b056f 100644 --- a/test/commands/watch.test.ts +++ b/test/commands/watch.test.ts @@ -1,9 +1,123 @@ -import {test } from '@oclif/test'; +import { test } from '@oclif/test'; +import { expect } from 'chai'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { AppWatcher } from '../../src/misc/appWatcher'; -describe('watch', () => { +describe('watch command', () => { test .stdout() .command(['watch']) .exit(2) - .it('runs and fails'); + .it('runs and fails without args'); +}); + +describe('AppWatcher Integration (Logic Test)', function() { + this.timeout(10000); + + const tempDir = path.join(__dirname, 'temp_app_watcher_test'); + let watcher: AppWatcher; + let buildCallCount = 0; + let configCallCount = 0; + // Helper to allow hooking into build Call + let onBuildCallback: () => void = () => { /* no-op */ }; + + beforeEach(async () => { + await fs.ensureDir(tempDir); + // Create dummy config files + await fs.writeFile(path.join(tempDir, 'app.json'), JSON.stringify({})); + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify({})); + await fs.writeFile(path.join(tempDir, 'tsconfig.json'), JSON.stringify({})); + buildCallCount = 0; + configCallCount = 0; + onBuildCallback = () => { /* no-op */ }; + }); + + afterEach(async () => { + if (watcher) { + await watcher.stop(); + } + await fs.remove(tempDir); + }); + + const startWatcher = async () => { + const fdMock = { folder: tempDir } as any; + // Mock logger to suppress output during tests, or verify calls if needed + const loggerMock = { log: () => { /* no-op */ }, error: () => { /* no-op */ } }; + + // We want to wait for the INITIAL build to finish before we start our tests, + // otherwise our counts will be off or we'll have race conditions. + let initialBuildResolver: () => void; + const initialBuildPromise = new Promise((resolve) => { initialBuildResolver = resolve; }); + + // Setup the watcher with a callback that resolves the promise on first call + watcher = new AppWatcher( + fdMock, + [], // no ignores + async () => { + buildCallCount++; + if (initialBuildResolver) { + initialBuildResolver(); + initialBuildResolver = undefined; // prevent calling again + } + }, + () => { configCallCount++; }, + loggerMock, + ); + + await watcher.start(); + // Wait for the initial "scan complete" build + await initialBuildPromise; + + // Reset counters so our tests only assert on NEW events + buildCallCount = 0; + configCallCount = 0; + }; + + it('should debounce rapid changes and trigger only one build', async () => { + await startWatcher(); + + // Rapidly write to the same file + await fs.writeFile(path.join(tempDir, 'test1.ts'), 'content1'); + await new Promise((r) => setTimeout(r, 100)); + await fs.writeFile(path.join(tempDir, 'test1.ts'), 'content2'); + await new Promise((r) => setTimeout(r, 100)); + await fs.writeFile(path.join(tempDir, 'test1.ts'), 'content3'); + + // Wait for debounce (1000ms) + stability (500ms) + buffer + await new Promise((r) => setTimeout(r, 3000)); + + expect(buildCallCount).to.equal(1, 'Expected exactly 1 build after rapid changes'); + }); + + it('should detect app.json changes and trigger config reload', async () => { + await startWatcher(); + + await fs.writeFile(path.join(tempDir, 'app.json'), JSON.stringify({ version: '1.0.1' })); + await new Promise((r) => setTimeout(r, 3000)); + + expect(configCallCount).to.equal(1, 'Expected config reload callback to be fired'); + // It also triggers a build because the file changed + expect(buildCallCount).to.equal(1, 'Expected build to be triggered on config change'); + }); + + it('should detect package.json changes and trigger config reload', async () => { + await startWatcher(); + + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ version: '1.0.1' })); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(configCallCount).to.equal(1, 'Expected config reload on package.json change'); + expect(buildCallCount).to.equal(1, 'Expected build to be triggered on package.json change'); + }); + + it('should detect tsconfig.json changes and trigger config reload', async () => { + await startWatcher(); + + await fs.writeFile(path.join(tempDir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'es6' } })); + await new Promise((r) => setTimeout(r, 3000)); + expect(configCallCount).to.equal(1, 'Expected config reload on tsconfig.json change'); + expect(buildCallCount).to.equal(1, 'Expected build to be triggered on tsconfig.json change'); + }); }); From da3763fb87ddbb9860df385a8654582d96d517dc Mon Sep 17 00:00:00 2001 From: amitb0ra Date: Sat, 17 Jan 2026 23:20:38 +0000 Subject: [PATCH 2/2] feat: optimize watch command with robust watching, caching, and debouncing --- src/commands/watch.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 9042026..1d7a2f8 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -128,6 +128,13 @@ const tasks = async (command: Command, fd: FolderDetails, flags: Record