feat: switched from worker API to fs based

This commit is contained in:
Morten Olsen
2024-01-12 14:14:40 +01:00
parent 6d8e5bf955
commit 59d6faaafc
38 changed files with 458 additions and 67 deletions

View File

@@ -15,14 +15,19 @@ npm install -g @morten-olsen/mini-loader-cli
Now, let's write a basic script that outputs a single artifact named “hello”. Create a new file with the following JavaScript code:
```javascript
import { artifacts } from "@morten-olsen/mini-loader";
import { artifacts } from '@morten-olsen/mini-loader';
artifacts.create('hello', 'world');
const run = async () => {
artifacts.create('hello', 'world');
};
run();
```
Save this file as `script.mjs`.
Save this file as `script.js`.
#### A Note on Dependencies
In this script, we're using the `@morten-olsen/mini-loader` package, which might not be installed in your local environment. No worries though, as mini loader can automatically download necessary packages when preparing the script. Alternatively, for a more structured approach (especially if you're using TypeScript), you can initialize a Node.js project and install the dependencies for complete access to typings.
### Step 3: Run the Script Locally
@@ -30,7 +35,7 @@ In this script, we're using the `@morten-olsen/mini-loader` package, which might
To validate that your script is functioning correctly, execute it locally using the following command:
```bash
mini-loader local run script.mjs -ai
mini-loader local run script.js -ai
```
The `-ai` flag instructs the CLI to automatically download any referenced packages when bundling the script.
@@ -41,4 +46,4 @@ After running the command, you should see an output confirming that a new artifa
Congratulations on setting up and running your first script with mini loader! You're now ready to take the next step.
[Next: Setting Up the Server](./setup-server.md)
[Next: Setting Up the Server](./setup-server.md)

View File

@@ -58,7 +58,7 @@ mini-loader artifacts ls
To download a specific artifact:
```bash
mini-loader artifacts pull <id> > myfile.txt
mini-loader artifacts pull <id> myfile.txt
```
Replace `<id>` with the identifier of the artifact you wish to download.
@@ -67,4 +67,4 @@ Replace `<id>` with the identifier of the artifact you wish to download.
You're now equipped to manage loads, runs, logs, and artifacts using the mini loader CLI. For advanced usage, such as managing secrets, proceed to the next section.
[Next: Managing Secrets](./managing-secrets.md)
[Next: Managing Secrets](./managing-secrets.md)

View File

@@ -27,6 +27,7 @@
"@rollup/plugin-sucrase": "^5.0.2",
"@trpc/client": "^10.45.0",
"commander": "^11.1.0",
"env-paths": "^3.0.0",
"inquirer": "^9.2.12",
"ora": "^8.0.1",
"rollup": "^4.9.4",

View File

@@ -2,13 +2,20 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { Runtime } from '@morten-olsen/mini-loader-server';
import type { RootRouter } from '@morten-olsen/mini-loader-server';
import { Context } from '../context/context.js';
const createClient = () => {
const createClient = (context: Context) => {
if (!context.host || !context.token) {
throw new Error('Not signed in');
}
const client = createTRPCProxyClient<RootRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:4500/trpc',
url: `${context.host}/trpc`,
headers: {
authorization: `Bearer ${context.token}`,
},
}),
],
});

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const list = new Command('list');
@@ -20,8 +21,9 @@ list
.option('-a, --limit <limit>', 'Limit', '1000')
.action(async () => {
const { runId, loadId, offset, limit } = list.opts();
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
const artifacts = await step('Getting artifacts', async () => {
return await client.artifacts.find.query({

View File

@@ -0,0 +1,32 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
import { dirname, resolve } from 'path';
import { mkdir, writeFile } from 'fs/promises';
const pull = new Command('pull');
pull
.description('Download artifact')
.argument('<artifact-id>', 'Artifact ID')
.argument('<file>', 'File to save')
.action(async (id, file) => {
const context = new Context();
const target = resolve(file);
const client = await step('Connecting to server', async () => {
return createClient(context);
});
const artifact = await step('Getting artifact', async () => {
const result = await client.artifacts.get.query(id);
if (!result) {
throw new Error('Artifact not found');
}
return result;
});
await mkdir(dirname(target), { recursive: true });
const data = Buffer.from(artifact.data, 'base64').toString('utf-8');
await writeFile(target, data, 'utf-8');
});
export { pull };

View File

@@ -0,0 +1,59 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
import inquirer from 'inquirer';
const remove = new Command('remove');
const toInt = (value?: string) => {
if (!value) {
return undefined;
}
return parseInt(value, 10);
};
remove
.alias('ls')
.description('List logs')
.option('-r, --run-id <runId>', 'Run ID')
.option('-l, --load-id <loadId>', 'Load ID')
.option('-o, --offset <offset>', 'Offset')
.option('-a, --limit <limit>', 'Limit', '1000')
.action(async () => {
const { runId, loadId, offset, limit } = remove.opts();
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient(context);
});
const response = await step('Preparing to delete', async () => {
return await client.artifacts.prepareRemove.query({
runId,
loadId,
offset: toInt(offset),
limit: toInt(limit),
});
});
if (!response.ids.length) {
console.log('No logs to delete');
return;
}
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to delete ${response.ids.length} logs?`,
},
]);
if (!confirm) {
return;
}
await step('Deleting artifacts', async () => {
await client.artifacts.remove.mutate(response);
});
});
export { remove };

View File

@@ -1,7 +1,11 @@
import { Command } from 'commander';
import { list } from './artifacts.list.js';
import { remove } from './artifacts.remove.js';
import { pull } from './artifacts.pull.js';
const artifacts = new Command('artifacts');
artifacts.addCommand(list);
artifacts.addCommand(remove);
artifacts.addCommand(pull);
export { artifacts };

View File

@@ -1,16 +1,19 @@
import { Command } from 'commander';
import inquerer from 'inquirer';
import { Context } from '../../context/context.js';
import { step } from '../../utils/step.js';
const login = new Command('login');
login.description('Login to your account');
login.action(async () => {
const context = new Context();
const { host, token } = await inquerer.prompt([
{
type: 'input',
name: 'host',
message: 'Enter the host of your server',
default: 'http://localhost:4500',
default: context.host ?? 'http://localhost:4500',
},
{
type: 'password',
@@ -19,7 +22,25 @@ login.action(async () => {
},
]);
console.log(host, token);
const healthResponse = await step('Getting auth status', async () => {
return await fetch(`${host}/health`, {
headers: {
authorization: `Bearer ${token}`,
},
});
});
if (!healthResponse.ok) {
throw new Error('Invalid token');
}
const health = await healthResponse.json();
if (!health.authorized) {
throw new Error('Invalid token');
}
await step('Saving login', async () => {
await context.saveLogin(host, token);
});
});
export { login };

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const list = new Command('list');
@@ -8,11 +9,12 @@ list
.alias('ls')
.description('List loads')
.action(async () => {
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
const loads = step('Getting data', async () => {
await client.loads.find.query({});
const loads = await step('Getting data', async () => {
return await client.loads.find.query({});
});
console.table(loads);
});

View File

@@ -3,6 +3,7 @@ import { resolve } from 'path';
import { createClient } from '../../client/client.js';
import { bundle } from '../../bundler/bundler.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const push = new Command('push');
@@ -14,9 +15,10 @@ push
.option('-ai, --auto-install', 'Auto install dependencies', false)
.action(async (script) => {
const opts = push.opts();
const context = new Context();
const location = resolve(script);
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
const code = await step('Bundling', async () => {
return await bundle({ entry: location, autoInstall: opts.autoInstall });

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const list = new Command('list');
@@ -22,8 +23,9 @@ list
.option('-s, --sort <order>', 'Sort', 'desc')
.action(async () => {
const { runId, loadId, severities, offset, limit, order } = list.opts();
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
const logs = await step('Getting logs', async () => {
return await client.logs.find.query({
@@ -35,7 +37,7 @@ list
order,
});
});
console.table(logs.reverse());
console.table(logs);
});
export { list };

View File

@@ -0,0 +1,63 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
import inquirer from 'inquirer';
const remove = new Command('remove');
const toInt = (value?: string) => {
if (!value) {
return undefined;
}
return parseInt(value, 10);
};
remove
.alias('ls')
.description('List logs')
.option('-r, --run-id <runId>', 'Run ID')
.option('-l, --load-id <loadId>', 'Load ID')
.option('--severities <severities...>', 'Severities')
.option('-o, --offset <offset>', 'Offset')
.option('-a, --limit <limit>', 'Limit', '1000')
.option('-s, --sort <order>', 'Sort', 'desc')
.action(async () => {
const { runId, loadId, severities, offset, limit, order } = remove.opts();
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient(context);
});
const response = await step('Preparing to delete', async () => {
return await client.logs.prepareRemove.query({
runId,
loadId,
severities,
offset: toInt(offset),
limit: toInt(limit),
order,
});
});
if (!response.ids.length) {
console.log('No logs to delete');
return;
}
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to delete ${response.ids.length} logs?`,
},
]);
if (!confirm) {
return;
}
await step('Deleting logs', async () => {
await client.logs.remove.mutate(response);
});
});
export { remove };

View File

@@ -1,7 +1,9 @@
import { Command } from 'commander';
import { list } from './logs.list.js';
import { remove } from './logs.remove.js';
const logs = new Command('logs');
logs.addCommand(list);
logs.addCommand(remove);
export { logs };

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const create = new Command('create');
@@ -8,8 +9,9 @@ create
.description('Create a new run')
.argument('load-id', 'Load ID')
.action(async (loadId) => {
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
await step('Creating run', async () => {
await client.runs.create.mutate({ loadId });

View File

@@ -1,16 +1,18 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const list = new Command('create');
const list = new Command('list');
list
.alias('ls')
.description('Find a run')
.argument('[load-id]', 'Load ID')
.action(async (loadId) => {
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
const runs = await step('Getting runs', async () => {
return await client.runs.find.query({ loadId });

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const list = new Command('list');
@@ -18,8 +19,9 @@ list
.option('-a, --limit <limit>', 'Limit', '1000')
.action(async () => {
const { offset, limit } = list.opts();
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
const secrets = await step('Getting secrets', async () => {
return await client.secrets.find.query({

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const remove = new Command('remove');
@@ -8,8 +9,9 @@ remove
.alias('rm')
.argument('<id>')
.action(async (id) => {
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
await step('Removing', async () => {
await client.secrets.remove.mutate({

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander';
import { createClient } from '../../client/client.js';
import { step } from '../../utils/step.js';
import { Context } from '../../context/context.js';
const set = new Command('set');
@@ -8,8 +9,9 @@ set
.argument('<id>')
.argument('[value]')
.action(async (id, value) => {
const context = new Context();
const client = await step('Connecting to server', async () => {
return createClient();
return createClient(context);
});
await step('Setting secret', async () => {
await client.secrets.set.mutate({

View File

@@ -0,0 +1,50 @@
import envPaths from 'env-paths';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
type ContextValues = {
host: string;
token: string;
};
class Context {
#location: string;
#config?: ContextValues;
constructor() {
const paths = envPaths('dws');
this.#location = paths.config;
if (existsSync(this.#location)) {
this.#config = JSON.parse(readFileSync(this.#location, 'utf-8'));
}
}
public get host() {
return this.#config?.host;
}
public get token() {
return this.#config?.token;
}
public saveLogin = (host: string, token: string) => {
this.#config = {
...(this.#config || {}),
host,
token,
};
this.save();
};
public save = async () => {
if (!this.#config) {
return;
}
const json = JSON.stringify(this.#config);
mkdir(dirname(this.#location), { recursive: true });
writeFileSync(this.#location, json);
};
}
export { Context };

View File

@@ -4,10 +4,10 @@ const step = async <T>(message: string, fn: () => Promise<T>): Promise<T> => {
const spinner = ora(message).start();
try {
const result = await fn();
spinner.succeed();
await spinner.succeed();
return result;
} catch (err) {
spinner.fail();
await spinner.fail();
throw err;
}
};

View File

@@ -1,5 +1,8 @@
import { artifacts, logger } from '@morten-olsen/mini-loader';
logger.info('Hello world');
const run = async () => {
await logger.info('Hello world');
await artifacts.create('foo', 'bar');
};
artifacts.create('foo', 'bar');
run();

View File

@@ -8,8 +8,8 @@ type ArtifactCreateEvent = {
};
};
const create = (name: string, data: Buffer | string) => {
send({
const create = async (name: string, data: Buffer | string) => {
await send({
type: 'artifact:create',
payload: {
name,

View File

@@ -1,7 +1,14 @@
import { workerData } from 'worker_threads';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
const get = <T>() => {
return workerData as T;
const path = process.env.INPUT_PATH;
const hasInput = path ? existsSync(path) : false;
const get = () => {
if (!hasInput || !path) {
return undefined;
}
return readFile(path, 'utf-8');
};
const input = {

View File

@@ -9,31 +9,31 @@ type LoggerEvent = {
};
};
const sendLog = (event: LoggerEvent['payload']) => {
send({
const sendLog = async (event: LoggerEvent['payload']) => {
await send({
type: 'log',
payload: event,
});
};
const info = (message: string, data?: unknown) => {
sendLog({
const info = async (message: string, data?: unknown) => {
await sendLog({
severity: 'info',
message,
data,
});
};
const warn = (message: string, data?: unknown) => {
sendLog({
const warn = async (message: string, data?: unknown) => {
await sendLog({
severity: 'warning',
message,
data,
});
};
const error = (message: string, data?: unknown) => {
sendLog({
const error = async (message: string, data?: unknown) => {
await sendLog({
severity: 'error',
message,
data,

View File

@@ -1,8 +1,7 @@
import { workerData } from 'worker_threads';
const secretData = JSON.parse(process.env.SECRETS || '{}');
const get = (id: string) => {
const items = workerData?.secrets ?? {};
return items[id];
return secretData[id];
};
const secrets = {

View File

@@ -1,8 +1,28 @@
import { parentPort } from 'worker_threads';
import { Socket, createConnection } from 'net';
const send = (data: any) => {
const cleaned = JSON.parse(JSON.stringify(data));
parentPort?.postMessage(cleaned);
};
const connect = () =>
new Promise<Socket>((resolve, reject) => {
const current = createConnection(process.env.HOST_SOCKET!);
current.on('connect', () => {
resolve(current);
});
current.on('error', (error) => {
reject(error);
});
});
const send = async (data: any) =>
new Promise<void>(async (resolve, reject) => {
const connection = await connect();
const cleaned = JSON.parse(JSON.stringify(data));
connection.write(JSON.stringify(cleaned), 'utf-8', (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
export { send };

View File

@@ -16,12 +16,13 @@
}
},
"devDependencies": {
"@morten-olsen/mini-loader-configs": "workspace:^",
"@morten-olsen/mini-loader": "workspace:^",
"@morten-olsen/mini-loader-configs": "workspace:^",
"@types/node": "^20.10.8",
"typescript": "^5.3.3"
},
"dependencies": {
"eventemitter3": "^5.0.1"
"eventemitter3": "^5.0.1",
"nanoid": "^5.0.4"
}
}

View File

@@ -1,6 +1,11 @@
import { Worker } from 'worker_threads';
import os from 'os';
import { EventEmitter } from 'eventemitter3';
import { Event } from '@morten-olsen/mini-loader';
import { join } from 'path';
import { createServer } from 'http';
import { nanoid } from 'nanoid';
import { chmod, mkdir, rm, writeFile } from 'fs/promises';
type RunEvents = {
message: (event: Event) => void;
@@ -10,29 +15,75 @@ type RunEvents = {
type RunOptions = {
script: string;
input?: unknown;
input?: Buffer | string;
secrets?: Record<string, string>;
};
const run = async ({ script, input, secrets }: RunOptions) => {
const dataDir = join(os.tmpdir(), 'mini-loader', nanoid());
await mkdir(dataDir, { recursive: true });
await chmod(dataDir, 0o700);
const hostSocket = join(dataDir, 'host');
const server = createServer();
const inputLocation = join(dataDir, 'input');
if (input) {
await writeFile(inputLocation, input);
}
const emitter = new EventEmitter<RunEvents>();
server.on('connection', (socket) => {
socket.on('data', (data) => {
const message = JSON.parse(data.toString());
emitter.emit('message', message);
});
});
server.listen(hostSocket);
const worker = new Worker(script, {
eval: true,
env: secrets,
stdin: false,
stdout: false,
stderr: false,
env: {
HOST_SOCKET: hostSocket,
SECRETS: JSON.stringify(secrets),
INPUT_PATH: inputLocation,
},
workerData: {
input,
secrets,
},
});
const promise = new Promise<void>((resolve, reject) => {
worker.on('message', (message: Event) => {
emitter.emit('message', message);
worker.stdout?.on('data', (data) => {
emitter.emit('message', {
type: 'log',
payload: {
severity: 'info',
message: data.toString(),
},
});
worker.on('exit', () => {
});
worker.stderr?.on('data', (data) => {
emitter.emit('message', {
type: 'log',
payload: {
severity: 'error',
message: data.toString(),
},
});
});
const promise = new Promise<void>((resolve, reject) => {
worker.on('exit', async () => {
server.close();
await rm(dataDir, { recursive: true, force: true });
resolve();
});
worker.on('error', (error) => {
worker.on('error', async (error) => {
server.close();
reject(error);
});
});

View File

@@ -27,5 +27,9 @@ program.addCommand(createToken);
await program.parseAsync(process.argv);
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
export type { Runtime } from './runtime/runtime.js';
export type { RootRouter } from './router/router.js';

View File

@@ -18,6 +18,13 @@ class ArtifactRepo extends EventEmitter<ArtifactRepoEvents> {
this.#options = options;
}
public get = async (id: string) => {
const { database } = this.#options;
const db = await database.instance;
const result = await db('artifacts').where({ id }).first();
return result || null;
};
public add = async (options: AddArtifactOptions) => {
const { database } = this.#options;
const db = await database.instance;
@@ -59,8 +66,9 @@ class ArtifactRepo extends EventEmitter<ArtifactRepoEvents> {
query.limit(options.limit);
}
const ids = await query;
const token = ids.map((id) => Buffer.from(id.id).toString('base64')).join('|');
const result = await query;
const ids = result.map((row) => row.id);
const token = ids.map((id) => Buffer.from(id).toString('base64')).join('|');
const hash = createHash('sha256').update(token).digest('hex');
return {
ids,

View File

@@ -56,8 +56,9 @@ class LogRepo extends EventEmitter<LogRepoEvents> {
query.whereIn('severity', options.severities);
}
const ids = await query;
const token = ids.map((id) => Buffer.from(id.id).toString('base64')).join('|');
const result = await query;
const ids = result.map((row) => row.id);
const token = ids.map((id) => Buffer.from(id).toString('base64')).join('|');
const hash = createHash('sha256').update(token).digest('hex');
return {
ids,

View File

@@ -11,12 +11,21 @@ const find = publicProcedure.input(findArtifactsSchema).query(async ({ input, ct
return result;
});
const get = publicProcedure.input(z.string()).query(async ({ input, ctx }) => {
const { runtime } = ctx;
const { repos } = runtime;
const { artifacts } = repos;
const result = await artifacts.get(input);
return result;
});
const prepareRemove = publicProcedure.input(findArtifactsSchema).query(async ({ input, ctx }) => {
const { runtime } = ctx;
const { repos } = runtime;
const { artifacts } = repos;
await artifacts.prepareRemove(input);
return await artifacts.prepareRemove(input);
});
const remove = publicProcedure
@@ -35,6 +44,7 @@ const remove = publicProcedure
});
const artifactsRouter = router({
get,
find,
remove,
prepareRemove,

View File

@@ -16,7 +16,7 @@ const prepareRemove = publicProcedure.input(findLogsSchema).query(async ({ input
const { repos } = runtime;
const { logs } = repos;
await logs.prepareRemove(input);
return await logs.prepareRemove(input);
});
const remove = publicProcedure

View File

@@ -14,7 +14,8 @@ const createContext = async ({ runtime }: ContextOptions) => {
if (!authorization) {
throw new Error('No authorization header');
}
await auth.validateToken(authorization);
const [, token] = authorization.split(' ');
await auth.validateToken(token);
return {
runtime,
};

View File

@@ -54,7 +54,7 @@ class RunnerInstance extends EventEmitter<RunnerInstanceEvents> {
const { runs, secrets } = repos;
try {
const { script: scriptHash, input } = await runs.getById(id);
const scriptLocation = resolve(config.files.location, 'script', `${scriptHash}.js`);
const scriptLocation = resolve(config.files.location, 'scripts', `${scriptHash}.js`);
const script = await readFile(scriptLocation, 'utf-8');
const allSecrets = await secrets.getAll();
await runs.started(id);

View File

@@ -10,6 +10,19 @@ const createServer = async (runtime: Runtime) => {
return { hello: 'world' };
});
server.get('/health', async (req) => {
let authorized = false;
try {
const { authorization } = req.headers;
if (authorization) {
const [, token] = authorization.split(' ');
await runtime.auth.validateToken(token);
authorized = true;
}
} catch (error) {}
return { authorized, status: 'ok' };
});
server.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {

11
pnpm-lock.yaml generated
View File

@@ -60,6 +60,9 @@ importers:
commander:
specifier: ^11.1.0
version: 11.1.0
env-paths:
specifier: ^3.0.0
version: 3.0.0
inquirer:
specifier: ^9.2.12
version: 9.2.12
@@ -132,6 +135,9 @@ importers:
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
nanoid:
specifier: ^5.0.4
version: 5.0.4
devDependencies:
'@morten-olsen/mini-loader':
specifier: workspace:^
@@ -2206,6 +2212,11 @@ packages:
dev: false
optional: true
/env-paths@3.0.0:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/err-code@2.0.3:
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
requiresBuild: true