@inbrx/cli 0.0.1-alpha.3 → 0.0.1-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @inbrx/cli
2
+
3
+ CLI for capturing local SMTP mail with inbrx. Start it, point your application at the local SMTP endpoint, and inspect captured emails in the browser.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx @inbrx/cli
9
+ ```
10
+
11
+ Defaults:
12
+
13
+ - SMTP server: `127.0.0.1:2525`
14
+ - Web UI: `http://127.0.0.1:3000`
15
+ - Storage: file-backed, using the standard user data directory for your operating system
16
+
17
+ Use memory-only storage for throwaway sessions:
18
+
19
+ ```bash
20
+ npx @inbrx/cli --storage memory
21
+ ```
22
+
23
+ ## Options
24
+
25
+ ```bash
26
+ inbrx start \
27
+ --smtp-host 127.0.0.1 \
28
+ --smtp-port 2525 \
29
+ --smtp-starttls \
30
+ --smtp-tls-key ./smtp.key \
31
+ --smtp-tls-cert ./smtp.crt \
32
+ --http-host 127.0.0.1 \
33
+ --http-port 3000 \
34
+ --max-messages 500 \
35
+ --storage file
36
+ ```
37
+
38
+ Environment variables:
39
+
40
+ - `SMTP_TEST_SMTP_HOST`
41
+ - `SMTP_TEST_SMTP_PORT`
42
+ - `INBRX_SMTP_STARTTLS=true|false`
43
+ - `INBRX_SMTP_TLS_KEY`
44
+ - `INBRX_SMTP_TLS_CERT`
45
+ - `SMTP_TEST_HTTP_HOST`
46
+ - `SMTP_TEST_HTTP_PORT`
47
+ - `SMTP_TEST_MAX_MESSAGES`
48
+ - `INBRX_STORAGE=file|memory`
49
+
50
+ ## STARTTLS
51
+
52
+ Enable STARTTLS with an automatically generated in-memory self-signed certificate:
53
+
54
+ ```bash
55
+ inbrx start --smtp-starttls
56
+ ```
57
+
58
+ Use your own certificate by providing both paths. Providing both paths also enables STARTTLS:
59
+
60
+ ```bash
61
+ inbrx start --smtp-tls-key ./smtp.key --smtp-tls-cert ./smtp.crt
62
+ ```
63
+
64
+ Self-signed certificates usually require test clients to disable certificate verification:
65
+
66
+ ```bash
67
+ swaks --to recipient@example.com \
68
+ --from sender@example.com \
69
+ --server 127.0.0.1 \
70
+ --port 2525 \
71
+ --tls \
72
+ --tls-verify false \
73
+ --auth LOGIN \
74
+ --auth-user sender@example.com \
75
+ --auth-password 'your_password' \
76
+ --header "Subject: test email" \
77
+ --body "Hello from SMTP"
78
+ ```
package/dist/cli.js CHANGED
@@ -19,6 +19,9 @@ export function createCliProgram() {
19
19
  .description('Start the SMTP server and HTTP API/UI.')
20
20
  .option('--smtp-host <host>', `SMTP bind host. Default: ${defaults.smtpHost}`)
21
21
  .option('--smtp-port <port>', `SMTP port. Default: ${defaults.smtpPort}`)
22
+ .option('--smtp-starttls', 'Advertise and support STARTTLS. Generates a temporary self-signed certificate unless TLS paths are provided.')
23
+ .option('--smtp-tls-key <path>', 'TLS private key path for STARTTLS. Enables STARTTLS when paired with --smtp-tls-cert.')
24
+ .option('--smtp-tls-cert <path>', 'TLS certificate path for STARTTLS. Enables STARTTLS when paired with --smtp-tls-key.')
22
25
  .option('--http-host <host>', `HTTP bind host. Default: ${defaults.httpHost}`)
23
26
  .option('--http-port <port>', `HTTP port. Default: ${defaults.httpPort}`)
24
27
  .option('--max-messages <count>', `Maximum retained messages. Default: ${defaults.maxMessages}`)
@@ -28,6 +31,9 @@ export function createCliProgram() {
28
31
  Environment:
29
32
  SMTP_TEST_SMTP_HOST
30
33
  SMTP_TEST_SMTP_PORT
34
+ INBRX_SMTP_STARTTLS
35
+ INBRX_SMTP_TLS_KEY
36
+ INBRX_SMTP_TLS_CERT
31
37
  SMTP_TEST_HTTP_HOST
32
38
  SMTP_TEST_HTTP_PORT
33
39
  SMTP_TEST_MAX_MESSAGES
@@ -42,6 +48,7 @@ async function startServer(options) {
42
48
  const app = await startApp(config);
43
49
  console.log('SMTP test server ready');
44
50
  console.log(`SMTP: smtp://${config.smtpHost}:${config.smtpPort}`);
51
+ console.log(`SMTP STARTTLS: ${config.smtpStartTls ? 'enabled' : 'disabled'}`);
45
52
  console.log(`Web UI: http://${config.httpHost}:${config.httpPort}`);
46
53
  console.log(`Storage: ${config.storage}${config.storage === 'file' ? ` (${getDefaultDataDir()})` : ''}`);
47
54
  console.log('Press Ctrl+C to stop.');
package/dist/config.js CHANGED
@@ -1,15 +1,25 @@
1
1
  const DEFAULTS = {
2
2
  smtpHost: '127.0.0.1',
3
3
  smtpPort: 2525,
4
+ smtpStartTls: false,
5
+ smtpTlsKeyPath: null,
6
+ smtpTlsCertPath: null,
4
7
  httpHost: '127.0.0.1',
5
8
  httpPort: 3000,
6
9
  maxMessages: 500,
7
10
  storage: 'file'
8
11
  };
9
12
  export function loadConfig(overrides = {}) {
13
+ const smtpTlsKeyPath = readOptionalString(overrides.smtpTlsKeyPath, process.env.INBRX_SMTP_TLS_KEY, DEFAULTS.smtpTlsKeyPath);
14
+ const smtpTlsCertPath = readOptionalString(overrides.smtpTlsCertPath, process.env.INBRX_SMTP_TLS_CERT, DEFAULTS.smtpTlsCertPath);
10
15
  const config = {
11
16
  smtpHost: readString(overrides.smtpHost, process.env.SMTP_TEST_SMTP_HOST, DEFAULTS.smtpHost),
12
17
  smtpPort: readNumber(overrides.smtpPort, process.env.SMTP_TEST_SMTP_PORT, DEFAULTS.smtpPort),
18
+ smtpStartTls: readBoolean(overrides.smtpStartTls ?? overrides.smtpStarttls, process.env.INBRX_SMTP_STARTTLS, DEFAULTS.smtpStartTls) ||
19
+ Boolean(smtpTlsKeyPath) ||
20
+ Boolean(smtpTlsCertPath),
21
+ smtpTlsKeyPath,
22
+ smtpTlsCertPath,
13
23
  httpHost: readString(overrides.httpHost, process.env.SMTP_TEST_HTTP_HOST, DEFAULTS.httpHost),
14
24
  httpPort: readNumber(overrides.httpPort, process.env.SMTP_TEST_HTTP_PORT, DEFAULTS.httpPort),
15
25
  maxMessages: readNumber(overrides.maxMessages, process.env.SMTP_TEST_MAX_MESSAGES, DEFAULTS.maxMessages),
@@ -17,6 +27,9 @@ export function loadConfig(overrides = {}) {
17
27
  };
18
28
  assertPort(config.smtpPort, 'SMTP port');
19
29
  assertPort(config.httpPort, 'HTTP port');
30
+ if (Boolean(config.smtpTlsKeyPath) !== Boolean(config.smtpTlsCertPath)) {
31
+ throw new Error('SMTP TLS key and certificate paths must be provided together.');
32
+ }
20
33
  if (!Number.isInteger(config.maxMessages) || config.maxMessages < 1) {
21
34
  throw new Error('Max messages must be a positive integer.');
22
35
  }
@@ -34,6 +47,15 @@ function readString(override, envValue, fallback) {
34
47
  }
35
48
  return fallback;
36
49
  }
50
+ function readOptionalString(override, envValue, fallback) {
51
+ if (override !== undefined && override !== null && override !== '') {
52
+ return String(override);
53
+ }
54
+ if (envValue !== undefined && envValue !== '') {
55
+ return envValue;
56
+ }
57
+ return fallback;
58
+ }
37
59
  function readNumber(override, envValue, fallback) {
38
60
  const value = override ?? envValue;
39
61
  if (value === undefined || value === '') {
@@ -45,6 +67,23 @@ function readNumber(override, envValue, fallback) {
45
67
  }
46
68
  return parsed;
47
69
  }
70
+ function readBoolean(override, envValue, fallback) {
71
+ const value = override ?? envValue;
72
+ if (value === undefined || value === null || value === '') {
73
+ return fallback;
74
+ }
75
+ if (typeof value === 'boolean') {
76
+ return value;
77
+ }
78
+ const normalized = String(value).toLowerCase();
79
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) {
80
+ return true;
81
+ }
82
+ if (['0', 'false', 'no', 'off'].includes(normalized)) {
83
+ return false;
84
+ }
85
+ throw new Error(`Expected a boolean, received "${value}".`);
86
+ }
48
87
  function assertPort(value, label) {
49
88
  if (!Number.isInteger(value) || value < 1 || value > 65535) {
50
89
  throw new Error(`${label} must be an integer between 1 and 65535.`);
@@ -0,0 +1,16 @@
1
+ export function createMailboxEvents() {
2
+ const listeners = new Set();
3
+ return {
4
+ emit(event) {
5
+ for (const listener of listeners) {
6
+ listener(event);
7
+ }
8
+ },
9
+ subscribe(listener) {
10
+ listeners.add(listener);
11
+ return () => {
12
+ listeners.delete(listener);
13
+ };
14
+ }
15
+ };
16
+ }
@@ -5,10 +5,11 @@ import { Readable } from 'node:stream';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { serve } from '@hono/node-server';
7
7
  import { Hono } from 'hono';
8
+ import { createMailboxEvents } from '../events/mailbox-events.js';
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const WEB_ROOT = path.resolve(__dirname, '../../..', 'web/dist');
10
- export function createHttpServer({ store, attachmentStore }) {
11
- const app = createHttpApp({ store, attachmentStore });
11
+ export function createHttpServer({ store, attachmentStore, events = createMailboxEvents() }) {
12
+ const app = createHttpApp({ store, attachmentStore, events });
12
13
  let server = null;
13
14
  return {
14
15
  listen(port, host) {
@@ -43,18 +44,20 @@ export function createHttpServer({ store, attachmentStore }) {
43
44
  }
44
45
  };
45
46
  }
46
- export function createHttpApp({ store, attachmentStore }) {
47
+ export function createHttpApp({ store, attachmentStore, events = createMailboxEvents() }) {
47
48
  const app = new Hono();
48
49
  app.onError((error, c) => {
49
50
  console.error(error);
50
51
  return c.json({ error: 'Internal server error' }, 500);
51
52
  });
52
53
  app.get('/api/health', (c) => c.json({ status: 'ok' }));
54
+ app.get('/api/events', (c) => sseResponse(events, c.req.raw.signal));
53
55
  app.get('/api/messages', async (c) => c.json({
54
56
  messages: (await store.list()).map(toMessageSummary)
55
57
  }));
56
58
  app.delete('/api/messages', async (c) => {
57
59
  const deleted = await store.clear();
60
+ events.emit({ type: 'messages.cleared', deleted });
58
61
  return c.json({ deleted });
59
62
  });
60
63
  app.get('/api/messages/:id', async (c) => {
@@ -84,12 +87,80 @@ export function createHttpApp({ store, attachmentStore }) {
84
87
  });
85
88
  });
86
89
  app.delete('/api/messages/:id', async (c) => {
87
- const deleted = await store.delete(c.req.param('id'));
90
+ const messageId = c.req.param('id');
91
+ const deleted = await store.delete(messageId);
92
+ if (deleted) {
93
+ events.emit({ type: 'message.deleted', id: messageId });
94
+ }
88
95
  return c.json({ deleted }, deleted ? 200 : 404);
89
96
  });
90
97
  app.all('*', (c) => serveStatic(new URL(c.req.url).pathname));
91
98
  return app;
92
99
  }
100
+ function sseResponse(events, signal) {
101
+ const encoder = new TextEncoder();
102
+ let unsubscribe = null;
103
+ let keepalive = null;
104
+ let closed = false;
105
+ const stream = new ReadableStream({
106
+ start(controller) {
107
+ const send = (event, data) => {
108
+ if (closed) {
109
+ return;
110
+ }
111
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
112
+ };
113
+ const cleanup = () => {
114
+ if (closed) {
115
+ return;
116
+ }
117
+ closed = true;
118
+ unsubscribe?.();
119
+ unsubscribe = null;
120
+ if (keepalive) {
121
+ clearInterval(keepalive);
122
+ keepalive = null;
123
+ }
124
+ };
125
+ send('ready', { version: 1 });
126
+ unsubscribe = events.subscribe((event) => {
127
+ send(event.type, eventData(event));
128
+ });
129
+ keepalive = setInterval(() => {
130
+ if (!closed) {
131
+ controller.enqueue(encoder.encode(': keepalive\n\n'));
132
+ }
133
+ }, 30000);
134
+ signal.addEventListener('abort', cleanup, { once: true });
135
+ },
136
+ cancel() {
137
+ closed = true;
138
+ unsubscribe?.();
139
+ unsubscribe = null;
140
+ if (keepalive) {
141
+ clearInterval(keepalive);
142
+ keepalive = null;
143
+ }
144
+ }
145
+ });
146
+ return new Response(stream, {
147
+ headers: {
148
+ 'content-type': 'text/event-stream; charset=utf-8',
149
+ 'cache-control': 'no-cache, no-transform',
150
+ connection: 'keep-alive'
151
+ }
152
+ });
153
+ }
154
+ function eventData(event) {
155
+ switch (event.type) {
156
+ case 'message.created':
157
+ return { id: event.id, receivedAt: event.receivedAt };
158
+ case 'message.deleted':
159
+ return { id: event.id };
160
+ case 'messages.cleared':
161
+ return { deleted: event.deleted };
162
+ }
163
+ }
93
164
  function toMessageSummary(message) {
94
165
  return {
95
166
  id: message.id,
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createMailboxEvents } from './events/mailbox-events.js';
1
2
  import { createHttpServer } from './http/server.js';
2
3
  import { createFileAttachmentStore, createMemoryAttachmentStore } from './store/attachment-store.js';
3
4
  import { getDefaultDataDir } from './store/data-dir.js';
@@ -5,9 +6,17 @@ import { createFileMessageStore } from './store/file-store.js';
5
6
  import { createMemoryStore } from './store/memory-store.js';
6
7
  import { createSmtpServer } from './smtp/server.js';
7
8
  export async function startApp(config) {
8
- const { store, attachmentStore } = createStores(config);
9
- const smtpServer = createSmtpServer({ store, attachmentStore });
10
- const httpServer = createHttpServer({ store, attachmentStore });
9
+ const events = createMailboxEvents();
10
+ const { store, attachmentStore } = createStores(config, events);
11
+ const smtpServer = await createSmtpServer({
12
+ store,
13
+ attachmentStore,
14
+ events,
15
+ startTls: config.smtpStartTls,
16
+ tlsKeyPath: config.smtpTlsKeyPath,
17
+ tlsCertPath: config.smtpTlsCertPath
18
+ });
19
+ const httpServer = createHttpServer({ store, attachmentStore, events });
11
20
  await Promise.all([
12
21
  smtpServer.listen(config.smtpPort, config.smtpHost),
13
22
  httpServer.listen(config.httpPort, config.httpHost)
@@ -19,14 +28,14 @@ export async function startApp(config) {
19
28
  }
20
29
  };
21
30
  }
22
- function createStores(config) {
31
+ function createStores(config, events) {
23
32
  if (config.storage === 'memory') {
24
33
  const attachmentStore = createMemoryAttachmentStore();
25
34
  return {
26
35
  attachmentStore,
27
36
  store: createMemoryStore({
28
37
  maxMessages: config.maxMessages,
29
- onDelete: (messageId) => attachmentStore.deleteForMessage(messageId)
38
+ onDelete: createDeleteHandler(attachmentStore, events)
30
39
  })
31
40
  };
32
41
  }
@@ -37,7 +46,15 @@ function createStores(config) {
37
46
  store: createFileMessageStore({
38
47
  rootDir: dataDir,
39
48
  maxMessages: config.maxMessages,
40
- onDelete: (messageId) => attachmentStore.deleteForMessage(messageId)
49
+ onDelete: createDeleteHandler(attachmentStore, events)
41
50
  })
42
51
  };
43
52
  }
53
+ function createDeleteHandler(attachmentStore, events) {
54
+ return async (messageId, reason) => {
55
+ await attachmentStore.deleteForMessage(messageId);
56
+ if (reason === 'evicted') {
57
+ events.emit({ type: 'message.deleted', id: messageId });
58
+ }
59
+ };
60
+ }
@@ -1,13 +1,21 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { SMTPServer } from 'smtp-server';
3
+ import { generate } from 'selfsigned';
2
4
  import { parseMessage } from '../mail/parser.js';
3
- export function createSmtpServer({ store, attachmentStore }) {
5
+ export async function createSmtpServer({ store, attachmentStore, events, startTls = false, tlsKeyPath = null, tlsCertPath = null }) {
6
+ const tlsConfig = startTls ? await createTlsConfig({ keyPath: tlsKeyPath, certPath: tlsCertPath }) : null;
4
7
  const server = new SMTPServer({
5
8
  name: 'inbrx',
6
9
  banner: 'inbrx ready',
10
+ ...(tlsConfig ?? {}),
7
11
  authOptional: true,
8
- disabledCommands: ['AUTH', 'STARTTLS'],
12
+ allowInsecureAuth: true,
13
+ disabledCommands: startTls ? [] : ['STARTTLS'],
9
14
  hidePIPELINING: true,
10
15
  logger: false,
16
+ onAuth(auth, _session, callback) {
17
+ callback(null, { user: auth.username || 'anonymous' });
18
+ },
11
19
  onMailFrom(_address, _session, callback) {
12
20
  callback();
13
21
  },
@@ -16,7 +24,10 @@ export function createSmtpServer({ store, attachmentStore }) {
16
24
  },
17
25
  onData(stream, session, callback) {
18
26
  void handleData(stream, session, store, attachmentStore)
19
- .then((messageId) => callback(null, `OK captured as ${messageId}`))
27
+ .then((message) => {
28
+ events?.emit({ type: 'message.created', id: message.id, receivedAt: message.receivedAt });
29
+ callback(null, `OK captured as ${message.id}`);
30
+ })
20
31
  .catch((error) => callback(error instanceof Error ? error : new Error(String(error))));
21
32
  }
22
33
  });
@@ -37,6 +48,42 @@ export function createSmtpServer({ store, attachmentStore }) {
37
48
  }
38
49
  };
39
50
  }
51
+ async function createTlsConfig({ keyPath, certPath }) {
52
+ if (keyPath || certPath) {
53
+ if (!keyPath || !certPath) {
54
+ throw new Error('SMTP TLS key and certificate paths must be provided together.');
55
+ }
56
+ const [key, cert] = await Promise.all([readFile(keyPath), readFile(certPath)]);
57
+ return { key, cert };
58
+ }
59
+ const pems = await generate([{ name: 'commonName', value: 'localhost' }], {
60
+ keySize: 2048,
61
+ algorithm: 'sha256',
62
+ extensions: [
63
+ {
64
+ name: 'basicConstraints',
65
+ cA: false
66
+ },
67
+ {
68
+ name: 'keyUsage',
69
+ digitalSignature: true,
70
+ keyEncipherment: true
71
+ },
72
+ {
73
+ name: 'subjectAltName',
74
+ altNames: [
75
+ { type: 2, value: 'localhost' },
76
+ { type: 7, ip: '127.0.0.1' },
77
+ { type: 7, ip: '::1' }
78
+ ]
79
+ }
80
+ ]
81
+ });
82
+ return {
83
+ key: pems.private,
84
+ cert: pems.cert
85
+ };
86
+ }
40
87
  async function handleData(stream, session, store, attachmentStore) {
41
88
  const raw = await readStream(stream);
42
89
  const smtp = toCapturedSmtpSession(session);
@@ -49,7 +96,7 @@ async function handleData(stream, session, store, attachmentStore) {
49
96
  await attachmentStore.deleteForMessage(message.id);
50
97
  throw error;
51
98
  }
52
- return message.id;
99
+ return message;
53
100
  }
54
101
  function readStream(stream) {
55
102
  return new Promise((resolve, reject) => {
@@ -21,13 +21,13 @@ export function createFileMessageStore({ rootDir, maxMessages, onDelete }) {
21
21
  return false;
22
22
  }
23
23
  await rm(messagePath(messagesDir, id), { force: true });
24
- await onDelete?.(id);
24
+ await onDelete?.(id, 'deleted');
25
25
  return true;
26
26
  },
27
27
  async clear() {
28
28
  const messages = await listMessages(messagesDir);
29
29
  await rm(messagesDir, { recursive: true, force: true });
30
- await Promise.all(messages.map((message) => onDelete?.(message.id)));
30
+ await Promise.all(messages.map((message) => onDelete?.(message.id, 'cleared')));
31
31
  return messages.length;
32
32
  }
33
33
  };
@@ -37,7 +37,7 @@ async function trimMessages({ messagesDir, maxMessages, onDelete }) {
37
37
  const removed = messages.slice(maxMessages);
38
38
  await Promise.all(removed.map(async (message) => {
39
39
  await rm(messagePath(messagesDir, message.id), { force: true });
40
- await onDelete?.(message.id);
40
+ await onDelete?.(message.id, 'evicted');
41
41
  }));
42
42
  }
43
43
  async function listMessages(messagesDir) {
@@ -9,7 +9,7 @@ export function createMemoryStore({ maxMessages, onDelete }) {
9
9
  const removedId = order.pop();
10
10
  if (removedId) {
11
11
  messages.delete(removedId);
12
- await onDelete?.(removedId);
12
+ await onDelete?.(removedId, 'evicted');
13
13
  }
14
14
  }
15
15
  return message;
@@ -27,7 +27,7 @@ export function createMemoryStore({ maxMessages, onDelete }) {
27
27
  order.splice(index, 1);
28
28
  }
29
29
  if (existed) {
30
- await onDelete?.(id);
30
+ await onDelete?.(id, 'deleted');
31
31
  }
32
32
  return existed;
33
33
  },
@@ -36,7 +36,7 @@ export function createMemoryStore({ maxMessages, onDelete }) {
36
36
  const ids = [...messages.keys()];
37
37
  messages.clear();
38
38
  order.length = 0;
39
- await Promise.all(ids.map((id) => onDelete?.(id)));
39
+ await Promise.all(ids.map((id) => onDelete?.(id, 'cleared')));
40
40
  return count;
41
41
  }
42
42
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inbrx/cli",
3
- "version": "0.0.1-alpha.3",
3
+ "version": "0.0.1-alpha.4",
4
4
  "description": "CLI for capturing local SMTP mail with inbrx.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,8 @@
9
9
  "type": "module",
10
10
  "files": [
11
11
  "bin",
12
- "dist"
12
+ "dist",
13
+ "README.md"
13
14
  ],
14
15
  "bin": {
15
16
  "inbrx": "./bin/inbrx.js"
@@ -28,10 +29,11 @@
28
29
  },
29
30
  "dependencies": {
30
31
  "@hono/node-server": "^2.0.8",
31
- "@inbrx/web": "0.0.1-alpha.3",
32
+ "@inbrx/web": "0.0.1-alpha.4",
32
33
  "commander": "^15.0.0",
33
34
  "hono": "^4.12.27",
34
35
  "mailparser": "^3.9.14",
36
+ "selfsigned": "^5.5.0",
35
37
  "smtp-server": "^3.19.2"
36
38
  },
37
39
  "devDependencies": {