fix(runtime): make production commands executable
This commit is contained in:
@@ -7,16 +7,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run --root ../.. apps/api/src",
|
"test": "vitest run --root ../.. apps/api/src",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"canary": "node --experimental-strip-types src/canary-cli.ts",
|
"canary": "tsx src/canary-cli.ts",
|
||||||
"start:production": "node --experimental-strip-types src/production-cli.ts",
|
"start:production": "tsx src/production-cli.ts",
|
||||||
"migration": "node --experimental-strip-types src/application/legacy-import/migration-command.ts"
|
"migration": "tsx src/application/legacy-import/migration-command.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@multi-simadmin/contracts": "workspace:*",
|
"@multi-simadmin/contracts": "workspace:*",
|
||||||
"@multi-simadmin/operation-registry": "workspace:*",
|
"@multi-simadmin/operation-registry": "workspace:*",
|
||||||
"better-sqlite3": "12.11.1",
|
"better-sqlite3": "12.11.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"fastify": "5.10.0"
|
"fastify": "5.10.0",
|
||||||
|
"tsx": "4.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export interface LegacyImportResult {
|
|||||||
|
|
||||||
export type LegacyImportTransactionHook = (result: LegacyImportResult) => void;
|
export type LegacyImportTransactionHook = (result: LegacyImportResult) => void;
|
||||||
|
|
||||||
interface NormalizedInstance extends Omit<LegacyImportInstancePreview, 'status'> {}
|
type NormalizedInstance = Omit<LegacyImportInstancePreview, 'status'>;
|
||||||
|
|
||||||
interface SourceRead {
|
interface SourceRead {
|
||||||
readonly bytes: Buffer;
|
readonly bytes: Buffer;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createCanaryGateway } from './canary-gateway.ts';
|
import { createCanaryGateway } from './canary-gateway.js';
|
||||||
import { readCanaryRuntimeOptions } from './canary-runtime.ts';
|
import { readCanaryRuntimeOptions } from './canary-runtime.js';
|
||||||
|
|
||||||
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
|
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(import.meta.dirname, '../../..');
|
||||||
|
const gatewayToken = 'synthetic-runtime-test-token-at-least-32-bytes';
|
||||||
|
const cleanup: Array<() => Promise<void>> = [];
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
interface RunningCommand {
|
||||||
|
readonly child: ChildProcessWithoutNullStreams;
|
||||||
|
output(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPackageCommand(
|
||||||
|
script: 'canary' | 'start:production',
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
) {
|
||||||
|
const child = spawn('corepack', ['pnpm', '--filter', '@multi-simadmin/api', 'run', script], {
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: { ...process.env, ...environment, FORCE_COLOR: '0' },
|
||||||
|
detached: true,
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
||||||
|
const command: RunningCommand = { child, output: () => output };
|
||||||
|
cleanup.push(() => stopCommand(command));
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopCommand(command: RunningCommand): Promise<void> {
|
||||||
|
if (command.child.exitCode !== null || command.child.signalCode !== null) return;
|
||||||
|
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
|
||||||
|
command.child.once('exit', (code, signal) => resolveExit({ code, signal })),
|
||||||
|
);
|
||||||
|
if (command.child.pid === undefined) throw new Error('Runtime command has no pid');
|
||||||
|
process.kill(-command.child.pid, 'SIGTERM');
|
||||||
|
const result = await Promise.race([
|
||||||
|
exit,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`Runtime did not stop after SIGTERM:\n${command.output()}`)),
|
||||||
|
5_000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
// pnpm's wrapper reports 143 when its child handles the group SIGTERM cleanly.
|
||||||
|
expect(result.code === 0 || result.code === 143 || result.signal === 'SIGTERM').toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForOutput(command: RunningCommand, text: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
while (!command.output().includes(text)) {
|
||||||
|
if (command.child.exitCode !== null || command.child.signalCode !== null) {
|
||||||
|
throw new Error(`Runtime exited before becoming ready:\n${command.output()}`);
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline)
|
||||||
|
throw new Error(`Runtime readiness timed out:\n${command.output()}`);
|
||||||
|
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listenOn(port: number): Promise<ReturnType<typeof createServer>> {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise<void>((resolveListen, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(port, '127.0.0.1', resolveListen);
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
||||||
|
await new Promise<void>((resolveClose, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolveClose())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPortFree(port: number): Promise<void> {
|
||||||
|
const server = await listenOn(port);
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listenerPids(port: number): Promise<readonly string[]> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
||||||
|
return stdout.trim().split(/\s+/u).filter(Boolean).sort();
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: unknown }).code;
|
||||||
|
if (code === 1 || code === '1') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(cleanup.splice(0).map((fn) => fn()));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential('executable package runtimes', () => {
|
||||||
|
it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => {
|
||||||
|
await expectPortFree(8790);
|
||||||
|
const legacyListeners = await listenerPids(8788);
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-'));
|
||||||
|
cleanup.push(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const dataRoot = join(directory, 'data');
|
||||||
|
await mkdir(dataRoot);
|
||||||
|
|
||||||
|
const command = startPackageCommand('start:production', {
|
||||||
|
MULTI_SIMADMIN_DATA_ROOT: dataRoot,
|
||||||
|
MULTI_SIMADMIN_DATABASE_PATH: join(dataRoot, 'synthetic.sqlite'),
|
||||||
|
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
|
||||||
|
API_HOST: '127.0.0.1',
|
||||||
|
API_PORT: '8790',
|
||||||
|
});
|
||||||
|
await waitForOutput(command, 'API listening at http://127.0.0.1:8790');
|
||||||
|
|
||||||
|
const response = await fetch('http://127.0.0.1:8790/healthz', {
|
||||||
|
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ status: 'ok' });
|
||||||
|
expect(await listenerPids(8788)).toEqual(legacyListeners);
|
||||||
|
expect(command.output()).not.toContain(':8788');
|
||||||
|
expect(command.output()).not.toContain(gatewayToken);
|
||||||
|
|
||||||
|
await stopCommand(command);
|
||||||
|
await expectPortFree(8790);
|
||||||
|
expect(command.output()).not.toContain(gatewayToken);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => {
|
||||||
|
const port = 8789;
|
||||||
|
await expectPortFree(port);
|
||||||
|
const legacyListeners = await listenerPids(8788);
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-'));
|
||||||
|
cleanup.push(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
await writeFile(join(directory, 'index.html'), '<main>synthetic canary</main>');
|
||||||
|
|
||||||
|
const command = startPackageCommand('canary', {
|
||||||
|
CANARY_DIST_DIR: directory,
|
||||||
|
CANARY_PORT: String(port),
|
||||||
|
CANARY_UPSTREAM_PORT: '8790',
|
||||||
|
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
|
||||||
|
});
|
||||||
|
await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`);
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe('<main>synthetic canary</main>');
|
||||||
|
expect(await listenerPids(8788)).toEqual(legacyListeners);
|
||||||
|
expect(command.output()).not.toContain(':8788');
|
||||||
|
expect(command.output()).not.toContain(gatewayToken);
|
||||||
|
|
||||||
|
await stopCommand(command);
|
||||||
|
await expectPortFree(port);
|
||||||
|
expect(command.output()).not.toContain(gatewayToken);
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildProductionControlPlane } from './production-control-plane.ts';
|
import { buildProductionControlPlane } from './production-control-plane.js';
|
||||||
import { createRuntimeConfig } from './runtime-config.ts';
|
import { createRuntimeConfig } from './runtime-config.js';
|
||||||
import { installBoundedShutdown } from './shutdown.ts';
|
import { installBoundedShutdown } from './shutdown.js';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = createRuntimeConfig(process.env);
|
const config = createRuntimeConfig(process.env);
|
||||||
@@ -8,7 +8,7 @@ try {
|
|||||||
databasePath: config.databasePath,
|
databasePath: config.databasePath,
|
||||||
gatewayToken: config.gatewayToken,
|
gatewayToken: config.gatewayToken,
|
||||||
});
|
});
|
||||||
await app.listen(config.api);
|
await app.listen({ host: config.api.host, port: config.api.port });
|
||||||
installBoundedShutdown(process, app, {
|
installBoundedShutdown(process, app, {
|
||||||
terminate: () => process.exit(1),
|
terminate: () => process.exit(1),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,5 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"]
|
||||||
"exclude": ["src/canary-cli.ts", "src/production-cli.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+28
-12
@@ -32,7 +32,7 @@ importers:
|
|||||||
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
|
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.0
|
specifier: 4.1.0
|
||||||
version: 4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3))
|
version: 4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -51,6 +51,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: 5.10.0
|
specifier: 5.10.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
|
tsx:
|
||||||
|
specifier: 4.22.4
|
||||||
|
version: 4.22.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/better-sqlite3':
|
'@types/better-sqlite3':
|
||||||
specifier: 7.6.13
|
specifier: 7.6.13
|
||||||
@@ -85,13 +88,13 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.7)
|
version: 19.2.3(@types/react@19.2.7)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: 5.1.1
|
specifier: 5.1.1
|
||||||
version: 5.1.1(vite@7.3.5(@types/node@24.13.3))
|
version: 5.1.1(vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4))
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: 27.2.0
|
specifier: 27.2.0
|
||||||
version: 27.2.0
|
version: 27.2.0
|
||||||
vite:
|
vite:
|
||||||
specifier: 7.3.5
|
specifier: 7.3.5
|
||||||
version: 7.3.5(@types/node@24.13.3)
|
version: 7.3.5(@types/node@24.13.3)(tsx@4.22.4)
|
||||||
|
|
||||||
packages/contracts: {}
|
packages/contracts: {}
|
||||||
|
|
||||||
@@ -1974,6 +1977,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
tsx@4.22.4:
|
||||||
|
resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||||
|
|
||||||
@@ -2907,7 +2915,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.53.0
|
'@typescript-eslint/types': 8.53.0
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.1.1(vite@7.3.5(@types/node@24.13.3))':
|
'@vitejs/plugin-react@5.1.1(vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
|
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
|
||||||
@@ -2915,7 +2923,7 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.0-beta.47
|
'@rolldown/pluginutils': 1.0.0-beta.47
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.18.0
|
react-refresh: 0.18.0
|
||||||
vite: 7.3.5(@types/node@24.13.3)
|
vite: 7.3.5(@types/node@24.13.3)(tsx@4.22.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -2928,13 +2936,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3))':
|
'@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.0
|
'@vitest/spy': 4.1.0
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.6(@types/node@24.13.3)
|
vite: 7.3.6(@types/node@24.13.3)(tsx@4.22.4)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.0':
|
'@vitest/pretty-format@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3924,6 +3932,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
tsx@4.22.4:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.28.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -3962,7 +3976,7 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite@7.3.5(@types/node@24.13.3):
|
vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.7
|
esbuild: 0.27.7
|
||||||
fdir: 6.5.0(picomatch@4.0.5)
|
fdir: 6.5.0(picomatch@4.0.5)
|
||||||
@@ -3973,8 +3987,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
tsx: 4.22.4
|
||||||
|
|
||||||
vite@7.3.6(@types/node@24.13.3):
|
vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
fdir: 6.5.0(picomatch@4.0.5)
|
fdir: 6.5.0(picomatch@4.0.5)
|
||||||
@@ -3985,11 +4000,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
tsx: 4.22.4
|
||||||
|
|
||||||
vitest@4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)):
|
vitest@4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.0
|
'@vitest/expect': 4.1.0
|
||||||
'@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3))
|
'@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))
|
||||||
'@vitest/pretty-format': 4.1.0
|
'@vitest/pretty-format': 4.1.0
|
||||||
'@vitest/runner': 4.1.0
|
'@vitest/runner': 4.1.0
|
||||||
'@vitest/snapshot': 4.1.0
|
'@vitest/snapshot': 4.1.0
|
||||||
@@ -4006,7 +4022,7 @@ snapshots:
|
|||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 7.3.6(@types/node@24.13.3)
|
vite: 7.3.6(@types/node@24.13.3)(tsx@4.22.4)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
|||||||
Reference in New Issue
Block a user