Skip to content

Commit 8e6e2b6

Browse files
add harper application lock file and some unit tests (#11)
1 parent 7089fcf commit 8e6e2b6

2 files changed

Lines changed: 405 additions & 20 deletions

File tree

components/Application.ts

Lines changed: 88 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,19 @@ import { CONFIG_PARAMS } from '../utility/hdbTerms.js';
33
import logger from '../utility/logging/harper_logger.js';
44

55
import { 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';
719
import { spawn } from 'node:child_process';
820
import { createReadStream, existsSync, readdirSync } from 'node:fs';
921
import { Readable } from 'node:stream';
@@ -13,43 +25,74 @@ import { extract } from 'tar-fs';
1325
import gunzip from 'gunzip-maybe';
1426

1527
interface 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

2370
export 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
*/
186229
export 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

414483
function 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

Comments
 (0)