claude-design-mode 0.4.0

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/src/serve.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import http from 'node:http';
2
+ import path from 'node:path';
3
+ import { createHandler, newToken, configScript, OVERLAY_ROUTE, SELECTION_ROUTE } from './server.js';
4
+
5
+ const BOOT_ROUTE = '/__design-mode/boot.js';
6
+
7
+ /**
8
+ * Standalone Design Mode server for apps that are not on Vite (Next, Remix,
9
+ * Rails, Django, a static site...). The app adds one script tag in development:
10
+ *
11
+ * <script src="http://localhost:3850/__design-mode/boot.js" referrerpolicy="origin"></script>
12
+ *
13
+ * referrerpolicy="origin" is required: the server identifies the requesting app
14
+ * by its Referer/Origin, and default browser policy strips the Referer on
15
+ * https -> http requests (and some frameworks set same-origin/no-referrer).
16
+ *
17
+ * boot.js carries this boot's token and loads the overlay; selections POST back
18
+ * here and land in the queue dir exactly as with the Vite plugin. There is no
19
+ * JSX stamping in this mode, so sources resolve from React debug stacks or the
20
+ * DOM path plus repo search (the skill handles both).
21
+ *
22
+ * Only pages from --app origins get the token: boot.js is served with a token
23
+ * only when the request's Referer/Origin is an allowed origin, and the
24
+ * selection endpoint accepts POSTs only from those origins.
25
+ */
26
+ export function serve({ port = 3850, apps = [], queueDir, root = process.cwd(), log = console.log } = {}) {
27
+ const token = newToken();
28
+ const apps_ = apps.map((a) => a.replace(/\/$/, ''));
29
+ const dir = path.resolve(root, queueDir || path.join('.design-mode', 'queue'));
30
+ const handle = createHandler({ token, queueDir: dir, root, allowOrigins: apps_, cors: true, log });
31
+
32
+ const referrerOrigin = (req) => {
33
+ const ref = req.headers.origin || req.headers.referer;
34
+ if (!ref) return null;
35
+ try { return new URL(ref).origin; } catch { return null; }
36
+ };
37
+
38
+ const server = http.createServer((req, res) => {
39
+ const url = (req.url || '').split('?')[0];
40
+ if (url === BOOT_ROUTE && req.method === 'GET') {
41
+ const from = referrerOrigin(req);
42
+ const allowed = from && apps_.includes(from);
43
+ res.setHeader('content-type', 'application/javascript');
44
+ res.setHeader('cache-control', 'no-store');
45
+ if (!allowed) {
46
+ const who = from ? `${JSON.stringify(from).slice(1, -1)} is not an allowed app origin; start the server with --app <origin>` : 'the request carried no Referer or Origin, so the server cannot tell which app is asking; add referrerpolicy="origin" to the boot.js script tag (and check --app <origin>)';
47
+ res.end(`console.warn(${JSON.stringify(`[design-mode] ${who}`)});`);
48
+ return;
49
+ }
50
+ const self = `http://localhost:${port}`;
51
+ res.end([
52
+ configScript({ endpoint: `${self}${SELECTION_ROUTE}`, token }) + ';',
53
+ `(function(){var s=document.createElement('script');s.src=${JSON.stringify(self + OVERLAY_ROUTE)};s.defer=true;document.head.appendChild(s);})();`,
54
+ ].join('\n'));
55
+ return;
56
+ }
57
+ if (handle(req, res)) return;
58
+ res.statusCode = 404;
59
+ res.end('not found');
60
+ });
61
+
62
+ server.listen(port, '127.0.0.1', () => {
63
+ log(`[design-mode] serving on http://localhost:${port}`);
64
+ log(`[design-mode] queue: ${path.relative(root, dir) || '.'}`);
65
+ if (apps_.length) log(`[design-mode] allowed apps: ${apps_.join(', ')}`);
66
+ else log('[design-mode] no --app origins given: pages cannot load the overlay until you pass --app http://localhost:<your-port>');
67
+ log(`[design-mode] add to your app (dev only): <script src="http://localhost:${port}${BOOT_ROUTE}" referrerpolicy="origin"></script>`);
68
+ });
69
+ return server;
70
+ }
@@ -0,0 +1,45 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+
3
+ export declare const OVERLAY_ROUTE: '/__design-mode/overlay.js';
4
+ export declare const SELECTION_ROUTE: '/__design-mode/selection';
5
+ export declare const HEALTH_ROUTE: '/__design-mode/health';
6
+ export declare const MAX_BODY: number;
7
+
8
+ /** Absolute filesystem path of the bundled overlay script. */
9
+ export declare function overlayPath(): string;
10
+ /** Absolute filesystem path of the bundled session skill directory. */
11
+ export declare function skillDir(): string;
12
+ /** A fresh per-boot token for the selection endpoint. */
13
+ export declare function newToken(): string;
14
+
15
+ export interface CreateHandlerOptions {
16
+ /** Per-boot secret the overlay sends back (see `newToken`). */
17
+ token: string;
18
+ /** Absolute directory where payloads are written as JSON files. */
19
+ queueDir: string;
20
+ /** Project root, used only to print relative paths. Default: `process.cwd()`. */
21
+ root?: string;
22
+ /** Extra Host names besides localhost/127.0.0.1/::1/*.localhost. */
23
+ allowedHosts?: string[];
24
+ /** Extra Origins to accept (the standalone server needs the app's origin). */
25
+ allowOrigins?: string[];
26
+ /** Answer preflights and echo an allowed Origin back (cross-origin overlay only). */
27
+ cors?: boolean;
28
+ log?: (msg: string) => void;
29
+ }
30
+
31
+ /**
32
+ * The Design Mode HTTP surface, framework-free. Returns a connect-style
33
+ * handler that returns true when it handled the request.
34
+ */
35
+ export declare function createHandler(
36
+ opts: CreateHandlerOptions
37
+ ): (req: IncomingMessage, res: ServerResponse) => boolean;
38
+
39
+ /** Serialize the plugin's `tokens` option (RegExp or strings) for the browser. */
40
+ export declare function serializeTokenHints(
41
+ tokens?: Record<string, RegExp | string | null | undefined>
42
+ ): Record<string, string>;
43
+
44
+ /** The inline config the page needs before the overlay script runs. */
45
+ export declare function configScript(config: Record<string, unknown>): string;
package/src/server.js ADDED
@@ -0,0 +1,177 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const OVERLAY_ROUTE = '/__design-mode/overlay.js';
7
+ export const SELECTION_ROUTE = '/__design-mode/selection';
8
+ export const HEALTH_ROUTE = '/__design-mode/health';
9
+ export const MAX_BODY = 512 * 1024;
10
+
11
+ // fileURLToPath, never URL.pathname: pathname is percent-encoded (a space is %20)
12
+ // and carries a leading slash before the drive letter on Windows.
13
+ export const overlayPath = () => fileURLToPath(new URL('./overlay.js', import.meta.url));
14
+ export const skillDir = () => fileURLToPath(new URL('../skills/design-mode/', import.meta.url));
15
+ export const newToken = () => randomBytes(16).toString('hex');
16
+
17
+ /**
18
+ * The Design Mode HTTP surface, framework-free so the Vite plugin and the
19
+ * standalone `claude-design-mode serve` command share one implementation.
20
+ *
21
+ * Returns a connect-style handler: (req, res) => boolean (true when handled).
22
+ *
23
+ * Security model for the selection endpoint: a localhost URL that ultimately
24
+ * feeds an agent is a remote prompt-injection surface, so every request must
25
+ * carry a per-boot random token in a custom header (which also forces a CORS
26
+ * preflight that cross-origin pages fail), any Origin header must match an
27
+ * allowed origin (the server's own origin by default), and the Host header must
28
+ * be a local name (so a DNS rebinding page cannot become same-origin with the
29
+ * server and read the token out of the HTML). Token comparison is constant-time.
30
+ *
31
+ * Options:
32
+ * token per-boot secret the overlay sends back (newToken())
33
+ * queueDir absolute dir where payloads are written as JSON files
34
+ * root project root, used only to print relative paths
35
+ * allowedHosts extra Host names besides localhost/127.0.0.1/::1/*.localhost
36
+ * allowOrigins extra Origins to accept (the standalone server needs the app's origin)
37
+ * cors when true, answer preflights and echo an allowed Origin back
38
+ * (needed only when the overlay lives on a different origin)
39
+ * log (msg) => void
40
+ */
41
+ export function createHandler(opts) {
42
+ const { token, queueDir, root = process.cwd(), log = () => {} } = opts;
43
+ const extraHosts = opts.allowedHosts || [];
44
+ const extraOrigins = opts.allowOrigins || [];
45
+ const cors = !!opts.cors;
46
+ let counter = 0;
47
+ fs.mkdirSync(queueDir, { recursive: true });
48
+
49
+ const selfOrigins = (req) => {
50
+ const host = req.headers.host;
51
+ return host ? [`http://${host}`, `https://${host}`] : [];
52
+ };
53
+ const originAllowed = (req, origin) => selfOrigins(req).includes(origin) || extraOrigins.includes(origin);
54
+ const isLocalHost = (host) => {
55
+ if (!host) return false;
56
+ const name = host.replace(/:\d+$/, '').replace(/^\[(.*)\]$/, '$1');
57
+ return name === 'localhost' || name.endsWith('.localhost') || name === '127.0.0.1'
58
+ || name === '::1' || extraHosts.includes(name);
59
+ };
60
+ const tokenMatches = (sent) => {
61
+ const a = Buffer.from(String(sent || ''));
62
+ const b = Buffer.from(token);
63
+ return a.length === b.length && timingSafeEqual(a, b);
64
+ };
65
+ const corsHeaders = (req, res) => {
66
+ const origin = req.headers.origin;
67
+ if (!cors || !origin || !originAllowed(req, origin)) return;
68
+ res.setHeader('access-control-allow-origin', origin);
69
+ res.setHeader('vary', 'Origin');
70
+ res.setHeader('access-control-allow-headers', 'content-type, x-design-mode-token');
71
+ res.setHeader('access-control-allow-methods', 'POST, OPTIONS');
72
+ };
73
+
74
+ return function handle(req, res) {
75
+ const url = (req.url || '').split('?')[0];
76
+
77
+ if (url === OVERLAY_ROUTE && req.method === 'GET') {
78
+ res.setHeader('content-type', 'application/javascript');
79
+ res.setHeader('cache-control', 'no-store');
80
+ corsHeaders(req, res);
81
+ res.end(fs.readFileSync(overlayPath(), 'utf8'));
82
+ return true;
83
+ }
84
+
85
+ if (url === HEALTH_ROUTE && req.method === 'GET') {
86
+ const pending = fs.existsSync(queueDir)
87
+ ? fs.readdirSync(queueDir).filter((f) => f.endsWith('.json')).length
88
+ : 0;
89
+ res.setHeader('content-type', 'application/json');
90
+ res.end(JSON.stringify({ ok: true, pending, queueDir }));
91
+ return true;
92
+ }
93
+
94
+ if (url !== SELECTION_ROUTE) return false;
95
+
96
+ if (req.method === 'OPTIONS') {
97
+ // Same-origin (Vite plugin) never preflights in practice; the standalone
98
+ // server does, and only echoes an allowed origin. The token and Origin
99
+ // checks below hold on their own regardless.
100
+ corsHeaders(req, res);
101
+ res.statusCode = 204;
102
+ res.end();
103
+ return true;
104
+ }
105
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return true; }
106
+ // set CORS headers before any check: the overlay must be able to READ a 403
107
+ // (to say "reload the page") even when the token rotated; corsHeaders only
108
+ // ever echoes an allowed origin, so this widens nothing
109
+ corsHeaders(req, res);
110
+
111
+ if (!isLocalHost(req.headers.host)) {
112
+ res.statusCode = 403;
113
+ res.end('{"error":"host not allowed"}');
114
+ return true;
115
+ }
116
+ const origin = req.headers.origin;
117
+ if (origin && !originAllowed(req, origin)) {
118
+ res.statusCode = 403;
119
+ res.end('{"error":"origin not allowed"}');
120
+ return true;
121
+ }
122
+ if (!tokenMatches(req.headers['x-design-mode-token'])) {
123
+ res.statusCode = 403;
124
+ res.end('{"error":"bad token"}');
125
+ return true;
126
+ }
127
+ if (!String(req.headers['content-type'] || '').includes('application/json')) {
128
+ res.statusCode = 415;
129
+ res.end('{"error":"json only"}');
130
+ return true;
131
+ }
132
+
133
+ let size = 0;
134
+ const chunks = [];
135
+ req.on('data', (c) => {
136
+ size += c.length;
137
+ if (size > MAX_BODY) { res.statusCode = 413; res.end(); req.destroy(); return; }
138
+ chunks.push(c);
139
+ });
140
+ req.on('end', () => {
141
+ let payload;
142
+ try {
143
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
144
+ } catch {
145
+ res.statusCode = 400;
146
+ res.end('{"error":"invalid json"}');
147
+ return;
148
+ }
149
+ if (!payload || typeof payload.instruction !== 'string' || typeof payload.seq !== 'number') {
150
+ res.statusCode = 422;
151
+ res.end('{"error":"missing instruction/seq"}');
152
+ return;
153
+ }
154
+ // collision-proof, order-stable name; atomic write so the watcher
155
+ // never wakes on a half-written file
156
+ counter += 1;
157
+ fs.mkdirSync(queueDir, { recursive: true });
158
+ const file = path.join(queueDir, `${Date.now()}-${String(counter).padStart(6, '0')}-${randomBytes(3).toString('hex')}.json`);
159
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(payload, null, 2));
160
+ fs.renameSync(`${file}.tmp`, file);
161
+ log(`[design-mode] ${payload.kind || 'selection'} #${payload.seq} -> ${path.relative(root, file)}`);
162
+ res.setHeader('content-type', 'application/json');
163
+ res.end(JSON.stringify({ ok: true, file: path.relative(root, file) }));
164
+ });
165
+ return true;
166
+ };
167
+ }
168
+
169
+ /** Serialize the plugin's `tokens` option (RegExp or strings) for the browser. */
170
+ export const serializeTokenHints = (tokens) => Object.fromEntries(
171
+ Object.entries(tokens || {})
172
+ .filter(([, v]) => v)
173
+ .map(([k, v]) => [k, v instanceof RegExp ? v.source : String(v)]),
174
+ );
175
+
176
+ /** The inline config the page needs before the overlay script runs. */
177
+ export const configScript = (config) => `window.__CDM_CONFIG=${JSON.stringify(config)}`;
package/src/stamp.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Babel visitor that stamps data-claude-source="relpath:line:col" onto JSX host
3
+ * elements (lowercase tags only). Component call sites are deliberately left
4
+ * unstamped: passing an unknown data attribute as a prop would either get
5
+ * forwarded to an unpredictable host (spread props, Radix asChild) or dropped;
6
+ * component identity comes from the fiber owner chain instead.
7
+ */
8
+ export default function stampPlugin({ types: t }) {
9
+ return {
10
+ name: 'design-mode-stamp',
11
+ visitor: {
12
+ JSXOpeningElement(path, state) {
13
+ const name = path.node.name;
14
+ if (name.type !== 'JSXIdentifier') return; // <Foo.Bar>, namespaced: skip
15
+ const first = name.name[0];
16
+ if (first !== first.toLowerCase()) return; // components: skip
17
+ if (!path.node.loc) return;
18
+ const exists = path.node.attributes.some(
19
+ (a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'data-claude-source'
20
+ );
21
+ if (exists) return;
22
+ const { line, column } = path.node.loc.start;
23
+ path.node.attributes.push(
24
+ t.jsxAttribute(
25
+ t.jsxIdentifier('data-claude-source'),
26
+ t.stringLiteral(`${state.opts.relFile}:${line}:${column + 1}`)
27
+ )
28
+ );
29
+ },
30
+ },
31
+ };
32
+ }
package/src/vite.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ export interface DesignModeOptions {
4
+ /** Where selection payloads are written. Default: `<vite-root>/.design-mode/queue`. */
5
+ queueDir?: string;
6
+ /** Extra Host names to accept besides localhost/127.0.0.1/::1/*.localhost. */
7
+ allowedHosts?: string[];
8
+ /** Set false to skip JSX stamping (non-React apps). */
9
+ stamp?: boolean;
10
+ /**
11
+ * Optional map of design-token families to the project's own custom-property
12
+ * patterns, e.g. `{ color: /^--brand-/, spacing: /^--space-/ }`. The overlay
13
+ * discovers tokens from the page's CSS on its own; these take precedence
14
+ * when a project's names are unusual.
15
+ */
16
+ tokens?: Partial<
17
+ Record<
18
+ | 'color'
19
+ | 'fontFamily'
20
+ | 'fontWeight'
21
+ | 'fontSize'
22
+ | 'lineHeight'
23
+ | 'tracking'
24
+ | 'radius'
25
+ | 'shadow'
26
+ | 'spacing',
27
+ RegExp | string
28
+ >
29
+ >;
30
+ }
31
+
32
+ /**
33
+ * Design Mode Vite plugin. Dev-serve only (and never inside Vitest); production
34
+ * builds are untouched. Stamps JSX host elements with
35
+ * `data-claude-source="relpath:line:col"`, serves the inspector overlay from
36
+ * the app's own origin, and receives selections on a token-checked endpoint.
37
+ */
38
+ export default function designMode(options?: DesignModeOptions): Plugin;
package/src/vite.js ADDED
@@ -0,0 +1,95 @@
1
+ import path from 'node:path';
2
+ import { transformSync } from '@babel/core';
3
+ import stampPlugin from './stamp.js';
4
+ import { createHandler, newToken, serializeTokenHints, configScript, OVERLAY_ROUTE, SELECTION_ROUTE } from './server.js';
5
+
6
+ /**
7
+ * Design Mode Vite plugin. Dev-serve only; never touches production builds.
8
+ *
9
+ * - stamps data-claude-source="relpath:line:col" on JSX host elements
10
+ * - serves the inspector overlay from the app's own origin
11
+ * - receives selection payloads on a token-checked endpoint and writes them
12
+ * to .design-mode/queue/ for the agent (`claude-design-mode wait` blocks on it)
13
+ *
14
+ * Options:
15
+ * queueDir where selections are written; default <vite-root>/.design-mode/queue
16
+ * allowedHosts extra Host names to accept besides localhost/127.0.0.1/::1/*.localhost
17
+ * tokens optional map of design-token families to the project's own custom-property
18
+ * patterns, e.g. { color: /^--brand-/, spacing: /^--space-/ }. The overlay
19
+ * discovers tokens from the page's CSS on its own (naming conventions, then
20
+ * value type); these patterns take precedence when a project's names are
21
+ * unusual. Families: color, fontFamily, fontWeight, fontSize, lineHeight,
22
+ * tracking, radius, shadow, spacing. Values may be RegExp or regex-source strings.
23
+ * stamp set false to skip JSX stamping (non-React apps); the overlay then resolves
24
+ * sources from React debug stacks or the DOM path instead
25
+ */
26
+ export default function designMode(options = {}) {
27
+ const token = newToken();
28
+ const tokenHints = serializeTokenHints(options.tokens);
29
+ let root = process.cwd();
30
+ let queueDir = '';
31
+ const warned = new Set();
32
+
33
+ const stamp = (code, id) => {
34
+ if (options.stamp === false) return null;
35
+ const [file] = id.split('?');
36
+ if (!/\.[jt]sx$/.test(file) || file.includes('node_modules')) return null;
37
+ const relFile = path.relative(root, file).split(path.sep).join('/');
38
+ try {
39
+ const result = transformSync(code, {
40
+ filename: file,
41
+ configFile: false,
42
+ babelrc: false,
43
+ sourceMaps: true,
44
+ retainLines: true,
45
+ // decorators: esbuild accepts them, so the stamping parse must too
46
+ parserOpts: { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'] },
47
+ plugins: [[stampPlugin, { relFile }]],
48
+ });
49
+ return result ? { code: result.code, map: result.map } : null;
50
+ } catch (e) {
51
+ // stamping is an aid, never a gate: an unparseable file loads unstamped
52
+ if (!warned.has(relFile)) {
53
+ warned.add(relFile);
54
+ console.warn(`[design-mode] could not stamp ${relFile} (${e.message ? e.message.split('\n')[0] : e}); serving it unstamped`);
55
+ }
56
+ return null;
57
+ }
58
+ };
59
+
60
+ return {
61
+ name: 'design-mode',
62
+ // dev serve only, and never inside Vitest: stamping test renders would
63
+ // break DOM snapshots, and tests have no use for the endpoint
64
+ apply: (_config, env) => env.command === 'serve' && env.mode !== 'test' && !process.env.VITEST,
65
+ enforce: 'pre', // must run before @vitejs/plugin-react compiles JSX away
66
+
67
+ configResolved(config) {
68
+ root = config.root;
69
+ queueDir = path.resolve(root, options.queueDir || path.join('.design-mode', 'queue'));
70
+ },
71
+
72
+ transform(code, id) {
73
+ return stamp(code, id);
74
+ },
75
+
76
+ transformIndexHtml() {
77
+ return [
78
+ { tag: 'script', injectTo: 'head', children: configScript({ endpoint: SELECTION_ROUTE, token, tokens: tokenHints }) },
79
+ { tag: 'script', injectTo: 'body', attrs: { src: OVERLAY_ROUTE, defer: true } },
80
+ ];
81
+ },
82
+
83
+ configureServer(server) {
84
+ const handle = createHandler({
85
+ token,
86
+ queueDir,
87
+ root,
88
+ allowedHosts: options.allowedHosts,
89
+ log: (msg) => server.config.logger.info(msg, { timestamp: true }),
90
+ });
91
+ server.middlewares.use((req, res, next) => { if (!handle(req, res)) next(); });
92
+ server.config.logger.info(`[design-mode] queue: ${path.relative(root, queueDir) || '.'} (toggle with Cmd+D in the page)`, { timestamp: true });
93
+ },
94
+ };
95
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Wake watcher for agent sessions. Blocks until a selection payload lands in
3
+ * the queue dir, prints the path(s), and exits 0. The agent runs this as a
4
+ * background process; the harness re-invokes the agent when it exits.
5
+ *
6
+ * claude-design-mode wait [queueDir] [--timeout <minutes>]
7
+ *
8
+ * Exit codes: 0 selection available · 2 timed out (re-arm me) · 1 error
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ export function wait({ dir, timeoutMin = 15, out = console.log, err = console.error, exit = process.exit } = {}) {
14
+ const queue = path.resolve(dir || path.join(process.cwd(), '.design-mode', 'queue'));
15
+ fs.mkdirSync(queue, { recursive: true });
16
+
17
+ const pending = () => {
18
+ try {
19
+ return fs.readdirSync(queue).filter((f) => f.endsWith('.json')).sort();
20
+ } catch {
21
+ fs.mkdirSync(queue, { recursive: true }); // dir was removed (git clean etc.); keep waiting
22
+ return [];
23
+ }
24
+ };
25
+
26
+ const finish = (files) => {
27
+ for (const f of files) out(path.join(queue, f));
28
+ exit(0);
29
+ };
30
+
31
+ const existing = pending();
32
+ if (existing.length) return finish(existing);
33
+
34
+ const check = () => {
35
+ const files = pending();
36
+ if (files.length) finish(files);
37
+ };
38
+
39
+ try {
40
+ fs.watch(queue, check);
41
+ } catch {
42
+ /* fs.watch can be flaky; the poll below still covers us */
43
+ }
44
+ const poll = setInterval(check, 2000);
45
+ poll.unref?.();
46
+
47
+ setTimeout(() => {
48
+ err(`no selection within ${timeoutMin}m`);
49
+ exit(2);
50
+ }, timeoutMin * 60 * 1000);
51
+
52
+ // keep the process alive while waiting
53
+ setInterval(() => {}, 1 << 30);
54
+ }