@@ -3,7 +3,19 @@ import { CONFIG_PARAMS } from '../utility/hdbTerms.js';
33import logger from '../utility/logging/harper_logger.js' ;
44
55import { dirname , extname , join } from 'node:path' ;
6- import { access , constants , cp , mkdir , mkdtemp , readdir , readFile , rm , stat , symlink } from 'node:fs/promises' ;
6+ import {
7+ access ,
8+ constants ,
9+ cp ,
10+ mkdir ,
11+ mkdtemp ,
12+ readdir ,
13+ readFile ,
14+ rm ,
15+ stat ,
16+ symlink ,
17+ writeFile ,
18+ } from 'node:fs/promises' ;
719import { spawn } from 'node:child_process' ;
820import { createReadStream , existsSync , readdirSync } from 'node:fs' ;
921import { Readable } from 'node:stream' ;
@@ -13,43 +25,74 @@ import { extract } from 'tar-fs';
1325import gunzip from 'gunzip-maybe' ;
1426
1527interface ApplicationConfig {
28+ // define known config properties
1629 package : string ;
1730 install ?: {
1831 command ?: string ;
1932 timeout ?: number ;
2033 } ;
34+ // an application config can have other arbitrary properties
35+ [ key : string ] : unknown ;
36+ }
37+
38+ export class InvalidPackageIdentifierError extends TypeError {
39+ constructor ( applicationName : string , packageIdentifier : unknown ) {
40+ super (
41+ `Invalid 'package' property for application ${ applicationName } : expected string, got ${ typeof packageIdentifier } `
42+ ) ;
43+ }
44+ }
45+
46+ export class InvalidInstallPropertyError extends TypeError {
47+ constructor ( applicationName : string , installProperty : unknown ) {
48+ super (
49+ `Invalid 'install' property for application ${ applicationName } : expected object, got ${ typeof installProperty } `
50+ ) ;
51+ }
52+ }
53+
54+ export class InvalidInstallCommandError extends TypeError {
55+ constructor ( applicationName : string , command : unknown ) {
56+ super (
57+ `Invalid 'install.command' property for application ${ applicationName } : expected string, got ${ typeof command } `
58+ ) ;
59+ }
60+ }
61+
62+ export class InvalidInstallTimeoutError extends TypeError {
63+ constructor ( applicationName : string , timeout : unknown ) {
64+ super (
65+ `Invalid 'install.timeout' property for application ${ applicationName } : expected non-negative number, got ${ typeof timeout } `
66+ ) ;
67+ }
2168}
2269
2370export function assertApplicationConfig (
2471 applicationName : string ,
25- applicationConfig : object & Record < 'package' , unknown >
72+ applicationConfig : Record < 'package' , unknown > & Record < string , unknown >
2673) : asserts applicationConfig is ApplicationConfig {
2774 if ( typeof applicationConfig . package !== 'string' ) {
28- throw new TypeError (
29- `Invalid 'package' property for application ${ applicationName } : expected string, got ${ typeof applicationConfig . package } `
30- ) ;
75+ throw new InvalidPackageIdentifierError ( applicationName , applicationConfig . package ) ;
3176 }
3277
3378 if ( 'install' in applicationConfig ) {
34- if ( typeof applicationConfig . install !== 'object' || applicationConfig . install === null ) {
35- throw new TypeError (
36- `Invalid 'install' property for application ${ applicationName } : expected object, got ${ typeof applicationConfig . install } `
37- ) ;
79+ if (
80+ typeof applicationConfig . install !== 'object' ||
81+ applicationConfig . install === null ||
82+ Array . isArray ( applicationConfig . install )
83+ ) {
84+ throw new InvalidInstallPropertyError ( applicationName , applicationConfig . install ) ;
3885 }
3986
4087 if ( 'command' in applicationConfig . install && typeof applicationConfig . install . command !== 'string' ) {
41- throw new TypeError (
42- `Invalid 'install.command' property for application ${ applicationName } : expected string, got ${ typeof applicationConfig . install . command } `
43- ) ;
88+ throw new InvalidInstallCommandError ( applicationName , applicationConfig . install . command ) ;
4489 }
4590
4691 if (
4792 'timeout' in applicationConfig . install &&
4893 ( typeof applicationConfig . install . timeout !== 'number' || applicationConfig . install . timeout < 0 )
4994 ) {
50- throw new TypeError (
51- `Invalid 'install.timeout' property for application ${ applicationName } : expected non-negativenumber, got ${ typeof applicationConfig . install . timeout } `
52- ) ;
95+ throw new InvalidInstallTimeoutError ( applicationName , applicationConfig . install . timeout ) ;
5396 }
5497 }
5598}
@@ -184,8 +227,9 @@ export async function extractApplication(application: Application) {
184227 * This method should only be called from the main thread
185228 */
186229export async function installApplication ( application : Application ) {
230+ let packageJSON : any ;
187231 try {
188- await access ( join ( application . dirPath , 'package.json' ) , constants . F_OK ) ;
232+ packageJSON = JSON . parse ( await readFile ( join ( application . dirPath , 'package.json' ) , 'utf8' ) ) ;
189233 } catch ( err ) {
190234 if ( err . code !== 'ENOENT' ) throw err ;
191235 // If no package.json, nothing to install
@@ -225,8 +269,6 @@ export async function installApplication(application: Application) {
225269 }
226270
227271 // Next, try package.json devEngines field
228- const packageJSON = JSON . parse ( await readFile ( join ( application . dirPath , 'package.json' ) , 'utf8' ) ) ;
229-
230272 const { packageManager } = packageJSON . devEngines || { } ;
231273
232274 // Custom package manager specified
@@ -344,7 +386,7 @@ export class Application {
344386 * during the installation process in order to actually resolve what the user specifies for a
345387 * component matching some of npm's package resolution rules.
346388 */
347- function derivePackageIdentifier ( packageIdentifier : string ) {
389+ export function derivePackageIdentifier ( packageIdentifier : string ) {
348390 if ( packageIdentifier . includes ( ':' ) ) {
349391 return packageIdentifier ;
350392 }
@@ -384,6 +426,18 @@ export async function installApplications() {
384426 // Ensure component directory exists
385427 await mkdir ( componentsRootDirPath , { recursive : true } ) ;
386428
429+ const harperApplicationLockPath = join ( getConfigValue ( CONFIG_PARAMS . ROOTPATH ) , 'harper-application-lock.json' ) ;
430+
431+ let harperApplicationLock : any = { application : { } } ;
432+ try {
433+ harperApplicationLock = JSON . parse ( await readFile ( harperApplicationLockPath , 'utf8' ) ) ;
434+ } catch ( error ) {
435+ // Ignore file not found error; will create new lock file after installations
436+ if ( error . code !== 'ENOENT' ) {
437+ throw error ;
438+ }
439+ }
440+
387441 const applicationInstallationPromises : Promise < void > [ ] = [ ] ;
388442
389443 for ( const [ name , applicationConfig ] of Object . entries ( config ) ) {
@@ -403,12 +457,27 @@ export async function installApplications() {
403457 install : applicationConfig . install ,
404458 } ) ;
405459
460+ // Lock check: only install if not already installed with matching configuration
461+ if (
462+ existsSync ( application . dirPath ) &&
463+ harperApplicationLock . applications [ name ] &&
464+ JSON . stringify ( harperApplicationLock . applications [ name ] ) === JSON . stringify ( applicationConfig )
465+ ) {
466+ logger . info ( `Application ${ name } is already installed with matching configuration; skipping installation` ) ;
467+ continue ;
468+ }
469+
406470 applicationInstallationPromises . push ( prepareApplication ( application ) ) ;
471+
472+ harperApplicationLock . applications [ name ] = applicationConfig ;
407473 }
408474
409475 const applicationInstallationStatuses = await Promise . allSettled ( applicationInstallationPromises ) ;
410476 logger . debug ( applicationInstallationStatuses ) ;
411477 logger . info ( 'All root applications loaded' ) ;
478+
479+ // Finally, write the lock file
480+ await writeFile ( harperApplicationLockPath , JSON . stringify ( harperApplicationLock , null , 2 ) , 'utf8' ) ;
412481}
413482
414483function getGitSSHCommand ( ) {
@@ -455,7 +524,6 @@ export function nonInteractiveSpawn(
455524 env . GIT_SSH_COMMAND = gitSSHCommand ;
456525 }
457526
458- // eslint-disable-next-line sonarjs/os-command
459527 const childProcess = spawn ( command , args , {
460528 shell : true ,
461529 cwd,
0 commit comments