diff --git a/package.json b/package.json index 6e63366..5d4f561 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,11 @@ "@eslint/js": "^9.38.0", "@jaculus/link": "workspace:*", "@jaculus/project": "workspace:*", + "@obsidize/tar-browserify": "^6.3.2", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", "@types/node": "^24.0.7", + "@types/pako": "^2.0.4", "@zenfs/core": "^1.11.4", "chai": "^5.1.2", "chai-bytes": "^0.1.2", @@ -31,6 +33,7 @@ "husky": "^9.1.7", "jiti": "^2.5.1", "mocha": "^11.7.2", + "pako": "^2.1.0", "prettier": "^3.6.2", "queue-fifo": "^0.2.5", "tsx": "^4.20.6", diff --git a/packages/device/src/controller.ts b/packages/device/src/controller.ts index eff0043..eb11279 100644 --- a/packages/device/src/controller.ts +++ b/packages/device/src/controller.ts @@ -38,6 +38,32 @@ export const ControllerCommandStrings: Record = { [ControllerCommand.CONFIG_ERASE]: "CONFIG_ERASE", }; +enum WifiKvNs { + Ssids = "wifi_net", + Main = "wifi_cfg", +} + +enum WifiKeys { + Mode = "mode", + StaMode = "sta_mode", + StaSpecific = "sta_ssid", + StaApFallback = "sta_ap_fallback", + ApSsid = "ap_ssid", + ApPass = "ap_pass", + CurrentIp = "current_ip", +} + +export enum WifiMode { + DISABLED = 0, + STATION = 1, + AP = 2, +} + +export enum WifiStaMode { + BEST_SIGNAL = 0, + SPECIFIC_SSID = 1, +} + enum KeyValueDataType { INT64 = 0, FLOAT32 = 1, @@ -449,4 +475,67 @@ export class Controller { } ); } + + // WiFi Configuration Methods + public async addWifiNetwork(ssid: string, password: string): Promise { + this._logger?.verbose(`Adding WiFi network: ${ssid}`); + return this.configSetString(WifiKvNs.Ssids, ssid.substring(0, 15), password); + } + + public async removeWifiNetwork(ssid: string): Promise { + this._logger?.verbose(`Removing WiFi network: ${ssid}`); + return this.configErase(WifiKvNs.Ssids, ssid); + } + + public getWifiMode(): Promise { + return this.configGetInt(WifiKvNs.Main, WifiKeys.Mode) as Promise; + } + + public setWifiMode(mode: WifiMode): Promise { + return this.configSetInt(WifiKvNs.Main, WifiKeys.Mode, mode); + } + + public getWifiStaMode(): Promise { + return this.configGetInt(WifiKvNs.Main, WifiKeys.StaMode) as Promise; + } + + public setWifiStaMode(mode: WifiStaMode): Promise { + return this.configSetInt(WifiKvNs.Main, WifiKeys.StaMode, mode); + } + + public getWifiStaSpecific(): Promise { + return this.configGetString(WifiKvNs.Main, WifiKeys.StaSpecific); + } + + public setWifiStaSpecific(ssid: string): Promise { + return this.configSetString(WifiKvNs.Main, WifiKeys.StaSpecific, ssid); + } + + public getWifiStaApFallback(): Promise { + return this.configGetInt(WifiKvNs.Main, WifiKeys.StaApFallback); + } + + public setWifiStaApFallback(enabled: boolean): Promise { + return this.configSetInt(WifiKvNs.Main, WifiKeys.StaApFallback, enabled ? 1 : 0); + } + + public getWifiApSsid(): Promise { + return this.configGetString(WifiKvNs.Main, WifiKeys.ApSsid); + } + + public setWifiApSsid(ssid: string): Promise { + return this.configSetString(WifiKvNs.Main, WifiKeys.ApSsid, ssid); + } + + public getWifiApPassword(): Promise { + return this.configGetString(WifiKvNs.Main, WifiKeys.ApPass); + } + + public setWifiApPassword(password: string): Promise { + return this.configSetString(WifiKvNs.Main, WifiKeys.ApPass, password); + } + + public getCurrentWifiIp(): Promise { + return this.configGetString(WifiKvNs.Main, WifiKeys.CurrentIp); + } } diff --git a/packages/device/src/device.ts b/packages/device/src/device.ts index 5feaabb..b57c9a0 100644 --- a/packages/device/src/device.ts +++ b/packages/device/src/device.ts @@ -9,9 +9,9 @@ import { } from "@jaculus/link/muxCommunicator"; import { CobsEncoder } from "@jaculus/link/encoders/cobs"; import { Uploader } from "./uploader.js"; -import { Controller } from "./controller.js"; +import { Controller, WifiMode, WifiStaMode } from "./controller.js"; -export { Uploader, Controller }; +export { Uploader, Controller, WifiMode, WifiStaMode }; export class JacDevice { private _mux: Mux; diff --git a/packages/device/src/uploader.ts b/packages/device/src/uploader.ts index 970b703..5f351b0 100644 --- a/packages/device/src/uploader.ts +++ b/packages/device/src/uploader.ts @@ -2,6 +2,7 @@ import { InputPacketCommunicator, OutputPacketCommunicator } from "@jaculus/link import { Packet } from "@jaculus/link/linkTypes"; import { Logger } from "@jaculus/common"; import { encodePath } from "./util.js"; +import crypto from "crypto"; export enum UploaderCommand { READ_FILE = 0x01, @@ -43,6 +44,17 @@ export const UploaderCommandStrings: Record = { [UploaderCommand.GET_DIR_HASHES]: "GET_DIR_HASHES", }; +enum SyncAction { + Noop, + Delete, + Upload, +} + +interface RemoteFileInfo { + sha1: string; + action: SyncAction; +} + export class Uploader { private _in: InputPacketCommunicator; private _out: OutputPacketCommunicator; @@ -489,4 +501,92 @@ export class Uploader { packet.send(); }); } + + public async uploadIfDifferent( + remoteHashes: [string, string][], + files: Record, + to: string + ) { + const filesInfo: Record = Object.fromEntries( + remoteHashes.map(([name, sha1]) => { + return [ + name, + { + sha1: sha1, + action: SyncAction.Delete, + }, + ]; + }) + ); + + for (const [filePath, data] of Object.entries(files)) { + const sha1 = crypto.createHash("sha1").update(data).digest("hex"); + const info = filesInfo[filePath]; + if (info === undefined) { + filesInfo[filePath] = { + sha1: sha1, + action: SyncAction.Upload, + }; + this._logger?.verbose(`${filePath} is new, will upload`); + } else if (info.sha1 === sha1) { + info.action = SyncAction.Noop; + this._logger?.verbose(`${filePath} has same sha1 on device and on disk, skipping`); + } else { + info.action = SyncAction.Upload; + this._logger?.verbose(`${filePath} is different, will upload`); + } + } + + const existingFolders = new Set(); + let countUploaded = 0; + let countDeleted = 0; + + for (const [rel_path, info] of Object.entries(filesInfo)) { + const dest_path = `${to}/${rel_path}`; + switch (info.action) { + case SyncAction.Noop: + break; + case SyncAction.Delete: + try { + await this.deleteFile(dest_path); + } catch (err) { + this._logger?.verbose(`Error deleting file ${dest_path}: ${err}`); + } + ++countDeleted; + break; + case SyncAction.Upload: { + const parts = dest_path.split("/"); + let cur_dir_part = ""; + for (const p of parts.slice(0, parts.length - 1)) { + if (p === "") { + continue; + } + const abs_p = cur_dir_part + p; + if (!existingFolders.has(abs_p)) { + await this.createDirectory(abs_p).catch((err: unknown) => { + this._logger?.error("Error creating directory: " + err); + }); + existingFolders.add(abs_p); + } + cur_dir_part += `${p}/`; + } + + const data = files[rel_path]; + await this.writeFile(dest_path, data).catch((cmd: UploaderCommand) => { + throw ( + "Failed to write file (" + + dest_path + + "): " + + UploaderCommandStrings[cmd] + ); + }); + + ++countUploaded; + break; + } + } + } + + this._logger?.info(`Files synced, ${countUploaded} uploaded, ${countDeleted} deleted`); + } } diff --git a/packages/firmware/package.json b/packages/firmware/package.json index d1203bc..1dcd0c8 100644 --- a/packages/firmware/package.json +++ b/packages/firmware/package.json @@ -11,8 +11,20 @@ }, "license": "GPL-3.0-only", "type": "module", - "main": "./dist/package.js", - "types": "./dist/package.d.ts", + "exports": { + ".": { + "types": "./dist/package.d.ts", + "import": "./dist/package.js" + }, + "./boards": { + "types": "./dist/boards.d.ts", + "import": "./dist/boards.js" + }, + "./manifest": { + "types": "./dist/manifest.d.ts", + "import": "./dist/manifest.js" + } + }, "files": [ "dist" ], @@ -24,11 +36,12 @@ }, "dependencies": { "@cubicap/esptool-js": "^0.3.2", - "@obsidize/tar-browserify": "^6.1.0", + "@obsidize/tar-browserify": "^6.3.2", "cli-progress": "^3.12.0", "get-uri": "^6.0.4", "pako": "^2.1.0", - "serialport": "^13.0.0" + "serialport": "^13.0.0", + "zod": "^4.1.12" }, "devDependencies": { "@types/cli-progress": "^3.11.6", diff --git a/packages/firmware/src/boards.ts b/packages/firmware/src/boards.ts new file mode 100644 index 0000000..35b5288 --- /dev/null +++ b/packages/firmware/src/boards.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +const BOARD_INDEX_URL = "https://f.jaculus.org/bin"; +const BOARDS_INDEX_JSON = "boards.json"; +const BOARD_VERSIONS_JSON = "versions.json"; + +const BoardVariantSchema = z.object({ + name: z.string(), + id: z.string(), +}); + +const BoardsIndexSchema = z.object({ + chip: z.string(), + variants: z.array(BoardVariantSchema), +}); + +const BoardVersionSchema = z.object({ + version: z.string(), +}); + +export type BoardVariant = z.infer; +export type BoardsIndex = z.infer; +export type BoardVersion = z.infer; + +export async function getBoardsIndex(): Promise { + try { + const response = fetch(`${BOARD_INDEX_URL}/${BOARDS_INDEX_JSON}`); + const res = await response; + const data = await res.json(); + const parsed = z.array(BoardsIndexSchema).safeParse(data); + if (!parsed.success) { + console.error("Failed to parse boards index:", z.prettifyError(parsed.error)); + return []; + } + return parsed.data; + } catch (e) { + console.error(e); + return []; + } +} + +export async function getBoardVersions(boardId: string): Promise { + try { + const response = fetch(`${BOARD_INDEX_URL}/${boardId}/${BOARD_VERSIONS_JSON}`); + const res = await response; + const data = await res.json(); + const parsed = z.array(BoardVersionSchema).safeParse(data); + if (!parsed.success) { + console.error("Failed to parse board versions:", z.prettifyError(parsed.error)); + return []; + } + return parsed.data; + } catch (e) { + console.error(e); + return []; + } +} + +export function getBoardVersionFirmwareTarUrl(boardId: string, version: string): string { + return `${BOARD_INDEX_URL}/${boardId}/${boardId}-${version}.tar.gz`; +} diff --git a/packages/firmware/src/esp32/esp32.ts b/packages/firmware/src/esp32/esp32.ts index 91762c0..9ea383d 100644 --- a/packages/firmware/src/esp32/esp32.ts +++ b/packages/firmware/src/esp32/esp32.ts @@ -81,9 +81,7 @@ class UploadReporter { } export async function flash(Package: Package, path: string, noErase: boolean): Promise { - const config = Package.getManifest().getConfig(); - - const flashBaud = parseInt(config["flashBaud"] ?? 921600); + const config = Package.getManifest().config; const partitions = config["partitions"]; if (!partitions) { @@ -107,7 +105,7 @@ export async function flash(Package: Package, path: string, noErase: boolean): P const loaderOptions: any = { debugLogging: false, transport: new NodeTransport(port), - baudrate: flashBaud, + baudrate: config.flashBaud || 921600, romBaudrate: 115200, terminal: { clean: () => {}, @@ -194,7 +192,7 @@ export async function flash(Package: Package, path: string, noErase: boolean): P } export function info(Package: Package): string { - const config = Package.getManifest().getConfig(); + const config = Package.getManifest().config; let output = "Chip type: " + config["chip"] + "\n"; if (config["flashBaud"]) { diff --git a/packages/firmware/src/manifest.ts b/packages/firmware/src/manifest.ts new file mode 100644 index 0000000..6f17dc9 --- /dev/null +++ b/packages/firmware/src/manifest.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +const PartitionSchema = z.object({ + name: z.string(), + address: z.string(), + file: z.string(), + isStorage: z.boolean().optional(), +}); + +const ManifestConfigSchema = z.object({ + chip: z.string(), + flashBaud: z.number().optional(), + partitions: z.array(PartitionSchema), +}); + +const ManifestDataSchema = z.object({ + board: z.string(), + version: z.string(), + platform: z.string(), + config: ManifestConfigSchema, +}); + +export type Partition = z.infer; +export type ManifestConfig = z.infer; + +export type Manifest = Readonly<{ + board: string; + version: string; + platform: string; + config: ManifestConfig; +}>; + +/** + * Parse the manifest file + * @param data Manifest file data + * @returns The manifest + */ +export function parseManifest(data: string): Manifest { + const parsed = ManifestDataSchema.parse(JSON.parse(data)); + return Object.freeze(parsed); +} diff --git a/packages/firmware/src/package.ts b/packages/firmware/src/package.ts index b493cea..8067a58 100644 --- a/packages/firmware/src/package.ts +++ b/packages/firmware/src/package.ts @@ -2,6 +2,7 @@ import { getUri } from "get-uri"; import { Archive } from "@obsidize/tar-browserify"; import pako from "pako"; import * as espPlatform from "./esp32/esp32.js"; +import { Manifest, parseManifest } from "./manifest.js"; /** * Module for loading and flashing package files @@ -18,67 +19,6 @@ import * as espPlatform from "./esp32/esp32.js"; * Example manifest.json can be found in the flasher module. */ -export class Manifest { - private board: string; - private version: string; - private platform: string; - private config: Record; - - constructor(board: string, version: string, platform: string, config: Record) { - this.board = board; - this.version = version; - this.platform = platform; - this.config = config; - } - - public getBoard(): string { - return this.board; - } - - public getVersion(): string { - return this.version; - } - - public getPlatform(): string { - return this.platform; - } - - public getConfig(): Record { - return this.config; - } -} - -/** - * Parse the manifest file - * @param data Manifest file data - * @returns The manifest - */ -function parseManifest(data: string) { - const manifest = JSON.parse(data); - - const board = manifest["board"]; - if (!board) { - throw new Error("No board defined in manifest"); - } - - const version = manifest["version"]; - if (!version) { - throw new Error("No version defined in manifest"); - } - - const platform = manifest["platform"]; - if (!platform) { - throw new Error("No platform defined in manifest"); - } - - const config = manifest["config"]; - if (!config) { - throw new Error("No config defined in manifest"); - } - - return new Manifest(board, version, platform, config); -} - export class Package { private manifest: Manifest; private data: Record; @@ -97,7 +37,7 @@ export class Package { } public async flash(port: string, noErase: boolean): Promise { - switch (this.manifest.getPlatform()) { + switch (this.manifest.platform) { case "esp32": await espPlatform.flash(this, port, noErase); break; @@ -107,7 +47,7 @@ export class Package { } public info(): string { - switch (this.manifest.getPlatform()) { + switch (this.manifest.platform) { case "esp32": return espPlatform.info(this); default: @@ -129,7 +69,7 @@ export async function loadPackage(uri: string): Promise { } const archive = Buffer.concat(chunks); - let manifest: Manifest = new Manifest("", "", "", {}); + let manifest: Manifest | null = null; const files: Record = {}; for await (const entry of Archive.read(pako.ungzip(archive))) { @@ -143,5 +83,8 @@ export async function loadPackage(uri: string): Promise { } } + if (!manifest) { + throw new Error("No manifest.json found in package"); + } return new Package(manifest, files); } diff --git a/packages/project/package.json b/packages/project/package.json index 12e6106..fced667 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -13,16 +13,24 @@ "type": "module", "exports": { ".": { - "types": "./dist/src/project/index.d.ts", - "import": "./dist/src/project/index.js" + "types": "./dist/src/project.d.ts", + "import": "./dist/src/project.js" }, "./compiler": { "types": "./dist/src/compiler/index.d.ts", "import": "./dist/src/compiler/index.js" }, "./fs": { - "types": "./dist/src/fs/index.d.ts", - "import": "./dist/src/fs/index.js" + "types": "./dist/src/fs.d.ts", + "import": "./dist/src/fs.js" + }, + "./package": { + "types": "./dist/src/package.d.ts", + "import": "./dist/src/package.js" + }, + "./registry": { + "types": "./dist/src/registry.d.ts", + "import": "./dist/src/registry.js" } }, "files": [ @@ -36,10 +44,15 @@ }, "dependencies": { "@jaculus/common": "workspace:*", - "typescript": "^5.8.3" + "pako": "^2.1.0", + "semver": "^7.7.3", + "typescript": "^5.8.3", + "zod": "^4.1.12" }, "devDependencies": { "@types/node": "^20.0.0", + "@types/pako": "^2.0.4", + "@types/semver": "^7.7.1", "rimraf": "^6.0.1" } } diff --git a/packages/project/src/compiler/index.ts b/packages/project/src/compiler/index.ts index 4647475..9f42cc4 100644 --- a/packages/project/src/compiler/index.ts +++ b/packages/project/src/compiler/index.ts @@ -1,8 +1,7 @@ -import { Logger } from "@jaculus/common"; import * as tsvfs from "./vfs.js"; import path from "path"; import { fileURLToPath } from "url"; -import { FSInterface } from "../fs/index.js"; +import { FSInterface } from "../fs.js"; import ts from "typescript"; type Writable = { write: (chunk: string) => void }; @@ -20,13 +19,121 @@ function printMessage(message: string | ts.DiagnosticMessageChain, stream: Writa } } +function validateProjectTsconfig(compilerOptions: ts.CompilerOptions, outDir: string) { + const forcedOptions: Record = { + target: [ts.ScriptTarget.ES2023, ts.ScriptTarget.ES2020], + module: [ts.ModuleKind.ES2022, ts.ModuleKind.ES2020], + moduleResolution: [ts.ModuleResolutionKind.NodeJs], + resolveJsonModule: [false], + esModuleInterop: [true], + outDir: [outDir], + }; + + const optionNames: Record> = { + target: Object.entries(ts.ScriptTarget).reduce((acc, [k, v]) => ({ ...acc, [v]: k }), {}), + module: Object.entries(ts.ModuleKind).reduce((acc, [k, v]) => ({ ...acc, [v]: k }), {}), + moduleResolution: Object.entries(ts.ModuleResolutionKind).reduce( + (acc, [k, v]) => ({ ...acc, [v]: k }), + {} + ), + }; + + for (const [key, values] of Object.entries(forcedOptions)) { + const valueNames = values.map((v) => optionNames[key]?.[v] ?? v).join(", "); + if (compilerOptions[key] && !values.includes(compilerOptions[key])) { + throw new Error(`tsconfig.json must have ${key} set to one of: [ ${valueNames} ]`); + } else if (!compilerOptions[key]) { + compilerOptions[key] = values[0]; + } + } +} + +export async function compileProject( + fs: FSInterface, + projectPath: string, + err: Writable, + out?: Writable, + tsLibsPath: string = path.dirname( + fileURLToPath(import.meta.resolve?.("typescript") ?? "typescript") + ) +): Promise { + const outDir = "build"; + const system = tsvfs.createSystem(fs, projectPath); + + const tsconfig = ts.findConfigFile("./", system.fileExists, "tsconfig.json"); + if (!tsconfig) { + throw new Error(`Could not find tsconfig.json in directory: ${projectPath}`); + } + const configJsonFile = ts.readConfigFile(tsconfig, system.readFile); + if (configJsonFile.error) { + printMessage(configJsonFile.error.messageText, err); + throw new Error("Error reading tsconfig.json"); + } + + validateProjectTsconfig(configJsonFile.config, outDir); + return await compile( + fs, + projectPath, + outDir, + configJsonFile.config, + system, + err, + out, + false, + tsLibsPath + ); +} + +export async function compileLibrary( + fs: FSInterface, + libraryPath: string, + err: Writable, + out?: Writable, + transpileOnly: boolean = false, + tsLibsPath: string = path.dirname( + fileURLToPath(import.meta.resolve?.("typescript") ?? "typescript") + ) +): Promise { + const outDir = "dist"; + const system = tsvfs.createSystem(fs, libraryPath); + + const configJson = { + compilerOptions: { + target: "ES2023", + module: "ES2022", + lib: ["es2023"], + moduleResolution: "node", + declaration: true, + declarationDir: "dist/types", + outDir: "dist/js", + rootDir: "src", + strict: true, + baseUrl: ".", + noEmitOnError: !transpileOnly, + }, + include: ["src"], + }; + + return await compile( + fs, + libraryPath, + outDir, + configJson, + system, + err, + out, + transpileOnly, + tsLibsPath + ); +} + /** * Compiles TypeScript files with custom FSInterface * @param fs - The file system interface (Node, zenfs, etc.) * @param inputDir - The input directory containing TypeScript files. * @param outDir - The output directory for compiled files. * @param err - The writable stream for error messages. - * @param logger - The logger instance. + * @param out - The writable stream for standard output messages. * @param tsLibsPath - The path to TypeScript libraries (in Node, it's the directory of the 'typescript' package) * (in zenfs, it's necessary to provide this path and copy TS files to the virtual FS in advance) * @returns A promise that resolves to true if compilation is successful, false otherwise. @@ -35,59 +142,28 @@ export async function compile( fs: FSInterface, inputDir: string, outDir: string, + configJson: Record, + system: ts.System, err: Writable, - logger?: Logger, + out?: Writable, + transpileOnly: boolean = false, tsLibsPath: string = path.dirname( fileURLToPath(import.meta.resolve?.("typescript") ?? "typescript") ) ): Promise { - const system = tsvfs.createSystem(fs, inputDir); - const tsconfig = ts.findConfigFile("./", system.fileExists, "tsconfig.json"); - if (!tsconfig) { - throw new Error(`Could not find tsconfig.json in directory: ${inputDir}`); - } - const config = ts.readConfigFile(tsconfig, system.readFile); - if (config.error) { - printMessage(config.error.messageText, err); - throw new Error("Error reading tsconfig.json"); - } - - const forcedOptions: Record = { - target: [ts.ScriptTarget.ES2023, ts.ScriptTarget.ES2020], - module: [ts.ModuleKind.ES2022, ts.ModuleKind.ES2020], - moduleResolution: [ts.ModuleResolutionKind.NodeJs], - resolveJsonModule: [false], - esModuleInterop: [true], - outDir: [outDir], - }; - - const { - options: compilerOptions, - fileNames, - errors, - } = ts.parseJsonConfigFileContent(config.config, system, "./"); + const { options, fileNames, errors } = ts.parseJsonConfigFileContent(configJson, system, "./"); if (errors.length > 0) { errors.forEach((error) => printMessage(error.messageText, err)); - throw new Error("Error parsing tsconfig.json"); - } - - for (const [key, values] of Object.entries(forcedOptions)) { - if (compilerOptions[key] && !values.includes(compilerOptions[key])) { - throw new Error( - `tsconfig.json must have ${key} set to one of: [ ${values.join(", ")} ]` - ); - } else if (!compilerOptions[key]) { - compilerOptions[key] = values[0]; - } + throw new Error(`Error parsing tsconfig.json - ${errors.length} error(s) found`); } - logger?.verbose("Compiling files:" + fileNames.join(", ")); + out?.write("Compiling files: [" + fileNames.join(", ") + "]\n"); - const host = tsvfs.createVirtualCompilerHost(system, compilerOptions, tsLibsPath); + const host = tsvfs.createVirtualCompilerHost(system, options, tsLibsPath); const program = ts.createProgram({ rootNames: fileNames, - options: compilerOptions, + options, host: host.compilerHost, }); const emitResult = program.emit(); @@ -117,5 +193,5 @@ export async function compile( throw new Error("Compilation failed"); } - return !emitResult.emitSkipped && !error; + return !emitResult.emitSkipped && (transpileOnly || !error); } diff --git a/packages/project/src/compiler/vfs.ts b/packages/project/src/compiler/vfs.ts index 2c31191..6cc6156 100644 --- a/packages/project/src/compiler/vfs.ts +++ b/packages/project/src/compiler/vfs.ts @@ -1,7 +1,7 @@ import { System, CompilerOptions, CompilerHost, SourceFile } from "typescript"; import ts from "typescript"; import path from "path"; -import { FSInterface } from "../fs/index.js"; +import { FSInterface } from "../fs.js"; function notImplemented(methodName: string): any { throw new Error(`Method '${methodName}' is not implemented.`); @@ -51,7 +51,7 @@ export function createSystem(fsVirtual: FSInterface, preFix: string): System { readDirectory: (directory, extensions, excludes, includes, depth) => { const absoluteDir = resolveVirtualPath(directory); - // ts.matchFiles is an internal API not exposed in types, but it exists + // ts.matchFiles is an internal API not exposed in types, but it is used by the compiler const matchResult = (ts as any).matchFiles( absoluteDir, extensions, diff --git a/packages/project/src/fs.ts b/packages/project/src/fs.ts new file mode 100644 index 0000000..7ef744e --- /dev/null +++ b/packages/project/src/fs.ts @@ -0,0 +1,128 @@ +import path from "path"; +import { Archive } from "@obsidize/tar-browserify"; +import pako from "pako"; + +export type FSPromisesInterface = typeof import("fs").promises; +export type FSInterface = typeof import("fs"); + +export type RequestFunction = (baseUri: string, libFile: string) => Promise; +export class JaculusRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "JaculusRequestError"; + } +} + +export async function getRequestJson( + getRequest: RequestFunction, + baseUri: string, + libFile: string +): Promise { + return getRequest(baseUri, libFile).then((data) => { + const text = new TextDecoder().decode(data); + return JSON.parse(text); + }); +} + +export async function copyFolder( + fsSource: FSInterface, + dirSource: string, + fsDest: FSInterface, + dirDest: string, + copySubdirs: boolean = true +) { + if (!fsSource.existsSync(dirSource)) { + console.warn(`Source directory ${dirSource} does not exist, skipping copy.`); + return; + } + + if (!fsDest.existsSync(dirDest)) { + await fsDest.promises.mkdir(dirDest, { recursive: true }); + } + + const items = fsSource.readdirSync(dirSource); + for (const item of items) { + const sourcePath = path.join(dirSource, item); + const destPath = path.join(dirDest, item); + const stats = fsSource.statSync(sourcePath); + if (stats.isDirectory() && copySubdirs) { + await copyFolder(fsSource, sourcePath, fsDest, destPath); + } else if (stats.isFile()) { + const content = fsSource.readFileSync(sourcePath, "utf-8"); + await fsDest.promises.writeFile(destPath, content, "utf-8"); + } + } +} + +export function recursivelyPrintFs(fs: FSInterface, dir: string, indent: string = "") { + const items = fs.readdirSync(dir); + for (const item of items) { + const fullPath = path.join(dir, item); + const stats = fs.statSync(fullPath); + if (stats.isDirectory()) { + console.log(`${indent}[DIR] ${item}`); + recursivelyPrintFs(fs, fullPath, indent + " "); + } else { + console.log(`${indent}[FILE] ${item}`); + } + } +} + +export async function extractTgzPackage( + packageData: Uint8Array, + fs: FSInterface, + extractionRoot: string +): Promise { + const fsp = fs.promises; + if (!fs.existsSync(extractionRoot)) { + await fsp.mkdir(extractionRoot, { recursive: true }); + } + + for await (const entry of Archive.read(pako.ungzip(packageData))) { + // archive entries are prefixed with "package/" -> skip that part + if (!entry.fileName.startsWith("package/")) { + continue; + } + const relativePath = entry.fileName.substring("package/".length); + if (!relativePath) { + continue; + } + + const fullPath = path.join(extractionRoot, relativePath); + + if (entry.isDirectory()) { + if (!fs.existsSync(fullPath)) { + await fsp.mkdir(fullPath, { recursive: true }); + } + } else if (entry.isFile()) { + const dirPath = path.dirname(fullPath); + if (!fs.existsSync(dirPath)) { + await fsp.mkdir(dirPath, { recursive: true }); + } + await fsp.writeFile(fullPath, entry.content!); + } + } +} + +export async function traverseDirectory( + fsp: FSPromisesInterface, + dir: string, + fileCallback: (filePath: string, content: Uint8Array) => Promise, + filterFiles?: (filePath: string) => boolean, + filterDirs?: (dirPath: string) => boolean +) { + const entries = await fsp.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!filterDirs || filterDirs(fullPath)) { + await traverseDirectory(fsp, fullPath, fileCallback, filterFiles, filterDirs); + } + } else if (entry.isFile()) { + if (!filterFiles || filterFiles(fullPath)) { + const content = await fsp.readFile(fullPath); + await fileCallback(fullPath, content); + } + } + } +} diff --git a/packages/project/src/fs/index.ts b/packages/project/src/fs/index.ts deleted file mode 100644 index e3676ea..0000000 --- a/packages/project/src/fs/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import path from "path"; - -export type FSPromisesInterface = typeof import("fs").promises; -export type FSInterface = typeof import("fs"); - -export async function copyFolder( - fsSource: FSInterface, - dirSource: string, - fsDest: FSInterface, - dirDest: string, - copySubdirs: boolean = true -) { - if (!fsSource.existsSync(dirSource)) { - console.warn(`Source directory ${dirSource} does not exist, skipping copy.`); - return; - } - - if (!fsDest.existsSync(dirDest)) { - fsDest.mkdirSync(dirDest, { recursive: true }); - } - - const items = fsSource.readdirSync(dirSource); - for (const item of items) { - const sourcePath = path.join(dirSource, item); - const destPath = path.join(dirDest, item); - const stats = fsSource.statSync(sourcePath); - if (stats.isDirectory() && copySubdirs) { - await copyFolder(fsSource, sourcePath, fsDest, destPath); - } else if (stats.isFile()) { - const content = fsSource.readFileSync(sourcePath, "utf-8"); - fsDest.writeFileSync(destPath, content, "utf-8"); - } - } -} - -export function recursivelyPrintFs(fs: FSInterface, dir: string, indent: string = "") { - const items = fs.readdirSync(dir); - for (const item of items) { - const fullPath = path.join(dir, item); - const stats = fs.statSync(fullPath); - if (stats.isDirectory()) { - console.log(`${indent}[DIR] ${item}`); - recursivelyPrintFs(fs, fullPath, indent + " "); - } else { - console.log(`${indent}[FILE] ${item}`); - } - } -} diff --git a/packages/project/src/package.ts b/packages/project/src/package.ts new file mode 100644 index 0000000..21f234e --- /dev/null +++ b/packages/project/src/package.ts @@ -0,0 +1,124 @@ +import * as z from "zod"; +import path from "path"; +import { FSInterface } from "./fs.js"; + +// name: npm package name pattern (allows scoped packages like @org/name) +// Got from: https://github.com/SchemaStore/schemastore/tree/d2684d4406cb26c254dffde1f43b5d1ee51c531a/src/schemas/json/package.json#L349-L354 +const NameSchema = z + .string() + .min(1) + .max(214) + .regex(/^(?:(?:@(?:[a-z0-9-*~][a-z0-9-*._~]*)?\/[a-z0-9-._~])|[a-z0-9-~])[a-z0-9-._~]*$/); + +// version: semver (1.0.0, 0.1.0, 0.0.1, 1.0.0-beta, etc) +const VersionFormat = z + .string() + .min(1) + .regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/); + +const DescriptionSchema = z.string(); + +// dependencies: record of package name to version (currently only exact version) +const DependenciesSchema = z.record(NameSchema, VersionFormat); +const RegistryUrisSchema = z.array(z.string()); +export const JaculusProjectTypeSchema = z.enum(["code", "jacly"]); + +const JaculusSchema = z.object({ + blocks: z.string().optional(), + projectType: JaculusProjectTypeSchema.optional(), + template: z.boolean().optional(), +}); + +const ExportKeyValueSchema = z.record(z.string(), z.string()); + +const ExportsSchema = z.union([z.string(), ExportKeyValueSchema]); + +const PackageJsonSchema = z.object({ + name: NameSchema, + version: VersionFormat, + description: DescriptionSchema.optional(), + dependencies: DependenciesSchema.default({}), + registry: RegistryUrisSchema.optional(), + jaculus: JaculusSchema.optional(), + type: z.enum(["module"]).optional(), + main: z.string().optional(), + scripts: z.record(z.string(), z.string()).optional(), + exports: ExportsSchema.optional(), + types: z.string().optional(), +}); + +export type DependencyObject = { + name: string; + version: string; +}; +export type Dependencies = z.infer; +export type RegistryUris = z.infer; +export type PackageJson = z.infer; +export type JaculusProjectType = z.infer; +export type JaculusConfig = z.infer; + +export function projectJsonSchema() { + return z.toJSONSchema(PackageJsonSchema, {}); +} + +export class InvalidPackageJsonFormatError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidPackageJsonFormatError"; + } +} + +export function parsePackageJson(json: unknown, filePathLog: string): PackageJson { + const result = PackageJsonSchema.safeParse(json); + if (!result.success) { + const pretty = z.prettifyError(result.error); + throw new InvalidPackageJsonFormatError( + `Invalid package.json format at '${filePathLog}': ${pretty}` + ); + } + return result.data; +} + +export async function loadPackageJson(fs: FSInterface, filePath: string): Promise { + const data = await fs.promises.readFile(filePath, { encoding: "utf-8" }); + let json: any; + try { + json = JSON.parse(data); + } catch (error) { + console.error(`Failed to parse package.json at ${filePath}:`, error); + throw new InvalidPackageJsonFormatError(`Invalid JSON format: ${(error as Error).message}`); + } + return parsePackageJson(json, filePath); +} + +export async function savePackageJson( + fs: FSInterface, + filePath: string, + pkg: PackageJson +): Promise { + const data = JSON.stringify(pkg, null, 4); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }); + } + + await fs.promises.writeFile(filePath, data, { encoding: "utf-8" }); +} + +export function splitLibraryNameVersion(library: string): { name: string; version: string | null } { + const lastAtIndex = library.lastIndexOf("@"); + + // No @ found or @ is at the beginning (scoped package without version) + if (lastAtIndex <= 0) { + return { name: library, version: null }; + } + + const name = library.substring(0, lastAtIndex); + const version = library.substring(lastAtIndex + 1); + + return { name, version: version || null }; +} + +export function getPackagePath(projectPath: string, name: string): string { + return path.join(projectPath, "node_modules", name); +} diff --git a/packages/project/src/project.ts b/packages/project/src/project.ts new file mode 100644 index 0000000..dca4450 --- /dev/null +++ b/packages/project/src/project.ts @@ -0,0 +1,474 @@ +import path from "path"; +import { Writable } from "stream"; +import { extractTgzPackage, FSInterface, traverseDirectory } from "./fs.js"; +import { Registry } from "./registry.js"; +import { + loadPackageJson, + savePackageJson, + Dependencies, + DependencyObject, + PackageJson, + getPackagePath, +} from "./package.js"; + +export type ResolvedDependencies = Dependencies; +type DataSourceType = "registry" | "local"; + +export interface ProjectPackage { + dirs: string[]; + files: Record; +} + +export interface JaclyBlocksFiles { + [filePath: string]: object; +} + +export interface JaclyBlocksTranslations { + [key: string]: string; +} + +export interface JaclyBlocksData { + blockFiles: JaclyBlocksFiles; + translations: JaclyBlocksTranslations; +} + +export class ProjectError extends Error { + constructor(message: string) { + super(message); + this.name = "ProjectError"; + } +} + +export class ProjectDependencyError extends ProjectError { + constructor( + message: string, + public readonly conflictingLib?: string, + public readonly requested?: string, + public readonly resolved?: string + ) { + super(message); + this.name = "ProjectDependencyError"; + } +} + +export class Project { + constructor( + public fs: FSInterface, + public projectPath: string, + public out: Writable, + public err: Writable, + public registry?: Registry + ) {} + + private async unpackPackage( + pkg: ProjectPackage, + filter: (fileName: string) => boolean, + dryRun: boolean = false + ): Promise { + for (const dir of pkg.dirs) { + const source = dir; + const fullPath = path.join(this.projectPath, source); + if (!this.fs.existsSync(fullPath) && !dryRun) { + this.err.write(`Create directory: ${fullPath}\n`); + await this.fs.promises.mkdir(fullPath, { recursive: true }); + } + } + + for (const [fileName, data] of Object.entries(pkg.files)) { + const source = fileName; + + if (!filter(source)) { + this.out.write(`[skip] ${source}\n`); + continue; + } + const fullPath = path.join(this.projectPath, source); + + const exists = this.fs.existsSync(fullPath); + this.out.write( + `${dryRun ? "[dry-run] " : ""}${exists ? "Overwrite" : "Create"} ${fullPath}\n` + ); + + if (!dryRun) { + const dir = path.dirname(fullPath); + if (!this.fs.existsSync(dir)) { + await this.fs.promises.mkdir(dir, { recursive: true }); + } + await this.fs.promises.writeFile(fullPath, data); + } + } + } + + async createFromPackage( + pkg: ProjectPackage, + dryRun: boolean = false, + validateFolder: boolean = true + ): Promise { + if (validateFolder && !dryRun && this.fs.existsSync(this.projectPath)) { + throw new ProjectError(`Directory '${this.projectPath}' already exists`); + } + + const filter = (fileName: string): boolean => { + if (fileName === "manifest.json") { + return false; + } + return true; + }; + + await this.unpackPackage(pkg, filter, dryRun); + } + + async updateFromPackage(pkg: ProjectPackage, dryRun: boolean = false): Promise { + if (!this.fs.existsSync(this.projectPath)) { + throw new ProjectError(`Directory '${this.projectPath}' does not exist`); + } + + if (!this.fs.statSync(this.projectPath).isDirectory()) { + throw new ProjectError(`Path '${this.projectPath}' is not a directory`); + } + + let manifest; + if (pkg.files["manifest.json"]) { + manifest = JSON.parse(new TextDecoder().decode(pkg.files["manifest.json"])); + } + + let skeleton: string[]; + if (!manifest || !manifest["skeletonFiles"]) { + skeleton = ["@types/*", "tsconfig.json"]; + } else { + const input = manifest["skeletonFiles"]; + skeleton = []; + for (const entry of input) { + if (typeof entry === "string") { + skeleton.push(entry); + } else { + throw new ProjectError(`Invalid skeleton entry: ${JSON.stringify(entry)}`); + } + } + } + + const filter = (fileName: string): boolean => { + if (fileName === "manifest.json") { + return false; + } + for (const pattern of skeleton) { + if (path.matchesGlob(fileName, pattern)) { + return true; + } + } + return false; + }; + + await this.unpackPackage(pkg, filter, dryRun); + } + + async loadProjectPackageJson(): Promise { + return loadPackageJson(this.fs, path.join(this.projectPath, "package.json")); + } + + async saveProjectPackageJson(pkg: PackageJson): Promise { + await savePackageJson(this.fs, path.join(this.projectPath, "package.json"), pkg); + } + + async installedLibraries( + includeResolvedDependencies: boolean = false + ): Promise { + const pkg = await this.loadProjectPackageJson(); + if (!includeResolvedDependencies) { + return pkg.dependencies; + } + return await this.resolveDependencies(pkg.dependencies, "registry"); + } + + async install(): Promise { + if (!this.registry) { + throw new ProjectError("Registry is not defined for the project"); + } + this.out.write("Resolving project dependencies...\n"); + const pkg = await this.loadProjectPackageJson(); + const resolvedDeps = await this.resolveDependencies(pkg.dependencies, "registry"); + await this.installDependencies(resolvedDeps); + return pkg.dependencies; + } + + async addLibraryVersion(library: string, version: string): Promise { + if (!this.registry) { + throw new ProjectError("Registry is not defined for the project"); + } + if (!(await this.registry?.exists(library))) { + throw new ProjectError(`Library '${library}' does not exist in the registry`); + } + + this.out.write(`Adding library '${library}@${version}' to project.\n`); + const pkg = await this.loadProjectPackageJson(); + const resolvedDependencies = await this.addLibVersion(library, version, pkg.dependencies); + pkg.dependencies[library] = version; + await this.saveProjectPackageJson(pkg); + await this.installDependencies(resolvedDependencies); + return pkg.dependencies; + } + + async addLibrary(library: string): Promise { + if (!this.registry) { + throw new ProjectError("Registry is not defined for the project"); + } + this.out.write(`Adding library '${library}' to project.\n`); + if (!(await this.registry?.exists(library))) { + throw new ProjectError(`Library '${library}' does not exist in the registry`); + } + + const pkg = await this.loadProjectPackageJson(); + const baseDeps = await this.resolveDependencies({ ...pkg.dependencies }, "registry"); + const versions = (await this.registry?.listVersions(library)) || []; + for (const version of versions) { + const resolvedDependencies = await this.addLibVersion(library, version, baseDeps); + pkg.dependencies[library] = version; + await this.saveProjectPackageJson(pkg); + await this.installDependencies(resolvedDependencies); + return pkg.dependencies; + } + throw new ProjectDependencyError( + `Failed to add library '${library}' to project with any available version` + ); + } + + async removeLibrary(libName: string): Promise { + if (!this.registry) { + throw new ProjectError("Registry is not defined for the project"); + } + this.out.write(`Removing library '${libName}' from project...\n`); + const pkg = await this.loadProjectPackageJson(); + if (!(libName in pkg.dependencies)) { + throw new ProjectError( + `Library '${libName}' has not been found in project dependencies` + ); + } + delete pkg.dependencies[libName]; + await this.saveProjectPackageJson(pkg); + + const resolvedDeps = await this.resolveDependencies(pkg.dependencies, "local"); + await this.installDependencies(resolvedDeps); + this.out.write(`Successfully removed library '${libName}' from project\n`); + return pkg.dependencies; + } + + private async resolveDependencies( + dependencies: Dependencies, + dataSourceType: DataSourceType = "registry" + ): Promise { + if (!this.registry) { + throw new ProjectError("Registry is not defined for the project"); + } + const resolvedDeps = { ...dependencies }; + const processedLibraries = new Set(); + const queue: Array = []; + + // start with direct dependencies + for (const [libName, libVersion] of Object.entries(resolvedDeps)) { + queue.push({ name: libName, version: libVersion }); + } + + // process BFS for dependencies + while (queue.length > 0) { + const dep = queue.shift()!; + + if (processedLibraries.has(dep.name)) { + continue; + } + processedLibraries.add(dep.name); + + try { + let packageJson: PackageJson | undefined; + + if (dataSourceType === "local") { + // use local package.json + const localPkgPath = path.join( + this.projectPath, + "node_modules", + dep.name, + "package.json" + ); + if (this.fs.existsSync(localPkgPath)) { + packageJson = await loadPackageJson(this.fs, localPkgPath); + } + } else { + // fetch from registry + packageJson = await this.registry?.getPackageJson(dep.name, dep.version); + } + + if (!packageJson) { + if (dataSourceType === "local") { + this.err.write( + `Package '${dep.name}@${dep.version}' not found locally in node_modules. Skipping transitive deps.\n` + ); + continue; + } + // TODO: fix it + throw new Error(`Package '${dep.name}@${dep.version}' not found in registry`); + } + + for (const [libName, libVersion] of Object.entries(packageJson.dependencies)) { + if (libName in resolvedDeps) { + // check for version conflicts - only allow exact matches + if (resolvedDeps[libName] !== libVersion) { + throw new ProjectDependencyError( + `Version conflict for library '${libName}': requested '${libVersion}', already resolved '${resolvedDeps[libName]}'`, + libName, + libVersion, + resolvedDeps[libName] + ); + } + continue; + } + + // add new dependency and enqueue for processing + resolvedDeps[libName] = libVersion; + queue.push({ name: libName, version: libVersion }); + } + } catch (error) { + if (dataSourceType === "local") { + this.err.write( + `Warning: Could not resolve local dependencies for '${dep.name}@${dep.version}': ${error}\n` + ); + continue; + } + this.err.write( + `Failed to resolve dependencies for '${dep.name}@${dep.version}': ${error}\n` + ); + throw error; + } + } + + return resolvedDeps; + } + + private async installDependencies(dependencies: Dependencies): Promise { + if (!this.registry) { + throw new ProjectError("Registry is not defined for the project"); + } + const nodeModulesPath = path.join(this.projectPath, "node_modules"); + if (this.fs.existsSync(nodeModulesPath)) { + await this.fs.promises.rm(nodeModulesPath, { recursive: true, force: true }); + } + + // install all resolved dependencies + for (const [libName, libVersion] of Object.entries(dependencies)) { + try { + this.out.write(` - Installing library '${libName}' version '${libVersion}'\n`); + const packageData = await this.registry.getPackageTgz(libName, libVersion); + const installPath = getPackagePath(this.projectPath, libName); + await extractTgzPackage(packageData, this.fs, installPath); + } catch (error) { + this.err.write(`Failed to install library '${libName}@${libVersion}': ${error}\n`); + throw error; + } + } + this.out.write("All dependencies resolved and installed successfully.\n"); + } + + private async addLibVersion( + library: string, + version: string, + testedDeps: Dependencies + ): Promise { + const newDeps = { ...testedDeps, [library]: version }; + return await this.resolveDependencies(newDeps, "registry"); + } + + /** + * Get JacLy block files and translations for all libraries + * @param locale - The locale for translations (e.g., "en", "cs") + * @returns JaclyData + */ + async getJaclyData(locale: string): Promise { + const pkg = await this.loadProjectPackageJson(); + const resolvedDeps = await this.resolveDependencies(pkg.dependencies, "local"); + const jaclyData: JaclyBlocksData = { blockFiles: {}, translations: {} }; + + for (const [libName] of Object.entries(resolvedDeps)) { + const pkgPath = path.join(this.projectPath, "node_modules", libName, "package.json"); + if (!this.fs.existsSync(pkgPath)) { + continue; + } + + let libPkg; + try { + libPkg = await loadPackageJson(this.fs, pkgPath); + } catch (e) { + this.err.write(`Failed to load package.json for '${libName}': ${e}. Skipping.\n`); + continue; + } + + if (libPkg.jaculus && libPkg.jaculus.blocks) { + const blocksDir = path.join( + this.projectPath, + "node_modules", + libName, + libPkg.jaculus.blocks + ); + + if (this.fs.existsSync(blocksDir)) { + const files = this.fs.readdirSync(blocksDir); + for (const file of files) { + const justFilename = path.basename(file); + if (!/^[a-zA-Z0-9_-]+\.jacly\.json$/.test(justFilename)) { + continue; + } + const fullPath = path.join(blocksDir, file); + try { + const fileContent = await this.fs.promises.readFile(fullPath, "utf-8"); + jaclyData.blockFiles[fullPath] = JSON.parse(fileContent); + } catch (e) { + this.err.write( + `Failed to read/parse JacLy block file '${fullPath}': ${e}\n` + ); + throw e; + } + } + } + + const translationFile = path.join(blocksDir, "translations", `${locale}.lang.json`); + if (this.fs.existsSync(translationFile)) { + try { + const fileContent = await this.fs.promises.readFile( + translationFile, + "utf-8" + ); + const localeTranslations = JSON.parse(fileContent); + Object.assign(jaclyData.translations, localeTranslations); + } catch (e) { + this.err.write( + `Failed to read/parse JacLy translation file '${translationFile}': ${e}\n` + ); + throw e; + } + } + } + } + return jaclyData; + } + + async getFlashFiles(): Promise> { + const jaculusFiles: Record = {}; + + const collectFlashFiles = async (dirPath: string, prefix: string = "") => { + if (!this.fs.existsSync(dirPath)) return; + await traverseDirectory( + this.fs.promises, + dirPath, + async (filePath: string, content: Uint8Array) => { + const relativePath = path.relative(dirPath, filePath).replace(/\\/g, "/"); + jaculusFiles[path.join(prefix, relativePath)] = content; + }, + (filePath: string) => + path.extname(filePath) === ".js" || path.basename(filePath) === "package.json" + ); + }; + + jaculusFiles["package.json"] = this.fs.readFileSync( + path.join(this.projectPath, "package.json") + ); + await collectFlashFiles(path.join(this.projectPath, "build")); + await collectFlashFiles(path.join(this.projectPath, "node_modules"), "node_modules"); + return jaculusFiles; + } +} diff --git a/packages/project/src/project/index.ts b/packages/project/src/project/index.ts deleted file mode 100644 index f7b958d..0000000 --- a/packages/project/src/project/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import path from "path"; -import { Writable } from "stream"; -import { FSInterface } from "../fs/index.js"; - -export interface ProjectPackage { - dirs: string[]; - files: Record; -} - -export async function unpackPackage( - fs: FSInterface, - outPath: string, - pkg: ProjectPackage, - filter: (fileName: string) => boolean, - err: Writable, - dryRun: boolean = false -): Promise { - for (const dir of pkg.dirs) { - const source = dir; - const fullPath = path.join(outPath, source); - if (!fs.existsSync(fullPath) && !dryRun) { - err.write(`Create directory: ${fullPath}\n`); - await fs.promises.mkdir(fullPath, { recursive: true }); - } - } - - for (const [fileName, data] of Object.entries(pkg.files)) { - const source = fileName; - - if (!filter(source)) { - err.write(`Skip file: ${source}\n`); - continue; - } - const fullPath = path.join(outPath, source); - - err.write(`${fs.existsSync(fullPath) ? "Overwrite" : "Create"} file: ${fullPath}\n`); - if (!dryRun) { - const dir = path.dirname(fullPath); - if (!fs.existsSync(dir)) { - await fs.promises.mkdir(dir, { recursive: true }); - } - await fs.promises.writeFile(fullPath, data); - } - } -} - -export async function createProject( - fs: FSInterface, - outPath: string, - pkg: ProjectPackage, - err: Writable, - dryRun: boolean = false -): Promise { - if (fs.existsSync(outPath)) { - err.write(`Directory '${outPath}' already exists\n`); - throw 1; - } - - const filter = (fileName: string): boolean => { - if (fileName === "manifest.json") { - return false; - } - return true; - }; - - await unpackPackage(fs, outPath, pkg, filter, err, dryRun); -} - -export async function updateProject( - fs: FSInterface, - outPath: string, - pkg: ProjectPackage, - err: Writable, - dryRun: boolean = false -): Promise { - if (!fs.existsSync(outPath)) { - err.write(`Directory '${outPath}' does not exist\n`); - throw 1; - } - - if (!fs.statSync(outPath).isDirectory()) { - err.write(`Path '${outPath}' is not a directory\n`); - throw 1; - } - - let manifest; - if (pkg.files["manifest.json"]) { - manifest = JSON.parse(new TextDecoder().decode(pkg.files["manifest.json"])); - } - - let skeleton: string[]; - if (!manifest || !manifest["skeletonFiles"]) { - skeleton = ["@types/*", "tsconfig.json"]; - } else { - const input = manifest["skeletonFiles"]; - skeleton = []; - for (const entry of input) { - if (typeof entry === "string") { - skeleton.push(entry); - } else { - err.write(`Invalid skeleton entry: ${JSON.stringify(entry)}\n`); - throw 1; - } - } - } - - const filter = (fileName: string): boolean => { - if (fileName === "manifest.json") { - return false; - } - for (const pattern of skeleton) { - if (path.matchesGlob(fileName, pattern)) { - return true; - } - } - return false; - }; - - await unpackPackage(fs, outPath, pkg, filter, err, dryRun); -} diff --git a/packages/project/src/registry.ts b/packages/project/src/registry.ts new file mode 100644 index 0000000..13a648f --- /dev/null +++ b/packages/project/src/registry.ts @@ -0,0 +1,253 @@ +import semver from "semver"; +import { getRequestJson, RequestFunction, JaculusRequestError } from "./fs.js"; +import { + parsePackageJson, + PackageJson, + JaculusProjectTypeSchema, + JaculusProjectType, +} from "./package.js"; +import * as z from "zod"; +import { Logger } from "@jaculus/common"; + +export const DefaultRegistryUrl = ["http://127.0.0.1:3737/", "https://registry.jaculus.org"]; + +/** + * + * Registry dist structure: + * Registry/ + * |-- list.json (list of packages) [{"id":"core"},{"id":"smart-led"}] + * |-- / + * |-- versions.json (list of versions) [{"version":"0.0.24"},{"version":"0.0.25"}] + * |-- / + * |-- package.tar.gz + * |-- package.json (same as in package) + * + * package.tar.gz contains: + * package/ + * |-- dist/ + * |-- blocks/ + * |-- package.json + * |-- README.md + */ + +export class RegistryFetchError extends Error { + constructor(message: string) { + super(message); + this.name = "RegistryFetchError"; + } +} + +const RegistryListSchema = z.array( + z.object({ + id: z.string(), + projectType: JaculusProjectTypeSchema.optional(), + isTemplate: z.boolean().optional(), + }) +); + +const RegistryVersionsSchema = z.array( + z.object({ + version: z.string(), + }) +); + +export type RegistryList = z.infer; +export type RegistryVersions = z.infer; + +export function parseRegistryList(json: object): RegistryList { + const result = RegistryListSchema.safeParse(json); + if (!result.success) { + const pretty = z.prettifyError(result.error); + throw new Error(`Invalid registry list format:\n${pretty}`); + } + return result.data; +} + +export function parseRegistryVersions(json: object): RegistryVersions { + const result = RegistryVersionsSchema.safeParse(json); + if (!result.success) { + const pretty = z.prettifyError(result.error); + throw new Error(`Invalid registry versions format:\n${pretty}`); + } + return result.data; +} + +export class Registry { + public registryUri: string[]; + private _logger?: Logger; + private packageJsonCache: Map = new Map(); + private pendingRequests: Map> = new Map(); + + private constructor( + registryUri: string[], + public getRequest: RequestFunction, + logger?: Logger + ) { + this.registryUri = registryUri; + this._logger = logger; + } + + /** + * Create a new Registry instance with validated registry URIs. + */ + public static async create( + registryUri: string[] | undefined, + getRequest: RequestFunction, + logger?: Logger + ): Promise { + const validatedUri = await Registry.validateRegistry( + registryUri ?? DefaultRegistryUrl, + getRequest, + logger + ); + return new Registry(validatedUri, getRequest, logger); + } + + /** + * Create a new Registry instance without validating registry URIs. + */ + public static createWithoutValidation( + registryUri: string[] | undefined, + getRequest: RequestFunction, + logger?: Logger + ): Registry { + return new Registry(registryUri ?? DefaultRegistryUrl, getRequest, logger); + } + + /** + * Validate registry URIs by checking if they are available. + * Returns only valid registry URIs. + */ + private static async validateRegistry( + registryUri: string[], + getRequest: RequestFunction, + logger?: Logger + ): Promise { + const validRegistryUri: string[] = []; + for (const uri of registryUri) { + try { + await getRequest(uri, ""); + validRegistryUri.push(uri); + } catch (error) { + logger?.error(`Registry ${uri} is not available: ${error}`); + } + } + return validRegistryUri; + } + + public async listPackages(): Promise { + const items = await this.fetchRegistryItems(); + return items.filter((item) => !item.isTemplate).map((item) => item.id); + } + + public async listTemplates(projectType?: JaculusProjectType): Promise { + const items = await this.fetchRegistryItems(); + return items + .filter((item) => item.isTemplate && (!projectType || item.projectType === projectType)) + .map((item) => item.id); + } + + private async fetchRegistryItems(): Promise { + const allItems: Map = new Map(); + let firstError: unknown; + + for (const uri of this.registryUri) { + try { + const libraries = parseRegistryList( + await getRequestJson(this.getRequest, uri, "list.json") + ); + for (const item of libraries) { + if (!allItems.has(item.id)) { + allItems.set(item.id, item); + } + } + } catch (error) { + firstError ??= error; + this._logger?.error(`Failed to fetch list from registry ${uri}: ${error}`); + } + } + + if (allItems.size === 0) { + if (firstError instanceof Error) { + throw firstError; + } + throw new RegistryFetchError("Failed to fetch library list from registries"); + } + + return Array.from(allItems.values()); + } + + public async exists(library: string): Promise { + let firstError: unknown; + + for (const uri of this.registryUri) { + try { + await getRequestJson(this.getRequest, uri, `${library}/versions.json`); + return true; + } catch (error) { + if (error instanceof JaculusRequestError) { + continue; + } + firstError ??= error; + } + } + + if (firstError instanceof Error) { + throw firstError; + } + if (firstError !== undefined) { + throw new RegistryFetchError(String(firstError)); + } + + return false; + } + + public async listVersions(library: string): Promise { + const versions = await this.retrieveSingleResultFromRegistries(async (uri) => { + return getRequestJson(this.getRequest, uri, `${library}/versions.json`); + }, `Failed to fetch versions for library '${library}' from any registry`); + return parseRegistryVersions(versions) + .map((item) => item.version) + .sort(semver.rcompare); + } + + public async getPackageJson(library: string, version: string): Promise { + const path = `${library}/${version}/package.json`; + const json = await this.retrieveSingleResultFromRegistries(async (uri) => { + return getRequestJson(this.getRequest, uri, path); + }, `Failed to fetch package.json for library '${library}' version '${version}' from any registry`); + return parsePackageJson(json, path); + } + + public async getPackageTgz(library: string, version: string): Promise { + return this.retrieveSingleResultFromRegistries(async (uri) => { + return this.getRequest(uri, `${library}/${version}/package.tar.gz`); + }, `Failed to fetch package.tar.gz for library '${library}' version '${version}' from any registry`); + } + + private async retrieveSingleResultFromRegistries( + action: (uri: string) => Promise, + errorMessage: string + ): Promise { + let firstError: unknown; + + for (const uri of this.registryUri) { + try { + const result = await action(uri); + return result; + } catch (error) { + firstError ??= error; + // try next registry + } + } + + if (firstError instanceof Error) { + throw firstError; + } + if (firstError !== undefined) { + throw new RegistryFetchError(String(firstError)); + } + + throw new RegistryFetchError(errorMessage); + } +} diff --git a/packages/project/tsconfig.json b/packages/project/tsconfig.json index dea97e0..9308504 100644 --- a/packages/project/tsconfig.json +++ b/packages/project/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "dist", "rootDir": "." }, - "include": ["src", "test"], + "include": ["src", "test", "src/registry.ts"], "exclude": ["test/data/test-project"] } diff --git a/packages/tools/package.json b/packages/tools/package.json index 12a3254..261638b 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -33,7 +33,7 @@ "@jaculus/firmware": "workspace:*", "@jaculus/link": "workspace:*", "@jaculus/project": "workspace:*", - "@obsidize/tar-browserify": "^6.1.0", + "@obsidize/tar-browserify": "^6.3.2", "chalk": "^5.4.1", "get-uri": "^6.0.4", "pako": "^2.1.0", diff --git a/packages/tools/src/commands/build.ts b/packages/tools/src/commands/build.ts index ce42333..204d122 100644 --- a/packages/tools/src/commands/build.ts +++ b/packages/tools/src/commands/build.ts @@ -1,8 +1,7 @@ import { Command, Opt } from "./lib/command.js"; import * as path from "path"; -import { stderr } from "process"; -import { compile } from "@jaculus/project/compiler"; -import { logger } from "../logger.js"; +import { stderr, stdout } from "process"; +import { compileProject } from "@jaculus/project/compiler"; import * as fs from "fs"; const cmd = new Command("Build TypeScript project", { @@ -10,7 +9,7 @@ const cmd = new Command("Build TypeScript project", { const path_ = options["input"] as string; const inputDir = path.resolve(path_); - if (await compile(fs, inputDir, "build", stderr, logger)) { + if (await compileProject(fs, inputDir, stderr, stdout)) { stderr.write("Compiled successfully\n"); } else { stderr.write("Compilation failed\n"); diff --git a/packages/tools/src/commands/flash.ts b/packages/tools/src/commands/flash.ts index 608e0cf..a31f908 100644 --- a/packages/tools/src/commands/flash.ts +++ b/packages/tools/src/commands/flash.ts @@ -1,8 +1,13 @@ import { Command, Env, Opt } from "./lib/command.js"; -import { stderr } from "process"; +import { stderr, stdout } from "process"; import { getDevice } from "./util.js"; import { logger } from "../logger.js"; -import { upload, uploadIfDifferent } from "../uploaderUtil.js"; +import fs from "fs"; +import { uriRequest } from "../util.js"; +import path, { dirname } from "path"; +import { loadPackageJson } from "@jaculus/project/package"; +import { Project } from "@jaculus/project"; +import { Registry } from "@jaculus/project/registry"; const cmd = new Command("Flash code to device (replace contents of ./code)", { action: async ( @@ -13,10 +18,16 @@ const cmd = new Command("Flash code to device (replace contents of ./code)", { const port = options["port"] as string; const baudrate = options["baudrate"] as string; const socket = options["socket"] as string; - const from = options["from"] as string; + const projectPath = options["path"] as string; const device = await getDevice(port, baudrate, socket, env); + const pkg = await loadPackageJson(fs, path.join(projectPath, "package.json")); + const registry = await Registry.create(pkg?.registry, uriRequest); + const project = new Project(fs, projectPath, stdout, stderr, registry); + + const files = await project.getFlashFiles(); + await device.controller.lock().catch((err: unknown) => { stderr.write("Error locking device: " + err + "\n"); throw 1; @@ -29,26 +40,35 @@ const cmd = new Command("Flash code to device (replace contents of ./code)", { try { logger.info("Getting current data hashes"); const dataHashes = await device.uploader.getDirHashes("code").catch((err: unknown) => { - stderr.write("Error getting data hashes: " + err + "\n"); + logger.verbose("Error getting data hashes: " + err); throw err; }); - await uploadIfDifferent(device.uploader, dataHashes, from, "code"); + await device.uploader.uploadIfDifferent(dataHashes, files, "code"); } catch { logger.info("Deleting old code"); await device.uploader.deleteDirectory("code").catch((err: unknown) => { logger.verbose("Error deleting directory: " + err); }); - const cmd = await upload(device.uploader, from, "code").catch((err: unknown) => { - stderr.write("Error uploading: " + err + "\n"); - throw 1; - }); - stderr.write(cmd.toString() + "\n"); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = `code/${filePath}`; + const dirPath = dirname(fullPath); + if (dirPath) { + await device.uploader.createDirectory(dirPath).catch((err: unknown) => { + logger.verbose("Error creating directory: " + err); + throw err; + }); + } + await device.uploader.writeFile(fullPath, content).catch((err: unknown) => { + logger.verbose("Error writing file: " + err); + throw err; + }); + } } await device.controller.start("index.js").catch((err: unknown) => { - stderr.write("Error starting program: " + err + "\n"); + logger.verbose("Error starting program: " + err); throw 1; }); @@ -58,7 +78,7 @@ const cmd = new Command("Flash code to device (replace contents of ./code)", { }); }, options: { - from: new Opt("Directory to flash", { required: true, defaultValue: "build" }), + path: new Opt("Project path", { required: true, defaultValue: "." }), }, chainable: true, }); diff --git a/packages/tools/src/commands/index.ts b/packages/tools/src/commands/index.ts index 03f42f4..a108cdb 100644 --- a/packages/tools/src/commands/index.ts +++ b/packages/tools/src/commands/index.ts @@ -5,6 +5,11 @@ import serialSocket from "./serial-socket.js"; import install from "./install.js"; import build from "./build.js"; import flash from "./flash.js"; +import libBuild from "./lib-build.js"; +import libInstall from "./lib-install.js"; +import libLs from "./lib-ls.js"; +import libRemove from "./lib-remove.js"; +import libSearch from "./lib-search.js"; import ls from "./ls.js"; import read from "./read.js"; import write from "./write.js"; @@ -32,6 +37,13 @@ export function registerJaculusCommands(jac: Program) { jac.addCommand("build", build); jac.addCommand("flash", flash); + + jac.addCommand("lib-build", libBuild); + jac.addCommand("lib-install", libInstall); + jac.addCommand("lib-ls", libLs); + jac.addCommand("lib-remove", libRemove); + jac.addCommand("lib-search", libSearch); + jac.addCommand("pull", pull); jac.addCommand("ls", ls); jac.addCommand("read", read); diff --git a/packages/tools/src/commands/install.ts b/packages/tools/src/commands/install.ts index 428fb31..b87efdf 100644 --- a/packages/tools/src/commands/install.ts +++ b/packages/tools/src/commands/install.ts @@ -18,9 +18,9 @@ const cmd = new Command("Install Jaculus to device", { const pkg = await loadPackage(pkgUri); - stdout.write("Version: " + pkg.getManifest().getVersion() + "\n"); - stdout.write("Board: " + pkg.getManifest().getBoard() + "\n"); - stdout.write("Platform: " + pkg.getManifest().getPlatform() + "\n"); + stdout.write("Version: " + pkg.getManifest().version + "\n"); + stdout.write("Board: " + pkg.getManifest().board + "\n"); + stdout.write("Platform: " + pkg.getManifest().platform + "\n"); stdout.write("\n"); if (info) { diff --git a/packages/tools/src/commands/lib-build.ts b/packages/tools/src/commands/lib-build.ts new file mode 100644 index 0000000..becd92d --- /dev/null +++ b/packages/tools/src/commands/lib-build.ts @@ -0,0 +1,31 @@ +import { stdout } from "process"; +import { Command, Opt } from "./lib/command.js"; +import fs from "fs"; +import path from "path"; +import { stderr } from "process"; +import { compileLibrary } from "@jaculus/project/compiler"; + +const cmd = new Command("List libraries from project package.json", { + action: async (options: Record) => { + const path_ = options["input"] as string; + const inputDir = path.resolve(path_); + const transpileOnly = options["transpileOnly"] as boolean; + + if (await compileLibrary(fs, inputDir, stderr, stdout, transpileOnly)) { + stderr.write("Compiled successfully\n"); + } else { + stderr.write("Compilation failed\n"); + throw 1; + } + }, + options: { + input: new Opt("The input directory", { required: true, defaultValue: "./" }), + transpileOnly: new Opt( + "Transpile only, skip type validation (still emits JS/d.ts on type errors)", + { isFlag: true } + ), + }, + chainable: true, +}); + +export default cmd; diff --git a/packages/tools/src/commands/lib-install.ts b/packages/tools/src/commands/lib-install.ts new file mode 100644 index 0000000..8f4b747 --- /dev/null +++ b/packages/tools/src/commands/lib-install.ts @@ -0,0 +1,41 @@ +import { stderr, stdout } from "process"; +import { Arg, Command, Opt } from "./lib/command.js"; +import fs from "fs"; +import { uriRequest } from "../util.js"; +import path from "path"; +import { loadPackageJson, splitLibraryNameVersion } from "@jaculus/project/package"; +import { Project } from "@jaculus/project"; +import { Registry } from "@jaculus/project/registry"; + +const cmd = new Command("Install Jaculus libraries base on project's package.json", { + action: async (options: Record, args: Record) => { + const libraryName = args["library"] as string; + const projectPath = options["path"] as string; + + const pkg = await loadPackageJson(fs, path.join(projectPath, "package.json")); + const registry = await Registry.create(pkg?.registry, uriRequest); + const project = new Project(fs, projectPath, stdout, stderr, registry); + + const { name, version } = splitLibraryNameVersion(libraryName); + if (name && version) { + await project.addLibraryVersion(name, version); + } else if (name) { + await project.addLibrary(name); + } else { + await project.install(); + } + }, + args: [ + new Arg( + "library", + "Library to add to the project (name@version) like led@1.0.0, if no version is specified, the latest version will be used", + { defaultValue: "" } + ), + ], + options: { + path: new Opt("Project directory path", { defaultValue: "./" }), + }, + chainable: true, +}); + +export default cmd; diff --git a/packages/tools/src/commands/lib-ls.ts b/packages/tools/src/commands/lib-ls.ts new file mode 100644 index 0000000..249baa6 --- /dev/null +++ b/packages/tools/src/commands/lib-ls.ts @@ -0,0 +1,39 @@ +import { stdout } from "process"; +import { Command, Opt } from "./lib/command.js"; +import fs from "fs"; +import path from "path"; +import { loadPackageJson } from "@jaculus/project/package"; +import { Project } from "@jaculus/project"; +import { Registry } from "@jaculus/project/registry"; +import { uriRequest } from "../util.js"; + +const cmd = new Command("List libraries from project package.json", { + action: async (options: Record) => { + const projectPath = options["path"] as string; + const includeResolvedDependencies = options["resolved"] as boolean; + + const pkg = await loadPackageJson(fs, path.join(projectPath, "package.json")); + const registry = includeResolvedDependencies + ? await Registry.create(pkg?.registry, uriRequest) + : undefined; + const project = new Project(fs, projectPath, stdout, stdout, registry); + + const dependencies = await project.installedLibraries(includeResolvedDependencies); + const list = Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b)); + if (list.length === 0) { + stdout.write("No libraries found.\n"); + return; + } + + for (const [name, version] of list) { + stdout.write(`${name}@${version}\n`); + } + }, + options: { + path: new Opt("Project directory path", { defaultValue: "./" }), + resolved: new Opt("Include resolved transitive dependencies", { isFlag: true }), + }, + chainable: true, +}); + +export default cmd; diff --git a/packages/tools/src/commands/lib-remove.ts b/packages/tools/src/commands/lib-remove.ts new file mode 100644 index 0000000..d244233 --- /dev/null +++ b/packages/tools/src/commands/lib-remove.ts @@ -0,0 +1,27 @@ +import { stderr, stdout } from "process"; +import { Arg, Command, Opt } from "./lib/command.js"; +import fs from "fs"; +import { uriRequest } from "../util.js"; +import path from "path"; +import { loadPackageJson } from "@jaculus/project/package"; +import { Project } from "@jaculus/project"; +import { Registry } from "@jaculus/project/registry"; + +const cmd = new Command("Remove a library from the project package.json", { + action: async (options: Record, args: Record) => { + const libraryName = args["library"] as string; + const projectPath = options["path"] as string; + + const pkg = await loadPackageJson(fs, path.join(projectPath, "package.json")); + const registry = await Registry.create(pkg?.registry, uriRequest); + const project = new Project(fs, projectPath, stdout, stderr, registry); + await project.removeLibrary(libraryName); + }, + args: [new Arg("library", "Library name to remove from the project", { required: true })], + options: { + path: new Opt("Project directory path", { defaultValue: "./" }), + }, + chainable: true, +}); + +export default cmd; diff --git a/packages/tools/src/commands/lib-search.ts b/packages/tools/src/commands/lib-search.ts new file mode 100644 index 0000000..939cf21 --- /dev/null +++ b/packages/tools/src/commands/lib-search.ts @@ -0,0 +1,53 @@ +import { stdout } from "process"; +import { Arg, Command, Opt } from "./lib/command.js"; +import fs from "fs"; +import path from "path"; +import { loadPackageJson } from "@jaculus/project/package"; +import { Registry } from "@jaculus/project/registry"; +import { uriRequest } from "../util.js"; + +const cmd = new Command("Search libraries in configured registries", { + action: async (options: Record, args: Record) => { + const projectPath = options["path"] as string; + const query = ((args["query"] as string | undefined) ?? "").trim(); + const allLibs = options["all-libs"] as boolean; + + const pkg = await loadPackageJson(fs, path.join(projectPath, "package.json")); + const registry = await Registry.create(pkg?.registry, uriRequest); + + const libraries = await registry.listPackages(); + const matches = allLibs + ? [...libraries].sort((a, b) => a.localeCompare(b)) + : libraries + .filter((library) => library.toLowerCase().includes(query.toLowerCase())) + .sort((a, b) => a.localeCompare(b)); + + if (!allLibs && query.length === 0) { + throw new Error('Query is required unless "--all-libs" is used'); + } + + if (matches.length === 0) { + const scope = allLibs ? "in configured registries" : `for "${query}"`; + stdout.write(`No registry libraries found ${scope}.\n`); + return; + } + + stdout.write(`${allLibs ? "All libraries:" : "Matching libraries:"}\n`); + const exactMatch = allLibs ? undefined : libraries.find((library) => library === query); + for (const library of matches) { + stdout.write(`${library}\n`); + if (exactMatch) { + const versions = await registry.listVersions(exactMatch); + stdout.write(` - versions: ${versions.join(", ")}\n`); + } + } + }, + args: [new Arg("query", "Library search query")], + options: { + path: new Opt("Project directory path", { defaultValue: "./" }), + "all-libs": new Opt("List all libraries from all configured registries", { isFlag: true }), + }, + chainable: true, +}); + +export default cmd; diff --git a/packages/tools/src/commands/project.ts b/packages/tools/src/commands/project.ts index 6239670..cf1cb4b 100644 --- a/packages/tools/src/commands/project.ts +++ b/packages/tools/src/commands/project.ts @@ -1,13 +1,13 @@ import { Arg, Command, Env, Opt } from "./lib/command.js"; -import { stderr } from "process"; +import { stderr, stdout } from "process"; import { getDevice } from "./util.js"; import fs from "fs"; import { Archive } from "@obsidize/tar-browserify"; import pako from "pako"; import { getUri } from "get-uri"; -import { createProject, updateProject, ProjectPackage } from "@jaculus/project"; import { JacDevice } from "@jaculus/device"; import { logger } from "../logger.js"; +import { ProjectPackage, Project } from "@jaculus/project"; async function loadFromDevice(device: JacDevice): Promise { await device.controller.lock().catch((err) => { @@ -87,7 +87,8 @@ export const projectCreate = new Command("Create project from package", { const dryRun = options["dry-run"] as boolean; const pkg = await loadPackage(options, env); - await createProject(fs, outPath, pkg, stderr, dryRun); + const project = new Project(fs, outPath, stdout, stderr); + await project.createFromPackage(pkg, dryRun); }, options: { package: new Opt("Uri pointing to the package file"), @@ -108,7 +109,8 @@ export const projectUpdate = new Command("Update existing project from package s const dryRun = options["dry-run"] as boolean; const pkg = await loadPackage(options, env); - await updateProject(fs, outPath, pkg, stderr, dryRun); + const project = new Project(fs, outPath, stdout, stderr); + await project.updateFromPackage(pkg, dryRun); }, options: { package: new Opt("Uri pointing to the package file"), diff --git a/packages/tools/src/commands/wifi.ts b/packages/tools/src/commands/wifi.ts index 49ed0e0..c8cd2e0 100644 --- a/packages/tools/src/commands/wifi.ts +++ b/packages/tools/src/commands/wifi.ts @@ -1,34 +1,7 @@ import { Arg, Opt, Command, Env } from "./lib/command.js"; import { stdout, stderr } from "process"; import { getDevice, readPassword } from "./util.js"; - -enum WifiKvNs { - Ssids = "wifi_net", - Main = "wifi_cfg", -} - -enum WifiKeys { - Mode = "mode", - StaMode = "sta_mode", - StaSpecific = "sta_ssid", - StaApFallback = "sta_ap_fallback", - ApSsid = "ap_ssid", - ApPass = "ap_pass", - CurrentIp = "current_ip", -} - -enum WifiMode { - DISABLED, - STATION, - AP, -} - -enum StaMode { - // Connect to any known network, pick the one with better signal if multiple found - BEST_SIGNAL, - // Connect to SSID specified in sta_ssid only - SPECIFIC_SSID, -} +import { WifiMode, WifiStaMode } from "@jaculus/device"; export const wifiAdd = new Command("Add a WiFi network", { action: async ( @@ -50,7 +23,7 @@ export const wifiAdd = new Command("Add a WiFi network", { throw 1; }); - await device.controller.configSetString("wifi_net", ssid.substring(0, 15), password); + await device.controller.addWifiNetwork(ssid, password); await device.controller.unlock().catch((err) => { stderr.write("Error unlocking device: " + err + "\n"); @@ -81,7 +54,7 @@ export const wifiRemove = new Command("Remove a WiFi network", { throw 1; }); - await device.controller.configErase("wifi_net", ssid); + await device.controller.removeWifiNetwork(ssid); await device.controller.unlock().catch((err) => { stderr.write("Error unlocking device: " + err + "\n"); @@ -121,23 +94,17 @@ export const wifiGet = new Command("Display current WiFi config", { throw 1; }); - const mode = await device.controller.configGetInt(WifiKvNs.Main, WifiKeys.Mode); - const staMode = await device.controller.configGetInt(WifiKvNs.Main, WifiKeys.StaMode); - const staSpecific = await device.controller.configGetString( - WifiKvNs.Main, - WifiKeys.StaSpecific - ); - const apSsid = await device.controller.configGetString(WifiKvNs.Main, WifiKeys.ApSsid); - const currentIp = await device.controller.configGetString( - WifiKvNs.Main, - WifiKeys.CurrentIp - ); + const mode = await device.controller.getWifiMode(); + const staMode = await device.controller.getWifiStaMode(); + const staSpecific = await device.controller.getWifiStaSpecific(); + const apSsid = await device.controller.getWifiApSsid(); + const currentIp = await device.controller.getCurrentWifiIp(); stdout.write(`Current IP: ${currentIp} WiFi Mode: ${WifiMode[mode]} -Station Mode: ${StaMode[staMode]} +Station Mode: ${WifiStaMode[staMode]} Station Specific SSID: ${staSpecific} AP SSID: ${apSsid} @@ -171,7 +138,7 @@ export const wifiDisable = new Command("Disable WiFi", { throw 1; }); - await device.controller.configSetInt(WifiKvNs.Main, WifiKeys.Mode, WifiMode.DISABLED); + await device.controller.setWifiMode(WifiMode.DISABLED); await device.controller.unlock().catch((err) => { stderr.write("Error unlocking device: " + err + "\n"); @@ -213,12 +180,12 @@ export const wifiSetAp = new Command("Set WiFi to AP mode (create a hotspot)", { throw 1; }); - await device.controller.configSetInt(WifiKvNs.Main, WifiKeys.Mode, WifiMode.AP); + await device.controller.setWifiMode(WifiMode.AP); if (ssid !== undefined) { - await device.controller.configSetString(WifiKvNs.Main, WifiKeys.ApSsid, ssid); + await device.controller.setWifiApSsid(ssid); } if (pass !== undefined) { - await device.controller.configSetString(WifiKvNs.Main, WifiKeys.ApPass, pass); + await device.controller.setWifiApPassword(pass); } await device.controller.unlock().catch((err) => { @@ -256,32 +223,16 @@ export const wifiSetSta = new Command("Set WiFi to Station mode (connect to a wi throw 1; }); - await device.controller.configSetInt(WifiKvNs.Main, WifiKeys.Mode, WifiMode.STATION); + await device.controller.setWifiMode(WifiMode.STATION); if (!specificSsid) { - await device.controller.configSetInt( - WifiKvNs.Main, - WifiKeys.StaMode, - StaMode.BEST_SIGNAL - ); + await device.controller.setWifiStaMode(WifiStaMode.BEST_SIGNAL); } else { - await device.controller.configSetInt( - WifiKvNs.Main, - WifiKeys.StaMode, - specificSsid ? StaMode.SPECIFIC_SSID : StaMode.BEST_SIGNAL - ); - await device.controller.configSetString( - WifiKvNs.Main, - WifiKeys.StaSpecific, - specificSsid - ); + await device.controller.setWifiStaMode(WifiStaMode.SPECIFIC_SSID); + await device.controller.setWifiStaSpecific(specificSsid); } - await device.controller.configSetInt( - WifiKvNs.Main, - WifiKeys.StaApFallback, - !noApFallback ? 1 : 0 - ); + await device.controller.setWifiStaApFallback(!noApFallback); await device.controller.unlock().catch((err) => { stderr.write("Error unlocking device: " + err + "\n"); diff --git a/packages/tools/src/uploaderUtil.ts b/packages/tools/src/uploaderUtil.ts index 4d48e9b..2a3e5b8 100644 --- a/packages/tools/src/uploaderUtil.ts +++ b/packages/tools/src/uploaderUtil.ts @@ -6,17 +6,6 @@ import { logger } from "./logger.js"; import path from "path"; import { stderr } from "process"; -enum SyncAction { - Noop, - Delete, - Upload, -} - -interface RemoteFileInfo { - sha1: string; - action: SyncAction; -} - export async function fileSha1(path: string): Promise { return new Promise((resolve, reject) => { const hasher = crypto.createHash("sha1"); @@ -146,7 +135,7 @@ export async function pull(uploader: Uploader, from: string, to: string): Promis return pullFile(uploader, from, to); } -export async function uploadIfDifferent( +export async function uploadIfDifferentFs( uploader: Uploader, remoteHashes: [string, string][], from: string, @@ -157,88 +146,21 @@ export async function uploadIfDifferent( throw 1; } - const filesInfo: Record = Object.fromEntries( - remoteHashes.map(([name, sha1]) => { - return [ - name, - { - sha1: sha1, - action: SyncAction.Delete, - }, - ]; - }) - ); - - const dirs: string[] = [from]; - while (dirs.length > 0) { - const cur_dir = dirs.pop() as string; - const rel_cur_dir = cur_dir.substring(from.length + 1); - - const entries = fs.readdirSync(cur_dir, { withFileTypes: true }); - for (const e of entries) { - if (e.isFile()) { - const key = rel_cur_dir ? `${rel_cur_dir}/${e.name}` : e.name; - const sha1 = await fileSha1(`${cur_dir}/${e.name}`); - const info = filesInfo[key]; - if (info === undefined) { - filesInfo[key] = { - sha1: sha1, - action: SyncAction.Upload, - }; - logger.verbose(`${key} is new, will upload`); - } else if (info.sha1 === sha1) { - info.action = SyncAction.Noop; - logger.verbose(`${key} has same sha1 on device and on disk, skipping`); - } else { - info.action = SyncAction.Upload; - logger.verbose(`${key} is different, will upload`); - } - } else if (e.isDirectory()) { - dirs.push(`${cur_dir}/${e.name}`); + const files: Record = {}; + function readFilesRec(dir: string, basePath: string) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.join(basePath, entry.name); + if (entry.isDirectory()) { + readFilesRec(fullPath, relativePath); + } else if (entry.isFile()) { + const data = fs.readFileSync(fullPath); + files[relativePath.replace(/\\/g, "/")] = data; } } } + readFilesRec(from, ""); - const existingFolders = new Set(); - let countUploaded = 0; - let countDeleted = 0; - - for (const [rel_path, info] of Object.entries(filesInfo)) { - const src_path = `${from}/${rel_path}`; - const dest_path = `${to}/${rel_path}`; - switch (info.action) { - case SyncAction.Noop: - break; - case SyncAction.Delete: - try { - await uploader.deleteFile(dest_path); - } catch (err) { - logger.verbose(`Error deleting file ${dest_path}: ${err}`); - } - ++countDeleted; - break; - case SyncAction.Upload: { - const parts = dest_path.split("/"); - let cur_dir_part = ""; - for (const p of parts.slice(0, parts.length - 1)) { - if (p === "") { - continue; - } - const abs_p = cur_dir_part + p; - if (!existingFolders.has(abs_p)) { - await uploader.createDirectory(abs_p).catch((err: unknown) => { - logger.error("Error creating directory: " + err); - }); - existingFolders.add(abs_p); - } - cur_dir_part += `${p}/`; - } - - await upload(uploader, src_path, dest_path); - ++countUploaded; - break; - } - } - } - logger.info(`Files synced, ${countUploaded} uploaded, ${countDeleted} deleted`); + await uploader.uploadIfDifferent(remoteHashes, files, to); } diff --git a/packages/tools/src/util.ts b/packages/tools/src/util.ts new file mode 100644 index 0000000..a5d3782 --- /dev/null +++ b/packages/tools/src/util.ts @@ -0,0 +1,40 @@ +import { JaculusRequestError, RequestFunction } from "@jaculus/project/fs"; +import { getUri } from "get-uri"; +import * as fs from "fs"; +import { fileURLToPath } from "url"; + +export const uriRequest: RequestFunction = async ( + baseUri: string, + libFile: string +): Promise => { + if (libFile === "") { + return new Uint8Array(); + } + + const uri = new URL( + libFile.replace(/^\/+/, ""), + baseUri.endsWith("/") ? baseUri : `${baseUri}/` + ).toString(); + + if (uri.startsWith("file:")) { + const filePath = fileURLToPath(uri); + try { + return new Uint8Array(fs.readFileSync(filePath)); + } catch (error) { + throw new JaculusRequestError( + `Failed to read ${filePath}: ${(error as Error).message}` + ); + } + } + + try { + const stream = await getUri(uri); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + return new Uint8Array(Buffer.concat(chunks)); + } catch (error) { + throw new JaculusRequestError(`Failed to fetch ${uri}: ${(error as Error).message}`); + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5efe82a..6300fc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@jaculus/project': specifier: workspace:* version: link:packages/project + '@obsidize/tar-browserify': + specifier: ^6.3.2 + version: 6.3.2 '@types/chai': specifier: ^4.3.20 version: 4.3.20 @@ -25,7 +28,10 @@ importers: version: 10.0.10 '@types/node': specifier: ^24.0.7 - version: 24.3.1 + version: 24.9.2 + '@types/pako': + specifier: ^2.0.4 + version: 2.0.4 '@zenfs/core': specifier: ^1.11.4 version: 1.11.4 @@ -37,19 +43,22 @@ importers: version: 0.1.2(chai@5.3.3) eslint: specifier: ^9.35.0 - version: 9.35.0(jiti@2.5.1) + version: 9.38.0(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.35.0(jiti@2.5.1)) + version: 10.1.8(eslint@9.38.0(jiti@2.6.1)) husky: specifier: ^9.1.7 version: 9.1.7 jiti: specifier: ^2.5.1 - version: 2.5.1 + version: 2.6.1 mocha: specifier: ^11.7.2 - version: 11.7.2 + version: 11.7.4 + pako: + specifier: ^2.1.0 + version: 2.1.0 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -61,10 +70,10 @@ importers: version: 4.20.6 typescript: specifier: ^5.9.2 - version: 5.9.2 + version: 5.9.3 typescript-eslint: specifier: ^8.43.0 - version: 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) packages/common: devDependencies: @@ -73,7 +82,7 @@ importers: version: 6.0.1 typescript: specifier: ^5.8.3 - version: 5.9.2 + version: 5.9.3 packages/device: dependencies: @@ -89,7 +98,7 @@ importers: version: 6.0.1 typescript: specifier: ^5.8.3 - version: 5.9.2 + version: 5.9.3 packages/firmware: dependencies: @@ -97,8 +106,8 @@ importers: specifier: ^0.3.2 version: 0.3.2 '@obsidize/tar-browserify': - specifier: ^6.1.0 - version: 6.1.0 + specifier: ^6.3.2 + version: 6.3.2 cli-progress: specifier: ^3.12.0 version: 3.12.0 @@ -111,13 +120,16 @@ importers: serialport: specifier: ^13.0.0 version: 13.0.0 + zod: + specifier: ^4.1.12 + version: 4.1.12 devDependencies: '@types/cli-progress': specifier: ^3.11.6 version: 3.11.6 '@types/node': specifier: ^24.0.7 - version: 24.3.1 + version: 24.9.2 '@types/pako': specifier: ^2.0.4 version: 2.0.4 @@ -126,7 +138,7 @@ importers: version: 6.0.1 typescript: specifier: ^5.8.3 - version: 5.9.2 + version: 5.9.3 packages/link: dependencies: @@ -142,20 +154,35 @@ importers: version: 6.0.1 typescript: specifier: ^5.8.3 - version: 5.9.2 + version: 5.9.3 packages/project: dependencies: '@jaculus/common': specifier: workspace:* version: link:../common + pako: + specifier: ^2.1.0 + version: 2.1.0 + semver: + specifier: ^7.7.3 + version: 7.7.3 typescript: specifier: ^5.8.3 - version: 5.9.2 + version: 5.9.3 + zod: + specifier: ^4.1.12 + version: 4.1.12 devDependencies: '@types/node': specifier: ^20.0.0 - version: 20.19.23 + version: 20.19.24 + '@types/pako': + specifier: ^2.0.4 + version: 2.0.4 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -178,8 +205,8 @@ importers: specifier: workspace:* version: link:../project '@obsidize/tar-browserify': - specifier: ^6.1.0 - version: 6.1.0 + specifier: ^6.3.2 + version: 6.3.2 chalk: specifier: ^5.4.1 version: 5.6.2 @@ -194,11 +221,11 @@ importers: version: 13.0.0 winston: specifier: ^3.17.0 - version: 3.17.0 + version: 3.18.3 devDependencies: '@types/node': specifier: ^24.0.7 - version: 24.3.1 + version: 24.9.2 '@types/pako': specifier: ^2.0.4 version: 2.0.4 @@ -207,7 +234,7 @@ importers: version: 6.0.1 typescript: specifier: ^5.8.3 - version: 5.9.2 + version: 5.9.3 packages: @@ -218,8 +245,8 @@ packages: '@cubicap/esptool-js@0.3.2': resolution: {integrity: sha512-ffVbukmg9MQP/Qku8Wxn224GhN8dNryZ4nR8CSXsfKPxeqcIvvY7wT5omy4YxsrC0Oki6/7aXbQJAQMW1whUnQ==} - '@dabh/diagnostics@2.0.3': - resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} @@ -383,40 +410,40 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.3.1': - resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.15.2': - resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + '@eslint/core@0.16.0': + resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.35.0': - resolution: {integrity: sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==} + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.38.0': resolution: {integrity: sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@humanfs/core@0.19.1': @@ -459,8 +486,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@obsidize/tar-browserify@6.1.0': - resolution: {integrity: sha512-doqiQPTJzhLiBdGENEjow8inpt5hfCD/MuxgfmZBuBqmCCOSgCZ7q1jIpzsUOQ618K/j/ZPYFQw+mltQwz/jCw==} + '@obsidize/tar-browserify@6.3.2': + resolution: {integrity: sha512-HN3ZSiXdJUNCbPqxaiA1l9Gxh0/fpAEvRK3qKxj5F1IkDo0DyuNltX0QV/IRtET/rQ+ikgaGre90anyR0ZGRGA==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -530,6 +557,9 @@ packages: resolution: {integrity: sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==} engines: {node: '>=20.0.0'} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} @@ -545,78 +575,81 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@20.19.23': - resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} + '@types/node@20.19.24': + resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} - '@types/node@22.18.10': - resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==} + '@types/node@22.18.13': + resolution: {integrity: sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==} - '@types/node@24.3.1': - resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + '@types/node@24.9.2': + resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - '@typescript-eslint/eslint-plugin@8.43.0': - resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==} + '@typescript-eslint/eslint-plugin@8.46.2': + resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.43.0 + '@typescript-eslint/parser': ^8.46.2 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.43.0': - resolution: {integrity: sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==} + '@typescript-eslint/parser@8.46.2': + resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.43.0': - resolution: {integrity: sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==} + '@typescript-eslint/project-service@8.46.2': + resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.43.0': - resolution: {integrity: sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==} + '@typescript-eslint/scope-manager@8.46.2': + resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.43.0': - resolution: {integrity: sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==} + '@typescript-eslint/tsconfig-utils@8.46.2': + resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.43.0': - resolution: {integrity: sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==} + '@typescript-eslint/type-utils@8.46.2': + resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.43.0': - resolution: {integrity: sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==} + '@typescript-eslint/types@8.46.2': + resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.43.0': - resolution: {integrity: sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==} + '@typescript-eslint/typescript-estree@8.46.2': + resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.43.0': - resolution: {integrity: sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==} + '@typescript-eslint/utils@8.46.2': + resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.43.0': - resolution: {integrity: sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==} + '@typescript-eslint/visitor-keys@8.46.2': + resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xterm/xterm@5.5.0': @@ -738,27 +771,28 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-convert@3.1.2: + resolution: {integrity: sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==} + engines: {node: '>=14.6'} color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-name@2.0.2: + resolution: {integrity: sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==} + engines: {node: '>=12.20'} - color@3.2.1: - resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + color-string@2.1.2: + resolution: {integrity: sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==} + engines: {node: '>=18'} - colorspace@1.1.4: - resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + color@5.0.2: + resolution: {integrity: sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==} + engines: {node: '>=18'} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -792,8 +826,8 @@ packages: supports-color: optional: true - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -859,8 +893,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.35.0: - resolution: {integrity: sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==} + eslint@9.38.0: + resolution: {integrity: sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -958,8 +992,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-tsconfig@4.12.0: - resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} get-uri@6.0.5: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} @@ -975,11 +1009,13 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: @@ -1024,9 +1060,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-arrayish@0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1043,6 +1076,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + is-plain-obj@2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} @@ -1065,8 +1102,8 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} - jiti@2.5.1: - resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true js-yaml@4.1.0: @@ -1117,8 +1154,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.1: - resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} merge2@1.4.1: @@ -1129,8 +1166,8 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - minimatch@10.0.3: - resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} minimatch@3.1.2: @@ -1144,8 +1181,8 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - mocha@11.7.2: - resolution: {integrity: sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==} + mocha@11.7.4: + resolution: {integrity: sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true @@ -1283,8 +1320,8 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} hasBin: true @@ -1307,9 +1344,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-swizzle@0.2.2: - resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} - stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -1373,23 +1407,23 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.43.0: - resolution: {integrity: sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==} + typescript-eslint@8.46.2: + resolution: {integrity: sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1409,8 +1443,8 @@ packages: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} - winston@3.17.0: - resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==} + winston@3.18.3: + resolution: {integrity: sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==} engines: {node: '>= 12.0.0'} word-wrap@1.2.5: @@ -1448,6 +1482,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + snapshots: '@colors/colors@1.6.0': {} @@ -1457,9 +1494,9 @@ snapshots: pako: 2.1.0 tslib: 2.8.1 - '@dabh/diagnostics@2.0.3': + '@dabh/diagnostics@2.0.8': dependencies: - colorspace: 1.1.4 + '@so-ric/colorspace': 1.1.6 enabled: 2.0.0 kuler: 2.0.0 @@ -1541,31 +1578,37 @@ snapshots: '@esbuild/win32-x64@0.25.11': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.9.0(eslint@9.38.0(jiti@2.6.1))': dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.38.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.1': {} + '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.0': + '@eslint/config-array@0.21.1': dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.1(supports-color@8.1.1) + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.3.1': {} + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.16.0': + dependencies: + '@types/json-schema': 7.0.15 - '@eslint/core@0.15.2': + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -1576,15 +1619,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.35.0': {} - '@eslint/js@9.38.0': {} - '@eslint/object-schema@2.1.6': {} + '@eslint/object-schema@2.1.7': {} - '@eslint/plugin-kit@0.3.5': + '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 0.15.2 + '@eslint/core': 0.17.0 levn: 0.4.1 '@humanfs/core@0.19.1': {} @@ -1625,7 +1666,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@obsidize/tar-browserify@6.1.0': {} + '@obsidize/tar-browserify@6.3.2': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -1633,7 +1674,7 @@ snapshots: '@serialport/binding-mock@10.2.2': dependencies: '@serialport/bindings-interface': 1.2.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -1684,11 +1725,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.2 + text-hex: 1.0.0 + '@types/chai@4.3.20': {} '@types/cli-progress@3.11.6': dependencies: - '@types/node': 24.3.1 + '@types/node': 24.9.2 '@types/estree@1.0.8': {} @@ -1696,113 +1742,115 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/node@20.19.23': + '@types/node@20.19.24': dependencies: undici-types: 6.21.0 - '@types/node@22.18.10': + '@types/node@22.18.13': dependencies: undici-types: 6.21.0 - '@types/node@24.3.1': + '@types/node@24.9.2': dependencies: - undici-types: 7.10.0 + undici-types: 7.16.0 '@types/pako@2.0.4': {} + '@types/semver@7.7.1': {} + '@types/triple-beam@1.3.5': {} - '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.43.0 - eslint: 9.35.0(jiti@2.5.1) + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 + eslint: 9.38.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.1(supports-color@8.1.1) - eslint: 9.35.0(jiti@2.5.1) - typescript: 5.9.2 + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.43.0(typescript@5.9.2)': + '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) - '@typescript-eslint/types': 8.43.0 - debug: 4.4.1(supports-color@8.1.1) - typescript: 5.9.2 + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.43.0': + '@typescript-eslint/scope-manager@8.46.2': dependencies: - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/visitor-keys': 8.43.0 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 - '@typescript-eslint/tsconfig-utils@8.43.0(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': dependencies: - typescript: 5.9.2 + typescript: 5.9.3 - '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - debug: 4.4.1(supports-color@8.1.1) - eslint: 9.35.0(jiti@2.5.1) - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.38.0(jiti@2.6.1) + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.43.0': {} + '@typescript-eslint/types@8.46.2': {} - '@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.43.0(typescript@5.9.2) - '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.1(supports-color@8.1.1) + '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 + debug: 4.4.3(supports-color@8.1.1) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + semver: 7.7.3 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) - typescript: 5.9.2 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.43.0': + '@typescript-eslint/visitor-keys@8.46.2': dependencies: - '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 '@xterm/xterm@5.5.0': @@ -1810,7 +1858,7 @@ snapshots: '@zenfs/core@1.11.4': dependencies: - '@types/node': 22.18.10 + '@types/node': 22.18.13 buffer: 6.0.3 eventemitter3: 5.0.1 readable-stream: 4.7.0 @@ -1914,32 +1962,26 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - color-convert@2.0.1: dependencies: color-name: 1.1.4 - color-name@1.1.3: {} + color-convert@3.1.2: + dependencies: + color-name: 2.0.2 color-name@1.1.4: {} - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.2 + color-name@2.0.2: {} - color@3.2.1: + color-string@2.1.2: dependencies: - color-convert: 1.9.3 - color-string: 1.9.1 + color-name: 2.0.2 - colorspace@1.1.4: + color@5.0.2: dependencies: - color: 3.2.1 - text-hex: 1.0.0 + color-convert: 3.1.2 + color-string: 2.1.2 concat-map@0.0.1: {} @@ -1963,7 +2005,7 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.1(supports-color@8.1.1): + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 optionalDependencies: @@ -2018,9 +2060,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.5.1)): + eslint-config-prettier@10.1.8(eslint@9.38.0(jiti@2.6.1)): dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.38.0(jiti@2.6.1) eslint-scope@8.4.0: dependencies: @@ -2031,25 +2073,24 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.35.0(jiti@2.5.1): + eslint@9.38.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.3.1 - '@eslint/core': 0.15.2 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.16.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.35.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint/js': 9.38.0 + '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -2069,7 +2110,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.5.1 + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -2151,7 +2192,7 @@ snapshots: get-caller-file@2.0.5: {} - get-tsconfig@4.12.0: + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -2159,7 +2200,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -2184,7 +2225,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.0.3 + minimatch: 10.1.1 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.0 @@ -2214,8 +2255,6 @@ snapshots: inherits@2.0.4: {} - is-arrayish@0.3.2: {} - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2226,6 +2265,8 @@ snapshots: is-number@7.0.0: {} + is-path-inside@3.0.3: {} + is-plain-obj@2.1.0: {} is-stream@2.0.1: {} @@ -2244,7 +2285,7 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 - jiti@2.5.1: {} + jiti@2.6.1: {} js-yaml@4.1.0: dependencies: @@ -2293,7 +2334,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.1: {} + lru-cache@11.2.2: {} merge2@1.4.1: {} @@ -2302,7 +2343,7 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - minimatch@10.0.3: + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -2316,16 +2357,17 @@ snapshots: minipass@7.1.2: {} - mocha@11.7.2: + mocha@11.7.4: dependencies: browser-stdout: 1.3.1 chokidar: 4.0.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) diff: 7.0.0 escape-string-regexp: 4.0.0 find-up: 5.0.0 glob: 10.4.5 he: 1.2.0 + is-path-inside: 3.0.3 js-yaml: 4.1.0 log-symbols: 4.1.0 minimatch: 9.0.5 @@ -2387,7 +2429,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.2.1 + lru-cache: 11.2.2 minipass: 7.1.2 pathval@2.0.1: {} @@ -2451,7 +2493,7 @@ snapshots: safe-stable-stringify@2.5.0: {} - semver@7.7.2: {} + semver@7.7.3: {} serialize-javascript@6.0.2: dependencies: @@ -2484,10 +2526,6 @@ snapshots: signal-exit@4.1.0: {} - simple-swizzle@0.2.2: - dependencies: - is-arrayish: 0.3.2 - stack-trace@0.0.10: {} string-width@4.2.3: @@ -2532,16 +2570,16 @@ snapshots: triple-beam@1.4.1: {} - ts-api-utils@2.1.0(typescript@5.9.2): + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: - typescript: 5.9.2 + typescript: 5.9.3 tslib@2.8.1: {} tsx@4.20.6: dependencies: esbuild: 0.25.11 - get-tsconfig: 4.12.0 + get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 @@ -2549,22 +2587,22 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2): + typescript-eslint@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) - typescript: 5.9.2 + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - typescript@5.9.2: {} + typescript@5.9.3: {} undici-types@6.21.0: {} - undici-types@7.10.0: {} + undici-types@7.16.0: {} uri-js@4.4.1: dependencies: @@ -2588,10 +2626,10 @@ snapshots: readable-stream: 3.6.2 triple-beam: 1.4.1 - winston@3.17.0: + winston@3.18.3: dependencies: '@colors/colors': 1.6.0 - '@dabh/diagnostics': 2.0.3 + '@dabh/diagnostics': 2.0.8 async: 3.2.6 is-stream: 2.0.1 logform: 2.7.0 @@ -2640,3 +2678,5 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zod@4.1.12: {} diff --git a/test/project/compiler.test.ts b/test/project/compiler.test.ts index 603102c..9160d02 100644 --- a/test/project/compiler.test.ts +++ b/test/project/compiler.test.ts @@ -1,11 +1,11 @@ -import { copyFolder } from "@jaculus/project/fs"; import * as chai from "chai"; import * as path from "path"; -import { compile } from "@jaculus/project/compiler"; +import { compileProject } from "@jaculus/project/compiler"; import * as fsReal from "fs"; import { tmpdir } from "os"; import { configure, umount, InMemory, fs as fsVirt } from "@zenfs/core"; import { fileURLToPath } from "url"; +import { copyFolder } from "@jaculus/project/fs"; const expect = chai.expect; const testProjectPath = path.resolve("./test/project/data/test-project"); @@ -71,8 +71,6 @@ describe("TypeScript Compiler", () => { afterEach(async () => { await config.cleanup(); - - // Clean up any temporary directories created for Node.js tests tempDirs.forEach((dir) => { if (fsReal.existsSync(dir)) { fsReal.rmSync(dir, { recursive: true, force: true }); @@ -89,10 +87,9 @@ describe("TypeScript Compiler", () => { }, }; - const result = await compile( + const result = await compileProject( config.fs, testData.inputPath, - "build", errorStream, undefined, testData.tsLibsPath @@ -123,7 +120,6 @@ describe("TypeScript Compiler", () => { }); it("should handle compilation errors gracefully", async () => { - // Create a temporary directory for this test let testDir: string; if (config.name === "Node.js FS") { testDir = fsReal.mkdtempSync(path.join(tmpdir(), "jaculus-error-test-")); @@ -177,10 +173,9 @@ describe("TypeScript Compiler", () => { }, }; - const result = await compile( + const result = await compileProject( config.fs, testDir, - "build", errorStream, undefined, testData.tsLibsPath @@ -210,10 +205,9 @@ describe("TypeScript Compiler", () => { }; try { - await compile( + await compileProject( config.fs, testDir, - "build", errorStream, undefined, testData.tsLibsPath diff --git a/test/project/data/.gitignore b/test/project/data/.gitignore new file mode 100644 index 0000000..c4cea76 --- /dev/null +++ b/test/project/data/.gitignore @@ -0,0 +1 @@ +*tar.gz diff --git a/test/project/data/test-project/package.json b/test/project/data/test-project/package.json new file mode 100644 index 0000000..f3ecc59 --- /dev/null +++ b/test/project/data/test-project/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "core": "0.0.24" + } +} diff --git a/test/project/data/test-registry/color/0.0.1/package.json b/test/project/data/test-registry/color/0.0.1/package.json new file mode 120000 index 0000000..8a5b9b5 --- /dev/null +++ b/test/project/data/test-registry/color/0.0.1/package.json @@ -0,0 +1 @@ +package/package.json \ No newline at end of file diff --git a/test/project/data/test-registry/color/0.0.1/package/package.json b/test/project/data/test-registry/color/0.0.1/package/package.json new file mode 100755 index 0000000..8e30452 --- /dev/null +++ b/test/project/data/test-registry/color/0.0.1/package/package.json @@ -0,0 +1,13 @@ +{ + "name": "color", + "version": "0.0.1", + "author": "kubaandrysek", + "license": "MIT", + "description": "Color package", + "type": "module", + "main": "", + "types": "dist/types/index.d.ts", + "jaculus": { + "blocks": "blocks" + } +} diff --git a/test/project/data/test-registry/color/0.0.2/package.json b/test/project/data/test-registry/color/0.0.2/package.json new file mode 120000 index 0000000..8a5b9b5 --- /dev/null +++ b/test/project/data/test-registry/color/0.0.2/package.json @@ -0,0 +1 @@ +package/package.json \ No newline at end of file diff --git a/test/project/data/test-registry/color/0.0.2/package/package.json b/test/project/data/test-registry/color/0.0.2/package/package.json new file mode 100644 index 0000000..55a657e --- /dev/null +++ b/test/project/data/test-registry/color/0.0.2/package/package.json @@ -0,0 +1,10 @@ +{ + "name": "color", + "version": "0.0.2", + "author": "kubaandrysek", + "license": "MIT", + "description": "Color package", + "type": "module", + "main": "", + "types": "dist/types/index.d.ts" +} diff --git a/test/project/data/test-registry/color/versions.json b/test/project/data/test-registry/color/versions.json new file mode 100644 index 0000000..9d42856 --- /dev/null +++ b/test/project/data/test-registry/color/versions.json @@ -0,0 +1,8 @@ +[ + { + "version": "0.0.1" + }, + { + "version": "0.0.2" + } +] diff --git a/test/project/data/test-registry/core/0.0.24/package.json b/test/project/data/test-registry/core/0.0.24/package.json new file mode 120000 index 0000000..8a5b9b5 --- /dev/null +++ b/test/project/data/test-registry/core/0.0.24/package.json @@ -0,0 +1 @@ +package/package.json \ No newline at end of file diff --git a/test/project/data/test-registry/core/0.0.24/package/package.json b/test/project/data/test-registry/core/0.0.24/package/package.json new file mode 100644 index 0000000..966676c --- /dev/null +++ b/test/project/data/test-registry/core/0.0.24/package/package.json @@ -0,0 +1,13 @@ +{ + "name": "core", + "version": "0.0.24", + "author": "cubicap", + "license": "MIT", + "description": "Minimal template for a new library", + "type": "module", + "main": "", + "types": "dist/types/index.d.ts", + "jaculus": { + "blocks": "blocks" + } +} diff --git a/test/project/data/test-registry/core/versions.json b/test/project/data/test-registry/core/versions.json new file mode 100644 index 0000000..f2940ac --- /dev/null +++ b/test/project/data/test-registry/core/versions.json @@ -0,0 +1,5 @@ +[ + { + "version": "0.0.24" + } +] diff --git a/test/project/data/test-registry/led-strip/0.0.5/package.json b/test/project/data/test-registry/led-strip/0.0.5/package.json new file mode 120000 index 0000000..8a5b9b5 --- /dev/null +++ b/test/project/data/test-registry/led-strip/0.0.5/package.json @@ -0,0 +1 @@ +package/package.json \ No newline at end of file diff --git a/test/project/data/test-registry/led-strip/0.0.5/package/package.json b/test/project/data/test-registry/led-strip/0.0.5/package/package.json new file mode 100644 index 0000000..14f6336 --- /dev/null +++ b/test/project/data/test-registry/led-strip/0.0.5/package/package.json @@ -0,0 +1,13 @@ +{ + "name": "led-strip", + "version": "0.0.5", + "author": "kubaandrysek", + "license": "MIT", + "description": "LED Strip control library", + "type": "module", + "main": "", + "types": "dist/types/index.d.ts", + "dependencies": { + "color": "0.0.2" + } +} diff --git a/test/project/data/test-registry/led-strip/versions.json b/test/project/data/test-registry/led-strip/versions.json new file mode 100644 index 0000000..c71df03 --- /dev/null +++ b/test/project/data/test-registry/led-strip/versions.json @@ -0,0 +1,5 @@ +[ + { + "version": "0.0.5" + } +] diff --git a/test/project/data/test-registry/list.json b/test/project/data/test-registry/list.json new file mode 100644 index 0000000..c411f38 --- /dev/null +++ b/test/project/data/test-registry/list.json @@ -0,0 +1,11 @@ +[ + { + "id": "core" + }, + { + "id": "led-strip" + }, + { + "id": "color" + } +] diff --git a/test/project/package.test.ts b/test/project/package.test.ts new file mode 100644 index 0000000..934a75b --- /dev/null +++ b/test/project/package.test.ts @@ -0,0 +1,472 @@ +import { loadPackageJson, PackageJson, savePackageJson } from "@jaculus/project/package"; +import { cleanupTestDir, createTestDir, expect, fs, path, mockFs } from "./testHelpers.js"; + +const projectBasePath = "data/test-project/"; + +describe("Package JSON", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir("jaculus-package-test-"); + }); + + afterEach(() => { + cleanupTestDir(tempDir); + }); + + describe("loadPackageJson()", () => { + it("should load valid package.json with all fields", async () => { + const packageData: PackageJson = { + name: "test-package", + version: "1.0.0", + description: "A test package", + dependencies: { + core: "0.0.24", + "led-strip": "1.2.3", + }, + registry: ["https://registry.example.com", "https://backup.registry.com"], + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + const loaded = await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + + expect(loaded).to.deep.equal(packageData); + expect(loaded.name).to.equal("test-package"); + expect(loaded.version).to.equal("1.0.0"); + expect(loaded.description).to.equal("A test package"); + expect(loaded.dependencies).to.have.property("core", "0.0.24"); + expect(loaded.dependencies).to.have.property("led-strip", "1.2.3"); + expect(loaded.registry).to.be.an("array").that.includes("https://registry.example.com"); + }); + + it("should load minimal valid package.json with only dependencies", async () => { + const packageData: PackageJson = { + name: "minimal-package", + version: "1.0.0", + dependencies: { + core: "0.0.24", + }, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + const loaded = await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + + expect(loaded).to.deep.equal(packageData); + expect(loaded.dependencies).to.have.property("core", "0.0.24"); + expect(loaded.name).to.equal("minimal-package"); + expect(loaded.version).to.equal("1.0.0"); + expect(loaded.description).to.be.undefined; + expect(loaded.registry).to.be.undefined; + }); + + it("should load package.json with empty dependencies", async () => { + const packageData: PackageJson = { + name: "empty-deps", + version: "1.0.0", + dependencies: {}, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + const loaded = await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + + expect(loaded).to.deep.equal(packageData); + expect(loaded.dependencies).to.be.an("object").that.is.empty; + }); + + it("should throw error for invalid JSON format", async () => { + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, "{ invalid json }"); + + try { + await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + + it("should throw error for non-existent file", async () => { + try { + await loadPackageJson(mockFs, path.join(tempDir, "non-existent.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + + it("should throw error for invalid package name", async () => { + const packageData = { + name: "invalid name with spaces", + version: "1.0.0", + dependencies: {}, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + try { + await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid package.json format"); + } + }); + + it("should throw error for name that's too long", async () => { + const packageData = { + name: "a".repeat(215), // exceeds 214 char limit + version: "1.0.0", + dependencies: {}, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + try { + await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid package.json format"); + } + }); + + it("should throw error for invalid version format", async () => { + const packageData = { + name: "test-package", + version: "invalid-version", + dependencies: {}, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + try { + await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid package.json format"); + } + }); + + it("should accept valid semver versions", async () => { + const versions = [ + "1.0.0", + "0.1.0", + "0.0.1", + "1.0.0-beta", + "1.0.0-alpha.1", + "2.0.0-rc.1", + "1.0.0-beta.2", + ]; + + for (const version of versions) { + const packageData: PackageJson = { + name: "test-package", + version: version, + dependencies: {}, + }; + + const packagePath = path.join( + tempDir, + `package-${version.replace(/[^a-zA-Z0-9]/g, "-")}.json` + ); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + const loaded = await loadPackageJson( + mockFs, + path.join(tempDir, `package-${version.replace(/[^a-zA-Z0-9]/g, "-")}.json`) + ); + expect(loaded.version).to.equal(version); + } + }); + + it("should handle invalid dependency names in dependencies", async () => { + const packageData = { + name: "test-package", + version: "1.0.0", + dependencies: { + "invalid dependency name": "1.0.0", + }, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + try { + await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid package.json format"); + } + }); + + it("should handle invalid dependency versions in dependencies", async () => { + const packageData = { + name: "test-package", + version: "1.0.0", + dependencies: { + "valid-name": "invalid-version", + }, + }; + + const packagePath = path.join(tempDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + try { + await loadPackageJson(mockFs, path.join(tempDir, "package.json")); + expect.fail("Expected loadPackageJson to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid package.json format"); + } + }); + }); + + describe("savePackageJson()", () => { + it("should save valid package.json with proper formatting", async () => { + const packageData: PackageJson = { + name: "test-package", + version: "1.0.0", + description: "A test package", + dependencies: { + core: "0.0.24", + "led-strip": "1.2.3", + }, + registry: ["https://registry.example.com"], + }; + + await savePackageJson(mockFs, path.join(tempDir, "package.json"), packageData); + + const packagePath = path.join(tempDir, "package.json"); + expect(fs.existsSync(packagePath)).to.be.true; + + const fileContent = fs.readFileSync(packagePath, "utf-8"); + const parsedData = JSON.parse(fileContent); + + expect(parsedData).to.deep.equal(packageData); + expect(fileContent).to.include(' "name": "test-package"'); + expect(fileContent).to.include(' "version": "1.0.0"'); + }); + + it("should save minimal package.json", async () => { + const packageData: PackageJson = { + name: "minimal-package", + version: "1.0.0", + dependencies: { + core: "0.0.24", + }, + }; + + await savePackageJson(mockFs, path.join(tempDir, "package.json"), packageData); + + const packagePath = path.join(tempDir, "package.json"); + const fileContent = fs.readFileSync(packagePath, "utf-8"); + const parsedData = JSON.parse(fileContent); + + expect(parsedData).to.deep.equal(packageData); + }); + + it("should create directory if it doesn't exist", async () => { + const nestedDir = path.join(tempDir, "nested", "directory"); + const packageData: PackageJson = { + name: "nested-package", + version: "1.0.0", + dependencies: {}, + }; + + // directory shouldn't exist initially + expect(fs.existsSync(nestedDir)).to.be.false; + + await savePackageJson(mockFs, path.join(nestedDir, "package.json"), packageData); + + const packagePath = path.join(nestedDir, "package.json"); + expect(fs.existsSync(packagePath)).to.be.true; + + const parsedData = JSON.parse(fs.readFileSync(packagePath, "utf-8")); + expect(parsedData).to.deep.equal(packageData); + }); + + it("should overwrite existing file", async () => { + const packagePath = path.join(tempDir, "package.json"); + const initialData: PackageJson = { + name: "initial", + version: "1.0.0", + dependencies: {}, + }; + await savePackageJson(mockFs, path.join(tempDir, "package.json"), initialData); + + // overwrite with new data + const newData: PackageJson = { + name: "updated", + version: "2.0.0", + dependencies: { + core: "1.0.0", + }, + }; + await savePackageJson(mockFs, path.join(tempDir, "package.json"), newData); + + const parsedData = JSON.parse(fs.readFileSync(packagePath, "utf-8")); + expect(parsedData).to.deep.equal(newData); + expect(parsedData.name).to.equal("updated"); + expect(parsedData.version).to.equal("2.0.0"); + }); + + it("should handle empty dependencies object", async () => { + const packageData: PackageJson = { + name: "empty-deps", + version: "1.0.0", + dependencies: {}, + }; + + await savePackageJson(mockFs, path.join(tempDir, "package.json"), packageData); + + const packagePath = path.join(tempDir, "package.json"); + const parsedData = JSON.parse(fs.readFileSync(packagePath, "utf-8")); + + expect(parsedData).to.deep.equal(packageData); + expect(parsedData.dependencies).to.be.an("object").that.is.empty; + }); + }); + + describe("integration test with existing test data", () => { + it("should load the existing test project package.json", async () => { + const testProjectPath = path.resolve( + path.dirname(import.meta.url.replace("file://", "")), + projectBasePath + ); + + const loaded = await loadPackageJson( + mockFs, + path.join(testProjectPath, "package.json") + ); + + expect(loaded).to.have.property("dependencies"); + expect(loaded.dependencies).to.have.property("core", "0.0.24"); + }); + + it("should roundtrip save and load", async () => { + const originalData: PackageJson = { + name: "roundtrip-test", + version: "1.2.3", + description: "Testing roundtrip save/load", + dependencies: { + core: "0.0.24", + "test-lib": "2.1.0-beta", + }, + registry: ["https://test.registry.com", "https://backup.registry.com"], + }; + + await savePackageJson(mockFs, path.join(tempDir, "roundtrip.json"), originalData); + const loadedData = await loadPackageJson(mockFs, path.join(tempDir, "roundtrip.json")); + expect(loadedData).to.deep.equal(originalData); + }); + }); + + describe("Schema validation edge cases", () => { + it("should accept valid package names with all allowed characters", async () => { + const validNames = [ + "core", + "led-strip", + "test_package", + "package.name", + "package123", + "a", + "@scope/package", + "@org/my-package", + "@company/test.package", + "test~package", + "A".repeat(214).toLowerCase(), // max length + ]; + + for (const name of validNames) { + const packageData: PackageJson = { + name: name, + version: "1.0.0", + dependencies: {}, + }; + + const packagePath = path.join( + tempDir, + `test-${name.replace(/[^a-zA-Z0-9]/g, "-")}.json` + ); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + const loaded = await loadPackageJson( + mockFs, + path.join(tempDir, `test-${name.replace(/[^a-zA-Z0-9]/g, "-")}.json`) + ); + expect(loaded.name).to.equal(name); + } + }); + + it("should reject invalid package names", async () => { + const invalidNames = [ + "", // empty + "name with spaces", + "Name", // uppercase at start + "Package123", // uppercase + "name@symbol", + "name#hash", + "name$dollar", + "@SCOPE/package", // uppercase in scope + "@scope/Package", // uppercase in package name + "a".repeat(215), // too long (exceeds 214) + ]; + + for (const name of invalidNames) { + const packageData = { + name: name, + version: "1.0.0", + dependencies: {}, + }; + + const packagePath = path.join( + tempDir, + `invalid-${Math.random().toString(36)}.json` + ); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + try { + await loadPackageJson(mockFs, path.join(tempDir, path.basename(packagePath))); + expect.fail(`Expected name "${name}" to be invalid`); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid package.json format"); + } + } + }); + + it("should handle complex dependency structures", async () => { + const packageData: PackageJson = { + name: "complex-deps", + version: "1.0.0", + dependencies: { + simple: "1.0.0", + "beta-version": "2.0.0-beta.1", + "alpha-version": "3.0.0-alpha", + "rc-version": "4.0.0-rc.2", + "long-name": "5.0.0", + "dots.and.more": "6.0.0", + under_scores: "7.0.0", + "dash-es": "8.0.0", + }, + }; + + const packagePath = path.join(tempDir, "complex.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageData, null, 2)); + + const loaded = await loadPackageJson(mockFs, path.join(tempDir, "complex.json")); + expect(loaded.dependencies).to.deep.equal(packageData.dependencies); + }); + }); +}); diff --git a/test/project/project-dependencies.test.ts b/test/project/project-dependencies.test.ts new file mode 100644 index 0000000..0fa79de --- /dev/null +++ b/test/project/project-dependencies.test.ts @@ -0,0 +1,398 @@ +import { + setupTest, + createProjectStructure, + createMockProject, + expectPackageJson, + expectOutputMessage, + expect, + generateTestRegistryPackages, +} from "./testHelpers.js"; + +describe("Project - Dependency Management", () => { + before(async () => { + await generateTestRegistryPackages("data/test-registry/"); + }); + + describe("install()", () => { + it("should install dependencies from package.json", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.install(); + + expectOutputMessage(mockOut, [ + "Resolving project dependencies", + "Installing library 'core' version '0.0.24'", + "All dependencies resolved and installed successfully", + ]); + } finally { + cleanup(); + } + }); + + it("should install transitive dependencies", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { "led-strip": "0.0.5" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.install(); + } finally { + cleanup(); + } + }); + + it("should handle empty dependencies", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.install(); + + expectOutputMessage(mockOut, [ + "Resolving project dependencies", + "All dependencies resolved and installed successfully", + ]); + } finally { + cleanup(); + } + }); + + it("should throw error when uriRequest is not provided", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24" }, + registry: [], + }); + + const project = await createMockProject(projectPath, mockOut, mockErr); + + try { + await project.install(); + expect.fail("Expected install to throw an error"); + } catch (error) { + expect((error as Error).message).to.include("Registry is not defined"); + } + } finally { + cleanup(); + } + }); + + it("should detect and report version conflicts", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { color: "0.0.1" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.install(); + + expectOutputMessage(mockOut, [ + "All dependencies resolved and installed successfully", + ]); + } finally { + cleanup(); + } + }); + }); + + describe("addLibrary()", () => { + it("should add library with latest compatible version", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.addLibrary("color"); + + expectPackageJson(projectPath, { hasDependency: ["color"] }); + expectOutputMessage(mockOut, ["Adding library 'color'"]); + } finally { + cleanup(); + } + }); + + it("should add library with its dependencies", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.addLibrary("led-strip"); + + expectPackageJson(projectPath, { hasDependency: ["led-strip"] }); + } finally { + cleanup(); + } + }); + + it("should not add library if no compatible version found", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + + try { + await project.addLibrary("non-existent-library"); + expect.fail("Expected addLibrary to throw an error"); + } catch (error) { + expect((error as Error).message).to.include("does not exist in the registry"); + } + } finally { + cleanup(); + } + }); + + it("should preserve existing dependencies when adding new library", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.addLibrary("color"); + + expectPackageJson(projectPath, { hasDependency: ["core", "0.0.24"] }); + expectPackageJson(projectPath, { hasDependency: ["color"] }); + } finally { + cleanup(); + } + }); + }); + + describe("addLibraryVersion()", () => { + it("should add library with specific version", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.addLibraryVersion("color", "0.0.2"); + + expectPackageJson(projectPath, { hasDependency: ["color", "0.0.2"] }); + expectOutputMessage(mockOut, ["Adding library 'color@0.0.2'"]); + } finally { + cleanup(); + } + }); + + it("should throw error for incompatible version", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + + try { + await project.addLibraryVersion("non-existent", "1.0.0"); + expect.fail("Expected addLibraryVersion to throw an error"); + } catch (error) { + expect((error as Error).message).to.include("does not exist"); + } + } finally { + cleanup(); + } + }); + + it("should update existing library to new version", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { color: "0.0.1" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.addLibraryVersion("color", "0.0.2"); + + expectPackageJson(projectPath, { hasDependency: ["color", "0.0.2"] }); + } finally { + cleanup(); + } + }); + }); + + describe("removeLibrary()", () => { + it("should remove library from dependencies", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24", color: "0.0.2" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.removeLibrary("color"); + + expectPackageJson(projectPath, { + noDependency: "color", + hasDependency: ["core", "0.0.24"], + }); + expectOutputMessage(mockOut, [ + "Removing library 'color'", + "Successfully removed library 'color'", + ]); + } finally { + cleanup(); + } + }); + + it("should throw when removing a non-existent library", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + try { + await project.removeLibrary("non-existent"); + expect.fail("Expected removeLibrary to throw an error"); + } catch (error) { + expect((error as Error).message).to.include("has not been found"); + } + } finally { + cleanup(); + } + }); + + it("should remove library and keep others intact", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24", color: "0.0.2", "led-strip": "0.0.5" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.removeLibrary("color"); + + expectPackageJson(projectPath, { + noDependency: "color", + hasDependency: ["core", "0.0.24"], + }); + expectPackageJson(projectPath, { hasDependency: ["led-strip", "0.0.5"] }); + } finally { + cleanup(); + } + }); + + it("should allow removing all libraries", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: { core: "0.0.24" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.removeLibrary("core"); + + expectPackageJson(projectPath, { dependencyCount: 0 }); + } finally { + cleanup(); + } + }); + }); + + describe("integration tests", () => { + it("should handle complete workflow: add, install, remove", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "workflow-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + + await project.addLibrary("color"); + expectPackageJson(projectPath, { hasDependency: ["color"] }); + + mockOut.clear(); + await project.install(); + + mockOut.clear(); + await project.addLibrary("core"); + expectPackageJson(projectPath, { hasDependency: ["core"] }); + expectPackageJson(projectPath, { hasDependency: ["color"] }); + + mockOut.clear(); + await project.removeLibrary("color"); + expectPackageJson(projectPath, { + noDependency: "color", + hasDependency: ["core"], + }); + } finally { + cleanup(); + } + }); + + it("should handle complex dependency trees", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-deps-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "complex-project", { + dependencies: { core: "0.0.24", "led-strip": "0.0.5" }, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + await project.install(); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/test/project/project-package.test.ts b/test/project/project-package.test.ts new file mode 100644 index 0000000..b067ad6 --- /dev/null +++ b/test/project/project-package.test.ts @@ -0,0 +1,413 @@ +import { Project, ProjectPackage } from "@jaculus/project"; +import { + setupTest, + createMockProject, + expectOutputMessage, + expect, + fs, + createProjectStructure, +} from "./testHelpers.js"; + +describe("Project - Package Operations", () => { + describe("constructor", () => { + it("should create Project instance with required parameters", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + const project = await createMockProject(projectPath, mockOut, mockErr); + + expect(project).to.be.instanceOf(Project); + expect(project.projectPath).to.equal(projectPath); + expect(project.out).to.equal(mockOut); + expect(project.err).to.equal(mockErr); + expect(project.registry).to.be.undefined; + } finally { + cleanup(); + } + }); + + it("should create Project instance with optional uriRequest", async () => { + const { tempDir, mockOut, mockErr, getRequest, cleanup } = + setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + const project = await createMockProject(projectPath, mockOut, mockErr, getRequest); + expect(project.registry).to.not.be.undefined; + } finally { + cleanup(); + } + }); + }); + + describe("createFromPackage()", () => { + it("should create project with files and directories", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = `${tempDir}/test-project`; + const project = new Project(fs, projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: ["src", "lib"], + files: { + "src/index.js": new TextEncoder().encode("console.log('hello');"), + "lib/utils.js": new TextEncoder().encode("export const helper = () => {};"), + "package.json": new TextEncoder().encode('{"name": "test"}'), + "manifest.json": new TextEncoder().encode('{"version": "1.0.0"}'), + }, + }; + + await project.createFromPackage(pkg, false); + + expectOutputMessage(mockOut, ["Create"]); + expect(fs.existsSync(`${projectPath}/src`)).to.be.true; + expect(fs.existsSync(`${projectPath}/lib`)).to.be.true; + expect(fs.existsSync(`${projectPath}/src/index.js`)).to.be.true; + expect(fs.existsSync(`${projectPath}/lib/utils.js`)).to.be.true; + expect(fs.existsSync(`${projectPath}/manifest.json`)).to.be.false; + } finally { + cleanup(); + } + }); + + it("should handle dry-run mode", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = `${tempDir}/test-project`; + const project = new Project(fs, projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: ["src"], + files: { + "src/index.js": new TextEncoder().encode("test"), + }, + }; + + await project.createFromPackage(pkg, true); + expectOutputMessage(mockOut, ["[dry-run]"]); + expect(fs.existsSync(projectPath)).to.be.false; + } finally { + cleanup(); + } + }); + + it("should overwrite existing files", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + const project = await createMockProject(projectPath, mockOut, mockErr); + + fs.mkdirSync(`${projectPath}/src`, { recursive: true }); + fs.writeFileSync(`${projectPath}/src/index.js`, "existing content"); + + const pkg: ProjectPackage = { + dirs: [], + files: { + "src/index.js": new TextEncoder().encode("new content"), + "manifest.json": new TextEncoder().encode('{"skeletonFiles": ["src/*"]}'), + }, + }; + + await project.updateFromPackage(pkg, false); + expectOutputMessage(mockOut, ["Overwrite"]); + const content = fs.readFileSync(`${projectPath}/src/index.js`, "utf-8"); + expect(content).to.equal("new content"); + } finally { + cleanup(); + } + }); + + it("should create nested directories", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = `${tempDir}/test-project`; + const project = new Project(fs, projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: ["src/lib/utils"], + files: { + "src/lib/utils/helper.js": new TextEncoder().encode("test"), + }, + }; + + await project.createFromPackage(pkg, false); + expectOutputMessage(mockOut, ["Create"]); + expect(fs.existsSync(`${projectPath}/src/lib/utils`)).to.be.true; + expect(fs.existsSync(`${projectPath}/src/lib/utils/helper.js`)).to.be.true; + } finally { + cleanup(); + } + }); + it("should throw error if project directory already exists", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = `${tempDir}/existing-project`; + fs.mkdirSync(projectPath, { recursive: true }); + + const project = new Project(fs, projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: [], + files: { + "package.json": new TextEncoder().encode('{"name": "test"}'), + }, + }; + + try { + await project.createFromPackage(pkg, false); + expect.fail("Expected createFromPackage to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("already exists"); + } + } finally { + cleanup(); + } + }); + }); + + describe("updateFromPackage()", () => { + it("should filter files based on skeleton patterns", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "test-project", { + dependencies: {}, + }); + const project = await createMockProject(projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: [], + files: { + "tsconfig.json": new TextEncoder().encode('{"compilerOptions": {}}'), + "src/index.js": new TextEncoder().encode("// should be filtered out"), + "manifest.json": new TextEncoder().encode( + '{"skeletonFiles": ["tsconfig.json"]}' + ), + }, + }; + + await project.updateFromPackage(pkg, false); + + expectOutputMessage(mockOut, ["tsconfig.json"]); + expect(fs.existsSync(`${projectPath}/tsconfig.json`)).to.be.true; + expect(fs.existsSync(`${projectPath}/src/index.js`)).to.be.false; + } finally { + cleanup(); + } + }); + + it("should update existing project with skeleton files", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "update-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: ["@types"], + files: { + "@types/stdio.d.ts": new TextEncoder().encode("declare module 'stdio';"), + "tsconfig.json": new TextEncoder().encode('{"compilerOptions": {}}'), + "src/index.js": new TextEncoder().encode("// this should be filtered out"), + }, + }; + + await project.updateFromPackage(pkg, false); + expectOutputMessage(mockOut, ["Create"]); + expect(fs.existsSync(`${projectPath}/@types/stdio.d.ts`)).to.be.true; + expect(fs.existsSync(`${projectPath}/tsconfig.json`)).to.be.true; + // src/index.js should be filtered out by default skeleton + expect(fs.existsSync(`${projectPath}/src/index.js`)).to.be.false; + } finally { + cleanup(); + } + }); + + it("should use default skeleton if manifest doesn't exist", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "update-project", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: ["@types"], + files: { + "@types/stdio.d.ts": new TextEncoder().encode("declare module 'stdio';"), + "tsconfig.json": new TextEncoder().encode('{"compilerOptions": {}}'), + "src/index.js": new TextEncoder().encode("code"), + }, + }; + + await project.updateFromPackage(pkg, false); + expect(fs.existsSync(`${projectPath}/@types/stdio.d.ts`)).to.be.true; + expect(fs.existsSync(`${projectPath}/tsconfig.json`)).to.be.true; + } finally { + cleanup(); + } + }); + + it("should throw error if project directory doesn't exist", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = `${tempDir}/non-existent`; + const project = new Project(fs, projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: [], + files: {}, + }; + + try { + await project.updateFromPackage(pkg, false); + expect.fail("Expected updateFromPackage to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("does not exist"); + } + } finally { + cleanup(); + } + }); + + it("should throw error if path is not a directory", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = `${tempDir}/not-a-dir`; + fs.writeFileSync(projectPath, "I am a file, not a directory"); + + const project = new Project(fs, projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: [], + files: {}, + }; + + try { + await project.updateFromPackage(pkg, false); + expect.fail("Expected updateFromPackage to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("is not a directory"); + } + } finally { + cleanup(); + } + }); + + it("should handle custom skeleton files from manifest", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "custom-skeleton", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr); + + const manifest = { + skeletonFiles: ["*.config.js", "types/*.d.ts"], + }; + + const pkg: ProjectPackage = { + dirs: ["types"], + files: { + "vite.config.js": new TextEncoder().encode("export default {}"), + "types/custom.d.ts": new TextEncoder().encode("declare module 'custom';"), + "src/index.js": new TextEncoder().encode("code"), + "manifest.json": new TextEncoder().encode(JSON.stringify(manifest)), + }, + }; + + await project.updateFromPackage(pkg, false); + expect(fs.existsSync(`${projectPath}/vite.config.js`)).to.be.true; + expect(fs.existsSync(`${projectPath}/types/custom.d.ts`)).to.be.true; + // src/index.js should be filtered out as it doesn't match the skeleton patterns + expect(fs.existsSync(`${projectPath}/src/index.js`)).to.be.false; + } finally { + cleanup(); + } + }); + + it("should throw error for invalid skeleton entry in manifest", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "invalid-skeleton", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr); + + const manifest = { + skeletonFiles: ["valid.js", { invalid: "object" }, "another.js"], + }; + + const pkg: ProjectPackage = { + dirs: [], + files: { + "manifest.json": new TextEncoder().encode(JSON.stringify(manifest)), + }, + }; + + try { + await project.updateFromPackage(pkg, false); + expect.fail("Expected updateFromPackage to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("Invalid skeleton entry"); + } + } finally { + cleanup(); + } + }); + + it("should handle dry-run mode", async () => { + const { tempDir, mockOut, mockErr, cleanup } = setupTest("jaculus-project-test-"); + + try { + const projectPath = createProjectStructure(tempDir, "dry-update", { + dependencies: {}, + }); + + const project = await createMockProject(projectPath, mockOut, mockErr); + + const pkg: ProjectPackage = { + dirs: ["@types"], + files: { + "@types/stdio.d.ts": new TextEncoder().encode("declare module 'stdio';"), + "tsconfig.json": new TextEncoder().encode('{"compilerOptions": {}}'), + }, + }; + + await project.updateFromPackage(pkg, true); + expectOutputMessage(mockOut, ["[dry-run]"]); + expect(fs.existsSync(`${projectPath}/@types/stdio.d.ts`)).to.be.false; + expect(fs.existsSync(`${projectPath}/tsconfig.json`)).to.be.false; + } finally { + cleanup(); + } + }); + }); +}); diff --git a/test/project/registry.test.ts b/test/project/registry.test.ts new file mode 100644 index 0000000..566e0b7 --- /dev/null +++ b/test/project/registry.test.ts @@ -0,0 +1,296 @@ +import { extractTgzPackage } from "@jaculus/project/fs"; +import { Registry } from "@jaculus/project/registry"; +import { + createGetRequest, + createFailingGetRequest, + cleanupTestDir, + createTestDir, + expect, + fs, + registryBasePath, + generateTestRegistryPackages, +} from "./testHelpers.js"; + +describe("Registry", () => { + before(async () => { + await generateTestRegistryPackages(registryBasePath); + }); + + describe("listPackages()", () => { + it("should list all libraries from registry", async () => { + const getRequest = createGetRequest(); + const registry = await Registry.create([registryBasePath], getRequest); + const libraries = await registry.listPackages(); + expect(libraries) + .to.be.an("array") + .that.includes("core") + .and.includes("led-strip") + .and.includes("color"); + }); + + it("should handle multiple registries", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const libraries = await registry.listPackages(); + expect(libraries).to.be.an("array"); + expect(libraries.length).to.be.greaterThan(0); + }); + + it("should throw error when registry is unreachable", async () => { + const getRequestFailure = createFailingGetRequest(); + const registry = Registry.createWithoutValidation( + [registryBasePath], + getRequestFailure + ); + try { + await registry.listPackages(); + expect.fail("Expected registry.listPackages() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + + it("should deduplicate library IDs across registries", async () => { + const getRequest = createGetRequest(); + const mockGetRequest = async (baseUri: string, libFile: string) => { + if (libFile === "list.json") { + return new TextEncoder().encode(JSON.stringify([{ id: "duplicate-lib" }])); + } + return getRequest(baseUri, libFile); + }; + + const registry = Registry.createWithoutValidation( + [registryBasePath, "another-registry"], + mockGetRequest + ); + const libraries = await registry.listPackages(); + expect(libraries.filter((id) => id === "duplicate-lib")).to.have.lengthOf(1); + }); + }); + + describe("exists()", () => { + it("should return true for existing library", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const exists = await registry.exists("core"); + expect(exists).to.be.true; + }); + + it("should return false for non-existing library", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const exists = await registry.exists("non-existent-library"); + expect(exists).to.be.false; + }); + + it("should return false when registry is unreachable", async () => { + const getRequestFailure = createFailingGetRequest(); + const registry = Registry.createWithoutValidation( + [registryBasePath], + getRequestFailure + ); + const exists = await registry.exists("core"); + expect(exists).to.be.false; + }); + }); + + describe("listVersions()", () => { + it("should list all versions for a library", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const versions = await registry.listVersions("color"); + expect(versions).to.be.an("array").that.includes("0.0.1").and.includes("0.0.2"); + }); + + it("should throw error for non-existing library", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + try { + await registry.listVersions("non-existent-library"); + expect.fail("Expected registry.listVersions() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + + it("should throw error when registry is unreachable", async () => { + const getRequestFailure = createFailingGetRequest(); + const registry = Registry.createWithoutValidation( + [registryBasePath], + getRequestFailure + ); + try { + await registry.listVersions("color"); + expect.fail("Expected registry.listVersions() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + }); + + describe("getPackageJson()", () => { + it("should get package.json for a specific library version", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const packageJson = await registry.getPackageJson("core", "0.0.24"); + expect(packageJson).to.be.an("object"); + expect(packageJson).to.have.property("name"); + expect(packageJson).to.have.property("version"); + }); + + it("should throw error for non-existing library version", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + try { + await registry.getPackageJson("non-existent-library", "1.0.0"); + expect.fail("Expected registry.getPackageJson() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + + it("should throw error when registry is unreachable", async () => { + const getRequestFailure = createFailingGetRequest(); + const registry = Registry.createWithoutValidation( + [registryBasePath], + getRequestFailure + ); + try { + await registry.getPackageJson("core", "0.0.24"); + expect.fail("Expected registry.getPackageJson() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + }); + + describe("getPackageTgz()", () => { + it("should get package tarball for a specific library version", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const packageData = await registry.getPackageTgz("core", "0.0.24"); + expect(packageData).to.be.instanceOf(Uint8Array); + expect(packageData.length).to.be.greaterThan(0); + + // check for gzip magic number + expect(packageData[0]).to.equal(0x1f); + expect(packageData[1]).to.equal(0x8b); + }); + + it("should throw error for non-existing library version", async () => { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + try { + await registry.getPackageTgz("non-existent-library", "1.0.0"); + expect.fail("Expected registry.getPackageTgz() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + + it("should throw error when registry is unreachable", async () => { + const getRequestFailure = createFailingGetRequest(); + const registry = Registry.createWithoutValidation( + [registryBasePath], + getRequestFailure + ); + try { + await registry.getPackageTgz("core", "0.0.24"); + expect.fail("Expected registry.getPackageTgz() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + }); + + describe("extractTgzPackage()", () => { + it("should extract library package to specified directory", async () => { + const tempDir = createTestDir("jaculus-test-"); + + try { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + + for (const library of await registry.listPackages()) { + for (const version of await registry.listVersions(library)) { + const packageData = await registry.getPackageTgz(library, version); + const extractDir = `${tempDir}/${library}-${version}`; + await extractTgzPackage(packageData, fs, extractDir); + } + } + } finally { + cleanupTestDir(tempDir); + } + }); + + it("should create extraction directory if it doesn't exist", async () => { + const tempDir = createTestDir("jaculus-test-"); + + try { + const getRequest = createGetRequest(); + const registry = Registry.createWithoutValidation([registryBasePath], getRequest); + const packageData = await registry.getPackageTgz("core", "0.0.24"); + const extractDir = `${tempDir}/nested/directory`; + + await extractTgzPackage(packageData, fs, extractDir); + } finally { + cleanupTestDir(tempDir); + } + }); + + it("should handle corrupt package data gracefully", async () => { + const tempDir = createTestDir("jaculus-test-"); + + try { + const corruptData = new Uint8Array([1, 2, 3, 4, 5]); // invalid gzip data + const extractDir = `${tempDir}/corrupt-test`; + + try { + await extractTgzPackage(corruptData, fs, extractDir); + expect.fail("Expected extractTgzPackage to throw an error for corrupt data"); + } catch (error) { + expect(error).to.exist; + } + } finally { + cleanupTestDir(tempDir); + } + }); + }); + + describe("multiple registries fallback", () => { + it("should try multiple registries and succeed with the working one", async () => { + const workingRegistry = registryBasePath; + const failingRegistry = "non-existent-registry"; + const getRequest = createGetRequest(); + + // mix working and failing registries + const registry = Registry.createWithoutValidation( + [failingRegistry, workingRegistry], + async (baseUri, libFile) => { + if (baseUri === failingRegistry) { + throw new Error("Registry not found"); + } + return getRequest(baseUri, libFile); + } + ); + + const exists = await registry.exists("core"); + expect(exists).to.be.true; + }); + + it("should fail when all registries are unreachable", async () => { + const getRequestFailure = createFailingGetRequest(); + const registry = Registry.createWithoutValidation( + ["registry1", "registry2"], + getRequestFailure + ); + + try { + await registry.listPackages(); + expect.fail("Expected registry.listPackages() to throw an error"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); + }); +}); diff --git a/test/project/testHelpers.ts b/test/project/testHelpers.ts new file mode 100644 index 0000000..73df6d2 --- /dev/null +++ b/test/project/testHelpers.ts @@ -0,0 +1,235 @@ +import path from "path"; +import fs from "fs"; +import { tmpdir } from "os"; +import { Writable } from "stream"; +import * as chai from "chai"; +import { Archive } from "@obsidize/tar-browserify"; +import pako from "pako"; +import { JaculusRequestError, RequestFunction } from "@jaculus/project/fs"; +import { Project } from "@jaculus/project"; +import { PackageJson, loadPackageJson } from "@jaculus/project/package"; +import { Registry } from "@jaculus/project/registry"; + +export const expect = chai.expect; +export const registryBasePath = "file://data/test-registry/"; +export { fs, path, fs as mockFs }; + +export async function createTarGzPackage(sourceDir: string, outFile: string): Promise { + const archive = new Archive(); + + // recursively add files from sourceDir with "package/" prefix + function addFilesToArchive(dir: string, baseDir: string = dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(baseDir, fullPath); + const tarPath = path.join("package", relativePath); + + if (entry.isDirectory()) { + archive.addDirectory(tarPath); + addFilesToArchive(fullPath, baseDir); + } else if (entry.isFile()) { + const content = fs.readFileSync(fullPath); + archive.addBinaryFile(tarPath, content); + } + } + } + + addFilesToArchive(sourceDir); + + const tarData = archive.toUint8Array(); + const gzData = pako.gzip(tarData); + fs.writeFileSync(outFile, gzData); +} + +export async function generateTestRegistryPackages(registryBasePath: string): Promise { + const baseDir = registryBasePath.replace(/^file:\/\//, ""); + const testDataPath = path.resolve( + path.dirname(import.meta.url.replace("file://", "")), + baseDir + ); + const libraries = JSON.parse(fs.readFileSync(path.join(testDataPath, "list.json"), "utf-8")); + + for (const lib of libraries) { + const libPath = path.join(testDataPath, lib.id); + const versionsFile = path.join(libPath, "versions.json"); + + if (fs.existsSync(versionsFile)) { + const versions = JSON.parse(fs.readFileSync(versionsFile, "utf-8")); + + for (const ver of versions) { + const versionPath = path.join(libPath, ver.version); + const packagePath = path.join(versionPath, "package"); + const tarGzPath = path.join(versionPath, "package.tar.gz"); + + if (fs.existsSync(packagePath)) { + await createTarGzPackage(packagePath, tarGzPath); + } + } + } + } +} + +// helper class to capture output +export class MockWritable extends Writable { + public output: string = ""; + + _write(chunk: any, _encoding: string, callback: (error?: Error | null) => void) { + this.output += chunk.toString(); + callback(); + } + + clear() { + this.output = ""; + } +} + +export const createGetRequest = (): RequestFunction => async (baseUri, libFile) => { + expect(baseUri).to.match(/^(file:\/\/|http:\/\/)/); + if (libFile === "") { + return new Uint8Array(); + } + + const baseDir = baseUri.replace(/^file:\/\//, ""); + const filePath = path.resolve( + path.dirname(import.meta.url.replace("file://", "")), + baseDir, + libFile + ); + try { + return new Uint8Array(fs.readFileSync(filePath)); + } catch (error) { + throw new JaculusRequestError(`Failed to read ${filePath}: ${(error as Error).message}`); + } +}; + +export const createFailingGetRequest = (): RequestFunction => async (baseUri, libFile) => { + throw new JaculusRequestError(`Simulated network error for ${baseUri}/${libFile}`); +}; + +export function createPackageJson( + projectPath: string, + dependencies: Record = {}, + registry: string[] = [registryBasePath], + additionalFields: Partial = {} +): void { + const packageData: PackageJson = { + name: "test-project", + version: "0.0.1", + dependencies, + registry, + ...additionalFields, + }; + + fs.mkdirSync(projectPath, { recursive: true }); + fs.writeFileSync(path.join(projectPath, "package.json"), JSON.stringify(packageData, null, 2)); +} + +export async function createMockProject( + projectPath: string, + mockOut: MockWritable, + mockErr: MockWritable, + getRequest?: RequestFunction +): Promise { + const pkg = await loadPackageJson(fs, path.join(projectPath, "package.json")); + let registry: Registry | undefined = undefined; + if (getRequest) { + registry = Registry.createWithoutValidation(pkg.registry, getRequest); + } + return new Project(fs, projectPath, mockOut, mockErr, registry); +} + +export function createTestDir(prefix: string = "jaculus-test-"): string { + return fs.mkdtempSync(path.join(tmpdir(), prefix)); +} + +export function cleanupTestDir(tempDir: string): void { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +export function createProjectStructure( + tempDir: string, + projectName: string, + packageData?: Partial +): string { + const projectPath = path.join(tempDir, projectName); + + if (packageData) { + createPackageJson( + projectPath, + packageData.dependencies || {}, + packageData.registry || [registryBasePath], + packageData + ); + } else { + fs.mkdirSync(projectPath, { recursive: true }); + } + + return projectPath; +} + +export function setupTest(prefix?: string): { + tempDir: string; + mockOut: MockWritable; + mockErr: MockWritable; + getRequest: RequestFunction; + cleanup: () => void; +} { + const tempDir = createTestDir(prefix); + const mockOut = new MockWritable(); + const mockErr = new MockWritable(); + const getRequest = createGetRequest(); + + const cleanup = () => cleanupTestDir(tempDir); + + return { tempDir, mockOut, mockErr, getRequest, cleanup }; +} + +export function readPackageJson(projectPath: string): PackageJson { + const packagePath = path.join(projectPath, "package.json"); + return JSON.parse(fs.readFileSync(packagePath, "utf-8")); +} + +export function expectPackageJson( + projectPath: string, + expectations: { + hasDependency?: [string, string?]; + noDependency?: string; + dependencyCount?: number; + } +): void { + const pkg = readPackageJson(projectPath); + + if (expectations.hasDependency) { + const [name, version] = expectations.hasDependency; + if (version) { + expect(pkg.dependencies).to.have.property(name, version); + } else { + expect(pkg.dependencies).to.have.property(name); + } + } + + if (expectations.noDependency) { + expect(pkg.dependencies).to.not.have.property(expectations.noDependency); + } + + if (expectations.dependencyCount !== undefined) { + expect(Object.keys(pkg.dependencies)).to.have.length(expectations.dependencyCount); + } +} + +export function expectOutputMessage( + mockOut: MockWritable, + includes: string[], + excludes: string[] = [] +): void { + for (const message of includes) { + expect(mockOut.output).to.include(message); + } + + for (const message of excludes) { + expect(mockOut.output).to.not.include(message); + } +}