From cd9d21473145a0293c4c384f2fcdfc606a46778a Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 30 Dec 2024 00:13:59 -0800 Subject: [PATCH] Add initial `@nx.js/install-title` package Closes #130. --- .changeset/tough-cheetahs-guess.md | 5 + packages/install-title/package.json | 39 +++ packages/install-title/src/index.ts | 456 +++++++++++++++++++++++++++ packages/install-title/src/ipc/es.ts | 26 ++ packages/install-title/src/ipc/ns.ts | 107 +++++++ packages/install-title/src/types.ts | 48 +++ packages/install-title/tsconfig.json | 18 ++ packages/install-title/typedoc.json | 7 + pnpm-lock.yaml | 23 ++ 9 files changed, 729 insertions(+) create mode 100644 .changeset/tough-cheetahs-guess.md create mode 100644 packages/install-title/package.json create mode 100644 packages/install-title/src/index.ts create mode 100644 packages/install-title/src/ipc/es.ts create mode 100644 packages/install-title/src/ipc/ns.ts create mode 100644 packages/install-title/src/types.ts create mode 100644 packages/install-title/tsconfig.json create mode 100644 packages/install-title/typedoc.json diff --git a/.changeset/tough-cheetahs-guess.md b/.changeset/tough-cheetahs-guess.md new file mode 100644 index 00000000..c2282fb4 --- /dev/null +++ b/.changeset/tough-cheetahs-guess.md @@ -0,0 +1,5 @@ +--- +"@nx.js/install-title": patch +--- + +Add initial `@nx.js/install-title` package diff --git a/packages/install-title/package.json b/packages/install-title/package.json new file mode 100644 index 00000000..f427b050 --- /dev/null +++ b/packages/install-title/package.json @@ -0,0 +1,39 @@ +{ + "name": "@nx.js/install-title", + "version": "0.0.0", + "description": "Install a title from an NSP file", + "repository": { + "type": "git", + "url": "https://github.com/TooTallNate/nx.js.git", + "directory": "packages/install-title" + }, + "homepage": "https://nxjs.n8.io/install-title", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "docs": "typedoc && ../../type-aliases-meta.sh" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@nx.js/constants": "^0.4.0", + "@nx.js/ncm": "^1.0.0", + "@nx.js/util": "^0.0.1", + "@tootallnate/nsp": "^0.0.2" + }, + "devDependencies": { + "@nx.js/runtime": "workspace:*" + }, + "keywords": [ + "nx.js", + "switch", + "install", + "nsp" + ], + "author": "Nathan Rajlich ", + "license": "MIT" +} diff --git a/packages/install-title/src/index.ts b/packages/install-title/src/index.ts new file mode 100644 index 00000000..f5da9c05 --- /dev/null +++ b/packages/install-title/src/index.ts @@ -0,0 +1,456 @@ +import { u8 } from '@nx.js/util'; +import { FsFileSystemType } from '@nx.js/constants'; +import { + NcmContentId, + NcmStorageId, + NcmContentStorage, + NcmContentMetaDatabase, + NcmContentMetaKey, + NcmContentInstallType, + NcmContentType, + NcmContentMetaHeader, + NcmContentInfo, + NcmContentStorageRecord, + NcmContentMetaType, + NcmPatchMetaExtendedHeader, + NcmApplicationMetaExtendedHeader, +} from '@nx.js/ncm'; +import { parseNsp, type FileEntry } from '@tootallnate/nsp'; +import { + NsApplicationRecordType, + nsDeleteApplicationRecord, + nsInvalidateApplicationControlCache, + nsListApplicationRecordContentMeta, + nsPushApplicationRecord, +} from './ipc/ns'; +import { esImportTicket } from './ipc/es'; +import { PackagedContentMetaHeader, PackagedContentInfo } from './types'; + +export interface StepStart { + type: 'start'; + name: string; + timeStart: number; +} + +export interface StepEnd { + type: 'end'; + name: string; + timeStart: number; + timeEnd: number; +} + +export interface StepProgress { + type: 'progress'; + name: string; + processed: number; + total: number; +} + +export type Step = StepStart | StepEnd | StepProgress; + +async function* step( + name: string, + fn: () => AsyncIterable, +): AsyncIterable { + const timeStart = performance.now(); + yield { type: 'start', name, timeStart }; + try { + return yield* fn(); + } finally { + yield { type: 'end', name, timeStart, timeEnd: performance.now() }; + } +} + +// Install NSP +export async function* installNsp( + data: Blob, + storageId: NcmStorageId, +): AsyncIterable { + const contentStorage = NcmContentStorage.open(storageId); + const contentMetaDatabase = NcmContentMetaDatabase.open(storageId); + + // The `.nca` files that will be installed + const ncaFiles = new Set(); + + // Map of title IDs found in the `.cnmt.nca` files and the accompanying + // `NcmContentMetaKey` instances that will be recorded + const titleIdToContentMetaKeysMap = new Map(); + + const nsp = yield* step('Parse NSP', async function* () { + return parseNsp(data); + }); + + // Prepare metadata from the `.cnmt.nca` file(s) + const cnmtNcaFiles = nsp.files + .entries() + .filter(([name]) => name.endsWith('.cnmt.nca')); + for (const [name, entry] of cnmtNcaFiles) { + // Install the `.nca` so that we can mount the + // filesystem to read the `.cnmt` file inside + await installNca(name, entry, contentStorage); + + // Read the decrypted `.cnmt` file + const cnmtData = await readContentMeta(name, contentStorage); + + const contentMetaKey = await installContentMetaRecords( + contentMetaDatabase, + cnmtData, + createMetaContentInfo(name, entry), + ncaFiles, + ); + + // Add the content meta key to add to the application record later + const cnmtHeader = new PackagedContentMetaHeader(cnmtData); + const titleId = getBaseTitleId(cnmtHeader.titleId, cnmtHeader.type); + let contentMetaKeys = titleIdToContentMetaKeysMap.get(titleId); + if (!contentMetaKeys) { + contentMetaKeys = []; + titleIdToContentMetaKeysMap.set(titleId, contentMetaKeys); + } + contentMetaKeys.push(contentMetaKey); + } + + // Install Tickets / Certificates + yield* importTicketCerts(nsp.files); + + // Install NCAs files + for (const name of ncaFiles) { + const entry = nsp.files.get(name); + if (!entry) { + throw new Error(`Missing "${name}" file`); + } + await installNca(name, entry, contentStorage); + } + + // Push application records + for (const [titleId, contentMetaKeys] of titleIdToContentMetaKeysMap) { + await pushApplicationRecord(titleId, contentMetaKeys, storageId); + } +} + +class ContentStoragePlaceholderWriteStream extends WritableStream { + #offset: bigint; + + constructor( + contentStorage: NcmContentStorage, + contentId: NcmContentId, + size: bigint, + ) { + const placeholderId = contentStorage.generatePlaceHolderId(); + try { + contentStorage.deletePlaceHolder(placeholderId); + } catch {} + contentStorage.createPlaceHolder(contentId, placeholderId, BigInt(size)); + + super({ + write: (chunk) => { + console.debug(`Writing ${chunk.byteLength} bytes (${this.#offset})`); + contentStorage.writePlaceHolder(placeholderId, this.#offset, chunk); + this.#offset += BigInt(chunk.byteLength); + }, + close: () => { + console.debug(`Finished writing ${this.#offset} bytes`); + try { + contentStorage.delete(contentId); + } catch {} + contentStorage.register(contentId, placeholderId); + }, + }); + + this.#offset = 0n; + } +} + +function createContentMetaKey( + header: PackagedContentMetaHeader, +): NcmContentMetaKey { + const key = new NcmContentMetaKey(); + key.id = header.titleId; + key.version = header.version; + key.type = header.type; + key.installType = NcmContentInstallType.Full; + return key; +} + +function createMetaContentInfo(name: string, entry: FileEntry): NcmContentInfo { + const info = new NcmContentInfo(); + info.contentId = NcmContentId.from(name); + info.size = entry.size; + info.contentType = NcmContentType.Meta; + return info; +} + +async function installNca( + name: string, + entry: FileEntry, + contentStorage: NcmContentStorage, +) { + console.debug(`Installing "${name}" (${entry.size} bytes)`); + const contentId = NcmContentId.from(name); + await entry.data + .stream() + .pipeTo( + new ContentStoragePlaceholderWriteStream( + contentStorage, + contentId, + entry.size, + ), + ); +} + +async function* importTicketCerts( + files: Map, +): AsyncIterable { + const ticketFiles = files.keys().filter((name) => name.endsWith('.tik')); + for (const tikName of ticketFiles) { + yield* step(`Importing ticket "${tikName}"`, async function* () { + const hash = tikName.slice(0, -4); + const tikEntry = files.get(tikName); + if (!tikEntry) { + throw new Error(`Missing "${tikName}" file`); + } + + const certName = `${hash}.cert`; + const certEntry = files.get(certName); + if (!certEntry) { + throw new Error(`Missing "${certName}" file`); + } + + const tik = await tikEntry.data.arrayBuffer(); + const cert = await certEntry.data.arrayBuffer(); + + console.debug(`Importing "${tikName}" and "${certName}" certificate`); + esImportTicket(tik, cert); + }); + } +} + +async function readContentMeta( + name: string, + contentStorage: NcmContentStorage, +) { + const contentId = NcmContentId.from(name); + const path = contentStorage.getPath(contentId); + console.debug(`Mounting path "${path}"`); + + const fs = Switch.FileSystem.openWithId( + 0n, + FsFileSystemType.ContentMeta, + path, + ); + const url = fs.mount(); + const files = Switch.readDirSync(url); + if (!files) { + throw new Error('Failed to read directory'); + } + const cnmtName = files.find((name) => name.endsWith('.cnmt')); + if (!cnmtName) { + throw new Error('Failed to find ".cnmt" file'); + } + console.debug(`Reading "${cnmtName}" file`); + const cnmt = Switch.readFileSync(new URL(cnmtName, url)); + if (!cnmt) { + throw new Error('Failed to read ".cnmt" file'); + } + return cnmt; +} + +async function installContentMetaRecords( + contentMetaDatabase: NcmContentMetaDatabase, + cnmtData: ArrayBuffer, + cnmtContentInfo: NcmContentInfo, + ncaFilesToInstall: Set, +): Promise { + const contentInfos: NcmContentInfo[] = []; + const cnmtHeader = new PackagedContentMetaHeader(cnmtData); + const key = createContentMetaKey(cnmtHeader); + const extendedHeaderSize = cnmtHeader.extendedHeaderSize; + const type = cnmtHeader.type; + let extendedDataSize = 0; + + // Add a `NcmContentInfo` for the `.cnmt.nca` file, since we installed it + contentInfos.push(cnmtContentInfo); + + // Add content records from `.cnmt` file + const contentCount = cnmtHeader.contentCount; + for (let i = 0; i < contentCount; i++) { + const packagedContentInfoOffset = + PackagedContentMetaHeader.sizeof + + extendedHeaderSize + + i * PackagedContentInfo.sizeof; + const packagedContentInfo = new PackagedContentInfo( + cnmtData, + packagedContentInfoOffset, + ); + const contentInfo = packagedContentInfo.contentInfo; + + // Don't install delta fragments. Even patches don't seem to install them. + if (contentInfo.contentType === NcmContentType.DeltaFragment) { + continue; + } + + contentInfos.push(contentInfo); + ncaFilesToInstall.add(`${contentInfo.contentId}.nca`); + } + + if (type === NcmContentMetaType.Patch) { + const patchMetaExtendedHeader = new NcmPatchMetaExtendedHeader( + cnmtData, + PackagedContentMetaHeader.sizeof, + ); + extendedDataSize = patchMetaExtendedHeader.extendedDataSize; + } + + const contentMetaData = new Uint8Array( + NcmContentMetaHeader.sizeof + + extendedHeaderSize + + contentInfos.length * NcmContentInfo.sizeof + + extendedDataSize, + ); + + // write header + const contentMetaHeader = new NcmContentMetaHeader(contentMetaData); + contentMetaHeader.extendedHeaderSize = extendedHeaderSize; + contentMetaHeader.contentCount = contentInfos.length; + contentMetaHeader.contentMetaCount = 0; + contentMetaHeader.attributes = 0; + contentMetaHeader.storageId = 0; + + // write extended header + contentMetaData.set( + new Uint8Array( + cnmtData, + PackagedContentMetaHeader.sizeof, + extendedHeaderSize, + ), + NcmContentMetaHeader.sizeof, + ); + + // Optionally disable the required system version field + if (type === NcmContentMetaType.Application) { + const extendedHeader = new NcmApplicationMetaExtendedHeader( + contentMetaData, + NcmContentMetaHeader.sizeof, + ); + extendedHeader.requiredSystemVersion = 0; + } else if (type === NcmContentMetaType.Patch) { + const extendedHeader = new NcmPatchMetaExtendedHeader( + contentMetaData, + NcmContentMetaHeader.sizeof, + ); + extendedHeader.requiredSystemVersion = 0; + } + + // write content infos + for (let i = 0; i < contentInfos.length; i++) { + const offset = + NcmContentMetaHeader.sizeof + + extendedHeaderSize + + i * NcmContentInfo.sizeof; + contentMetaData.set(u8(contentInfos[i]), offset); + } + + // write extended data + if (extendedDataSize > 0) { + contentMetaData.set( + new Uint8Array( + cnmtData, + PackagedContentMetaHeader.sizeof + + extendedHeaderSize + + contentInfos.length * NcmContentInfo.sizeof, + extendedDataSize, + ), + NcmContentMetaHeader.sizeof + + extendedHeaderSize + + contentInfos.length * NcmContentInfo.sizeof, + ); + } + + contentMetaDatabase.set(key, contentMetaData); + contentMetaDatabase.commit(); + + return key; +} + +async function pushApplicationRecord( + titleId: bigint, + contentMetaKeys: NcmContentMetaKey[], + storageId: NcmStorageId, +) { + console.debug( + `Pushing application record for "${titleId + .toString(16) + .padStart(16, '0')}" (${contentMetaKeys.length} content meta keys)`, + ); + + // TODO: for some reason, `nsCountApplicationContentMeta()` is returning 0. + // So skip for now and rely on `listApplicationRecordContentMeta()` instead + //let existingRecordCount = 0; + //try { + // existingRecordCount = nsCountApplicationContentMeta(titleId); + //} catch (err: unknown) { + // console.log(err); + // const recordDoesNotExist = false; // TODO for code 0x410 + // if (!recordDoesNotExist) { + // throw err; + // } + //} + //console.debug(`Found ${existingRecordCount} existing content meta records`); + + const contentStorageRecords = new Uint8Array( + NcmContentStorageRecord.sizeof * 20, // TODO: randomly selected max array length + ); + + let entriesRead = 0; + try { + entriesRead = nsListApplicationRecordContentMeta( + 0n, + titleId, + contentStorageRecords, + ); + console.debug(`Found ${entriesRead} existing content meta records`); + } catch (err: unknown) { + console.log('nsListApplicationRecordContentMeta', err); + } + + // Add new content meta keys to the end of the array + for (let i = 0; i < contentMetaKeys.length; i++) { + const contentStorageRecord = new NcmContentStorageRecord( + contentStorageRecords, + (entriesRead + i) * NcmContentStorageRecord.sizeof, + ); + contentStorageRecord.key = contentMetaKeys[i]; + contentStorageRecord.storageId = storageId; + } + + try { + nsDeleteApplicationRecord(titleId); + } catch {} + + nsPushApplicationRecord( + titleId, + NsApplicationRecordType.Installed, + contentStorageRecords.slice( + 0, + (entriesRead + contentMetaKeys.length) * NcmContentStorageRecord.sizeof, + ), + ); + + // force flush + if (entriesRead > 0) { + nsInvalidateApplicationControlCache(titleId); + } +} + +function getBaseTitleId(titleId: bigint, type: NcmContentMetaType): bigint { + switch (type) { + case NcmContentMetaType.Patch: + return titleId ^ 0x800n; + + case NcmContentMetaType.AddOnContent: + return (titleId ^ 0x1000n) & ~0xfffn; + + default: + return titleId; + } +} diff --git a/packages/install-title/src/ipc/es.ts b/packages/install-title/src/ipc/es.ts new file mode 100644 index 00000000..74fb1cf7 --- /dev/null +++ b/packages/install-title/src/ipc/es.ts @@ -0,0 +1,26 @@ +// Ported from `tinwoo/nx/ipc/es.c` +import { SfBufferAttr } from '@nx.js/constants'; + +const es = new Switch.Service('es'); + +export function esImportTicket(tik: ArrayBuffer, cert: ArrayBuffer) { + //Result esImportTicket(void const *tikBuf, size_t tikSize, void const *certBuf, size_t certSize) { + // return serviceDispatch(&g_esSrv, 1, + // .buffer_attrs = { + // SfBufferAttr_HipcMapAlias | SfBufferAttr_In, + // SfBufferAttr_HipcMapAlias | SfBufferAttr_In, + // }, + // .buffers = { + // { tikBuf, tikSize }, + // { certBuf, certSize }, + // }, + // ); + //} + es.dispatch(1, { + bufferAttrs: [ + SfBufferAttr.HipcMapAlias | SfBufferAttr.In, + SfBufferAttr.HipcMapAlias | SfBufferAttr.In, + ], + buffers: [tik, cert], + }); +} diff --git a/packages/install-title/src/ipc/ns.ts b/packages/install-title/src/ipc/ns.ts new file mode 100644 index 00000000..67faa713 --- /dev/null +++ b/packages/install-title/src/ipc/ns.ts @@ -0,0 +1,107 @@ +// Ported from `tinwoo/nx/ipc/ns_ext.c` +import { SfBufferAttr } from '@nx.js/constants'; +import { NcmStorageId } from '@nx.js/ncm'; + +export enum NsApplicationRecordType { + // installed + Installed = 0x3, + // application is gamecard, but gamecard isn't insterted + GamecardMissing = 0x5, + // archived + Archived = 0xb, +} + +const nsAm2 = new Switch.Service('ns:am2'); +const nsAppManSrv = new Switch.Service(); +nsAm2.dispatch(7996, { + outObjects: [nsAppManSrv], +}); + +export function nsPushApplicationRecord( + titleId: bigint, + lastModifiedEvent: NsApplicationRecordType, + contentRecords: ArrayBuffer, +) { + //Result nsPushApplicationRecord(u64 title_id, u8 last_modified_event, ContentStorageRecord *content_records_buf, size_t buf_size) { + // struct { + // u8 last_modified_event; + // u8 padding[0x7]; + // u64 title_id; + // } in = { last_modified_event, {0}, title_id }; + // + // return serviceDispatchIn(&g_nsAppManSrv, 16, in, + // .buffer_attrs = { SfBufferAttr_HipcMapAlias | SfBufferAttr_In }, + // .buffers = { { content_records_buf, buf_size } }); + //} + const inData = new ArrayBuffer(0x10); + new Uint8Array(inData)[0] = lastModifiedEvent; + new BigUint64Array(inData, 0x8)[0] = titleId; + nsAppManSrv.dispatchIn(16, inData, { + bufferAttrs: [SfBufferAttr.HipcMapAlias | SfBufferAttr.In], + buffers: [contentRecords], + }); +} + +export function nsListApplicationRecordContentMeta( + offset: bigint, + titleId: bigint, + outBuf: ArrayBuffer, +) { + //Result nsListApplicationRecordContentMeta(u64 offset, u64 titleID, void *out_buf, size_t out_buf_size, u32 *entries_read_out) { + // struct { + // u64 offset; + // u64 titleID; + // } in = { offset, titleID }; + // + // struct { + // u32 entries_read; + // } out; + // + // Result rc = serviceDispatchInOut(&g_nsAppManSrv, 17, in, out, + // .buffer_attrs = { SfBufferAttr_HipcMapAlias | SfBufferAttr_Out }, + // .buffers = { { out_buf, out_buf_size } }); + // + // if (R_SUCCEEDED(rc) && entries_read_out) *entries_read_out = out.entries_read; + // + // return rc; + //} + const inData = new BigUint64Array([offset, titleId]); + const out = new Uint32Array(1); + nsAppManSrv.dispatchInOut(17, inData, out, { + bufferAttrs: [SfBufferAttr.HipcMapAlias | SfBufferAttr.Out], + buffers: [outBuf], + }); + return out[0]; +} + +export function nsDeleteApplicationRecord(titleId: bigint) { + //Result nsDeleteApplicationRecord(u64 titleID) { + // struct { + // u64 titleID; + // } in = { titleID }; + // + // return serviceDispatchIn(&g_nsAppManSrv, 27, in); + //} + const inData = new BigUint64Array([titleId]); + nsAppManSrv.dispatchIn(27, inData); +} + +export function nsInvalidateApplicationControlCache(titleId: bigint) { + //Result nsInvalidateApplicationControlCache(Service* srv, u64 tid) { + // return serviceDispatchIn(srv, 404, tid); + //} + const inData = new BigUint64Array([titleId]); + nsAppManSrv.dispatchIn(404, inData); +} + +export function nsIsAnyApplicationEntityInstalled(titleId: bigint) { + //Result nsIsAnyApplicationEntityInstalled(u64 application_id, bool *out) { + // u8 tmp=0; + // if (R_SUCCEEDED(rc)) rc = serviceDispatchInOut(srv_ptr, 1300, application_id, tmp); + // if (R_SUCCEEDED(rc) && out) *out = tmp & 1; + //} + const inData = new BigUint64Array([titleId]); + const out = new Uint8Array(1); + nsAppManSrv.dispatchInOut(1300, inData, out); + return Boolean(out[0] & 1); +} diff --git a/packages/install-title/src/types.ts b/packages/install-title/src/types.ts new file mode 100644 index 00000000..f16e97c6 --- /dev/null +++ b/packages/install-title/src/types.ts @@ -0,0 +1,48 @@ +import { NcmContentInfo, NcmContentMetaType } from '@nx.js/ncm'; +import { ArrayBufferStruct, view, u8 } from '@nx.js/util'; + +export class PackagedContentMetaHeader extends ArrayBufferStruct { + //u64 title_id; + //u32 version; + //u8 type; /* NcmContentMetaType */ + //u8 _0xd; + //u16 extended_header_size; + //u16 content_count; + //u16 content_meta_count; + //u8 attributes; + //u8 storage_id; + //u8 install_type; /* NcmContentInstallType */ + //bool comitted; + //u32 required_system_version; + //u32 _0x1c; + static sizeof = 0x20 as const; + + get titleId() { + return view(this).getBigUint64(0x0, true); + } + get version() { + return view(this).getUint32(0x8, true); + } + get type(): NcmContentMetaType { + return view(this).getUint8(0xc); + } + get extendedHeaderSize() { + return view(this).getUint16(0xe, true); + } + get contentCount() { + return view(this).getUint16(0x10, true); + } +} + +export class PackagedContentInfo extends ArrayBufferStruct { + //u8 hash[0x20]; + //NcmContentInfo content_info; + static sizeof = 0x38 as const; + + get hash() { + return u8(this).subarray(0x0, 0x20); + } + get contentInfo() { + return new NcmContentInfo(this, 0x20); + } +} diff --git a/packages/install-title/tsconfig.json b/packages/install-title/tsconfig.json new file mode 100644 index 00000000..60215a01 --- /dev/null +++ b/packages/install-title/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "outDir": "dist", + "target": "es2022", + "declaration": true, + "sourceMap": true, + "moduleResolution": "Bundler", + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": [ + "@nx.js/runtime" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/install-title/typedoc.json b/packages/install-title/typedoc.json new file mode 100644 index 00000000..4ac055f7 --- /dev/null +++ b/packages/install-title/typedoc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": ["../../typedoc.json"], + "name": "@nx.js/install-title", + "entryPoints": ["./src/index.ts"], + "out": "docs" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c0fcbf6..a37db265 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,6 +570,25 @@ importers: specifier: github:TooTallNate/kleur#rgb version: github.com/TooTallNate/kleur/aa22b05da68d29ff6d2ce5eba5895ac654b7f25a + packages/install-title: + dependencies: + '@nx.js/constants': + specifier: ^0.4.0 + version: link:../constants + '@nx.js/ncm': + specifier: ^1.0.0 + version: link:../ncm + '@nx.js/util': + specifier: ^0.0.1 + version: link:../util + '@tootallnate/nsp': + specifier: ^0.0.2 + version: 0.0.2 + devDependencies: + '@nx.js/runtime': + specifier: workspace:* + version: link:../runtime + packages/ncm: dependencies: '@nx.js/constants': @@ -3815,6 +3834,10 @@ packages: '@tootallnate/nacp': 0.2.0 dev: false + /@tootallnate/nsp@0.0.2: + resolution: {integrity: sha512-z5rPb6sMFwidTpLw05VHX/Hu1yUGvKXhQd+990nbcAPwGOzMkdSToETQ/4jSzyDoke1FsPyLdpSYlUuXWIEuDg==} + dev: false + /@tootallnate/romfs@0.1.0: resolution: {integrity: sha512-ZXEgARulK9g0wLgL2mrsxOyAfcI0pAVSi+0UUs1B3lfbBv7qaltMe37YTmn8Aa8BvJ/sc+XqTI5aA7U6NX/nRg==} dev: false