@alexkroman1/aai-cli 1.9.2 → 1.10.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.
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ import { n as log } from "./_ui-CKEIHAtB.mjs";
3
+ import { r as errorMessage, t as errorCode } from "./_utils-ECl2je7-.mjs";
4
+ import { n as fallbackHtmlPlugin } from "./client-bundler-VdcgFcos.mjs";
5
+ import { buildWorker } from "./worker-bundler.mjs";
6
+ import { createWorkerEvaluator } from "./_bundler-CKz9Q15i.mjs";
7
+ import { n as ensureApiKey } from "./_config-BMjZt5hG.mjs";
8
+ import { t as resolveServerEnv } from "./_server-common-Bv-4Rskq.mjs";
9
+ import { createRequire } from "node:module";
10
+ import { existsSync } from "node:fs";
11
+ import path from "node:path";
12
+ import fs from "node:fs/promises";
13
+ import { requiredProviderEnvVars, withHostCredentialFallback } from "@alexkroman1/aai/runtime";
14
+ import { watch } from "chokidar";
15
+ import getPort, { portNumbers } from "get-port";
16
+ import pDebounce from "p-debounce";
17
+ //#region _dev-bundler.ts
18
+ /**
19
+ * Incremental worker builds for `aai dev`.
20
+ *
21
+ * The deploy path (`buildWorker` in `worker-bundler.ts`) is a from-scratch
22
+ * Vite/Rollup pass — right for a one-shot `aai deploy`, but 1–3 s per save
23
+ * when the dev watcher runs it on every change. This module keeps a
24
+ * long-lived esbuild `context()` whose `rebuild()` reuses the previous
25
+ * build's work, cutting rebuilds to tens of ms. Deploy keeps Vite untouched,
26
+ * so nothing that ships is produced by esbuild.
27
+ *
28
+ * Parity with `buildWorker` where it matters for dev:
29
+ *
30
+ * - single-file ESM output, unminified (dev builds never minify);
31
+ * - `node:` builtins external (esbuild's `platform: "node"`), everything
32
+ * else — zod, workspace deps, local imports — bundled in;
33
+ * - `.md` imports resolve to their raw text (esbuild's `text` loader is
34
+ * `rawMdPlugin`'s transform), and Vite-style `?raw` suffix imports are
35
+ * honored via `rawSuffixPlugin` below.
36
+ *
37
+ * Known dev/deploy differences, accepted: Rollup and esbuild can disagree on
38
+ * `exports`-condition ordering for exotic dual-format packages, and Vite's
39
+ * lib build applies `define`/`import.meta.env` replacements esbuild does not.
40
+ * When a rebuild fails for anything other than a compile error in the agent's
41
+ * code, the caller falls back to the cold Vite path (see `_dev-server.ts`),
42
+ * so an esbuild-specific resolution gap degrades to the old slow-but-correct
43
+ * behavior rather than a broken dev server.
44
+ *
45
+ * esbuild does not touch `process.env` (verified: no NODE_ENV write in its
46
+ * JS API), so the `withPreservedNodeEnv` wrapper Vite builds need is not
47
+ * required here.
48
+ */
49
+ /**
50
+ * Vite serves `import x from "./file?raw"` as the file's text. esbuild treats
51
+ * the suffix as part of the filename, so resolve it explicitly and load the
52
+ * real file with the `text` loader.
53
+ */
54
+ const rawSuffixPlugin = {
55
+ name: "raw-suffix",
56
+ setup(build) {
57
+ build.onResolve({ filter: /\?raw$/ }, (args) => ({
58
+ path: path.resolve(args.resolveDir, args.path.slice(0, -4)),
59
+ namespace: "aai-raw"
60
+ }));
61
+ build.onLoad({
62
+ filter: /.*/,
63
+ namespace: "aai-raw"
64
+ }, async (args) => ({
65
+ contents: await fs.readFile(args.path, "utf8"),
66
+ loader: "text"
67
+ }));
68
+ }
69
+ };
70
+ /**
71
+ * True for esbuild build failures — compile/resolve errors in the code being
72
+ * built (the esbuild analog of a Rollup diagnostic). Anything else coming out
73
+ * of `rebuild()` is an esbuild-infrastructure problem, which callers treat as
74
+ * "fall back to the cold Vite path" rather than "the user's code is broken".
75
+ */
76
+ function isEsbuildBuildFailure(err) {
77
+ return err instanceof Error && "errors" in err && Array.isArray(err.errors);
78
+ }
79
+ /**
80
+ * Create an incremental dev builder for the agent at `cwd`.
81
+ *
82
+ * The context is created lazily on first `build()` and kept across calls —
83
+ * that reuse is the entire point. A rebuild that fails for a non-compile
84
+ * reason drops the context so the next call starts from a clean one.
85
+ */
86
+ function createDevWorkerBuilder(cwd) {
87
+ let ctx;
88
+ async function ensureContext() {
89
+ if (ctx) return ctx;
90
+ const { context } = await import("esbuild");
91
+ ctx = await context({
92
+ entryPoints: [path.join(cwd, "agent.ts")],
93
+ absWorkingDir: cwd,
94
+ bundle: true,
95
+ format: "esm",
96
+ platform: "node",
97
+ target: "node20",
98
+ write: false,
99
+ outfile: "worker.js",
100
+ minify: false,
101
+ logLevel: "silent",
102
+ loader: { ".md": "text" },
103
+ plugins: [rawSuffixPlugin]
104
+ });
105
+ return ctx;
106
+ }
107
+ return {
108
+ async build() {
109
+ const active = await ensureContext();
110
+ let result;
111
+ try {
112
+ result = await active.rebuild();
113
+ } catch (err) {
114
+ if (!isEsbuildBuildFailure(err)) {
115
+ ctx = void 0;
116
+ await active.dispose().catch(() => void 0);
117
+ }
118
+ throw err;
119
+ }
120
+ const file = result.outputFiles?.[0];
121
+ if (!file) throw new Error("esbuild produced no output for agent.ts");
122
+ return file.text;
123
+ },
124
+ async dispose() {
125
+ const active = ctx;
126
+ ctx = void 0;
127
+ await active?.dispose().catch(() => void 0);
128
+ }
129
+ };
130
+ }
131
+ //#endregion
132
+ //#region _dev-server.ts
133
+ /**
134
+ * Dev server for directory-based agents.
135
+ *
136
+ * Imports agent.ts directly for the full agent definition,
137
+ * builds a runtime, and starts an HTTP+WebSocket server. Watches for
138
+ * file changes and restarts automatically. Optionally runs Vite for
139
+ * client SPA HMR.
140
+ */
141
+ async function resolveAgentEnv(root, agentDef) {
142
+ const env = await resolveServerEnv(root);
143
+ const required = requiredProviderEnvVars(agentDef);
144
+ if (required.includes("ASSEMBLYAI_API_KEY") && !env.ASSEMBLYAI_API_KEY) env.ASSEMBLYAI_API_KEY = await ensureApiKey();
145
+ const missing = required.filter((name) => !(env[name] || process.env[name]));
146
+ if (missing.length > 0) log.warn(`Missing provider credential${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}. Set ${missing.length > 1 ? "them" : "it"} in .env or the environment.`);
147
+ return env;
148
+ }
149
+ /**
150
+ * The env handed to `createServer` for host-mode connections: provider
151
+ * credentials plus the `AAI_ALLOW_HOST` gate read straight from the shell
152
+ * (it is a control variable, not something an agent declares in `.env`).
153
+ */
154
+ function hostModeEnv(providerEnv) {
155
+ const gate = process.env.AAI_ALLOW_HOST;
156
+ return gate === void 0 ? providerEnv : {
157
+ ...providerEnv,
158
+ AAI_ALLOW_HOST: gate
159
+ };
160
+ }
161
+ /**
162
+ * Explicit bind host for the dev server, or `undefined` to take the
163
+ * loopback default. An empty `AAI_DEV_HOST` means "unset", not "every
164
+ * interface" — Node treats `listen(port, "")` as 0.0.0.0, which would quietly
165
+ * undo the loopback default this exists to guard.
166
+ */
167
+ function devBindHost() {
168
+ const host = process.env.AAI_DEV_HOST?.trim();
169
+ return host ? host : void 0;
170
+ }
171
+ /**
172
+ * Load the agent definition by bundling agent.ts (and all its local imports)
173
+ * into a single ESM file, then importing that. A raw `import(agent.ts?t=...)`
174
+ * only cache-busts agent.ts itself — transitive imports (./tools.ts, etc.)
175
+ * stay in Node's ESM registry, so edits to them are ignored on reload.
176
+ * Bundling picks them up.
177
+ *
178
+ * The bundle comes from the incremental esbuild builder (`_dev-bundler.ts`)
179
+ * rather than the deploy path's cold Vite build — a save rebuilds in tens of
180
+ * ms instead of 1–3 s. Compile errors in the agent's code propagate (the
181
+ * restart loop reports them and keeps the old server); any other builder
182
+ * failure falls back to the cold Vite build so an esbuild-specific gap can't
183
+ * take the dev loop down. Evaluation goes through the memoizing evaluator so
184
+ * a no-op save doesn't leak another module into the ESM registry.
185
+ */
186
+ async function loadAgentDefWith(cwd, builder, evaluate) {
187
+ let code;
188
+ try {
189
+ code = await builder.build();
190
+ } catch (err) {
191
+ if (isEsbuildBuildFailure(err)) throw err;
192
+ code = await buildWorker(cwd);
193
+ }
194
+ return evaluate(code);
195
+ }
196
+ /**
197
+ * True for paths that should never trigger a restart: anything inside
198
+ * `node_modules/` and any dot-entry (`.git/`, `.aai/`, `.DS_Store`, …).
199
+ * `.git/` especially matters — commits and status checks churn the index
200
+ * and would otherwise cause spurious full backend restarts.
201
+ *
202
+ * Exception: `.env` / `.env.*` files stay watched — env edits should
203
+ * restart the server with the new values.
204
+ */
205
+ function isIgnoredPath(dir, filePath) {
206
+ const rel = path.relative(dir, filePath);
207
+ if (!rel || rel.startsWith("..")) return false;
208
+ return rel.split(path.sep).some((segment) => {
209
+ if (segment === "node_modules") return true;
210
+ if (segment === ".env" || segment.startsWith(".env.")) return false;
211
+ return segment.startsWith(".");
212
+ });
213
+ }
214
+ /**
215
+ * Watch the agent directory for changes and call `onChange` when detected.
216
+ * Debounces to avoid rapid restarts. Uses chokidar for reliable recursive
217
+ * watching across platforms (raw `fs.watch` misses events on Linux).
218
+ */
219
+ function watchDirectory(dir, onChange) {
220
+ const debouncedChange = pDebounce(() => {
221
+ log.info("File change detected, restarting...");
222
+ onChange();
223
+ }, 300);
224
+ const watcher = watch(dir, {
225
+ ignored: (filePath) => isIgnoredPath(dir, filePath),
226
+ ignoreInitial: true,
227
+ persistent: false
228
+ });
229
+ watcher.on("error", (err) => {
230
+ const hint = errorCode(err) === "ENOSPC" ? " The inotify watch limit was reached — raise the fs.inotify max_user_watches sysctl." : "";
231
+ log.error(`File watcher error: ${errorMessage(err)}.${hint} Auto-restart on file changes may have stopped; restart \`aai dev\` after fixing.`);
232
+ });
233
+ watcher.on("all", () => {
234
+ debouncedChange().catch((err) => {
235
+ log.error(`Watch handler failed: ${errorMessage(err)}`);
236
+ });
237
+ });
238
+ return watcher;
239
+ }
240
+ /** Locate the pre-built default aai-ui client (served when no custom client.tsx). */
241
+ function resolveDefaultClientDir() {
242
+ const require = createRequire(import.meta.url);
243
+ let pkgPath;
244
+ try {
245
+ pkgPath = require.resolve("@alexkroman1/aai-ui/package.json");
246
+ } catch (err) {
247
+ throw new Error(`Could not locate the default client UI (${errorMessage(err)}) — is @alexkroman1/aai-ui installed? Try reinstalling dependencies (pnpm install).`, { cause: err });
248
+ }
249
+ return path.join(path.dirname(pkgPath), "dist", "default-client");
250
+ }
251
+ /**
252
+ * Vite dev-server config for the client SPA. Extracted so the proxy wiring
253
+ * is unit-testable: `/websocket` MUST proxy with `ws: true` or `aai dev`
254
+ * with a `client.tsx` serves a page whose WebSocket never connects.
255
+ *
256
+ * `strictPort` because the reported URL is `http://localhost:<port>` —
257
+ * without it, Vite silently binds port+N when the port is busy and the
258
+ * printed/JSON-returned URL points at whatever else was listening.
259
+ */
260
+ function viteDevConfig(cwd, vitePort, backendPort) {
261
+ const target = `http://localhost:${backendPort}`;
262
+ return {
263
+ root: cwd,
264
+ plugins: [fallbackHtmlPlugin(cwd)],
265
+ server: {
266
+ port: vitePort,
267
+ strictPort: true,
268
+ proxy: {
269
+ "/health": target,
270
+ "/websocket": {
271
+ target,
272
+ ws: true
273
+ }
274
+ }
275
+ }
276
+ };
277
+ }
278
+ /**
279
+ * Start the dev server for a directory-based agent.
280
+ *
281
+ * Returns a cleanup function to shut down the server and watchers.
282
+ */
283
+ async function startDevServer(opts) {
284
+ const { cwd, port } = opts;
285
+ const { createRuntime, createServer } = await import("@alexkroman1/aai/runtime");
286
+ const hasClient = existsSync(path.join(cwd, "client.tsx"));
287
+ const backendPort = hasClient ? await getPort({ port: portNumbers(port + 1, port + 100) }) : port;
288
+ const vitePort = port;
289
+ const clientDirOpt = hasClient ? {} : { clientDir: resolveDefaultClientDir() };
290
+ const devBuilder = createDevWorkerBuilder(cwd);
291
+ const evaluateWorker = createWorkerEvaluator(cwd);
292
+ /** Full build sequence, shared by initial startup and every restart. */
293
+ async function buildServer() {
294
+ const agentDef = await loadAgentDefWith(cwd, devBuilder, evaluateWorker);
295
+ const env = await resolveAgentEnv(cwd, agentDef);
296
+ const providerEnv = withHostCredentialFallback(env);
297
+ const runtime = createRuntime({
298
+ agent: agentDef,
299
+ env,
300
+ providerEnv
301
+ });
302
+ return createServer({
303
+ runtime,
304
+ name: agentDef.name,
305
+ env: hostModeEnv(providerEnv),
306
+ hostBaseAgent: agentDef,
307
+ ...clientDirOpt
308
+ });
309
+ }
310
+ let restarting = true;
311
+ let pendingRestart = false;
312
+ let closed = false;
313
+ let currentServer;
314
+ function kickRestart() {
315
+ if (restarting) {
316
+ pendingRestart = true;
317
+ return;
318
+ }
319
+ restarting = true;
320
+ restart().catch((err) => {
321
+ log.error(`Restart failed: ${errorMessage(err)}`);
322
+ }).finally(() => {
323
+ restarting = false;
324
+ });
325
+ }
326
+ const watcher = watchDirectory(cwd, kickRestart);
327
+ let viteServer;
328
+ try {
329
+ currentServer = await buildServer();
330
+ await currentServer.listen(backendPort, devBindHost());
331
+ if (hasClient) {
332
+ const { createServer: createViteServer } = await import("vite");
333
+ viteServer = await createViteServer(viteDevConfig(cwd, vitePort, backendPort));
334
+ await viteServer.listen();
335
+ viteServer.httpServer?.on("error", (err) => {
336
+ log.error(`Vite dev server error: ${errorMessage(err)}`);
337
+ });
338
+ }
339
+ } catch (err) {
340
+ await watcher.close().catch(() => void 0);
341
+ await devBuilder.dispose().catch(() => void 0);
342
+ await viteServer?.close().catch(() => void 0);
343
+ throw err;
344
+ }
345
+ restarting = false;
346
+ if (pendingRestart) kickRestart();
347
+ async function restart() {
348
+ do {
349
+ pendingRestart = false;
350
+ await restartOnce();
351
+ } while (pendingRestart && !closed);
352
+ }
353
+ async function restartOnce() {
354
+ let newServer;
355
+ try {
356
+ newServer = await buildServer();
357
+ } catch (err) {
358
+ log.error(`Restart failed: ${errorMessage(err)} (previous server still running)`);
359
+ return;
360
+ }
361
+ if (closed) {
362
+ await newServer.close().catch(() => void 0);
363
+ return;
364
+ }
365
+ try {
366
+ await currentServer.close();
367
+ } catch {}
368
+ try {
369
+ await listenWithRetry(newServer);
370
+ currentServer = newServer;
371
+ if (closed) {
372
+ await newServer.close().catch(() => void 0);
373
+ return;
374
+ }
375
+ log.success("Restarted");
376
+ } catch (err) {
377
+ log.error(`Restart failed: ${errorMessage(err)} — dev server is down; save a file to retry.`);
378
+ await newServer.close().catch(() => void 0);
379
+ }
380
+ }
381
+ /**
382
+ * Listen with a few short-backoff retries. During the close→listen swap the
383
+ * port is momentarily free, so another process can snatch it (or the OS can
384
+ * hold it in TIME_WAIT); one blind attempt would leave the dev server down
385
+ * until the next file change.
386
+ */
387
+ async function listenWithRetry(server) {
388
+ const LISTEN_ATTEMPTS = 3;
389
+ const LISTEN_RETRY_DELAY_MS = 250;
390
+ for (let attempt = 1;; attempt++) try {
391
+ await server.listen(backendPort, devBindHost());
392
+ return;
393
+ } catch (err) {
394
+ if (attempt >= LISTEN_ATTEMPTS || closed) throw err;
395
+ await new Promise((resolve) => setTimeout(resolve, LISTEN_RETRY_DELAY_MS));
396
+ }
397
+ }
398
+ let cleanupPromise;
399
+ return () => {
400
+ cleanupPromise ??= (async () => {
401
+ closed = true;
402
+ await watcher.close().catch(() => void 0);
403
+ await devBuilder.dispose().catch(() => void 0);
404
+ await viteServer?.close().catch(() => void 0);
405
+ await currentServer.close().catch(() => void 0);
406
+ })();
407
+ return cleanupPromise;
408
+ };
409
+ }
410
+ //#endregion
411
+ export { startDevServer };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { a as readJson, c as writeJson, i as isEexist } from "./_utils-BeU10C7O.mjs";
3
- import { i as isDevMode, n as getMonorepoRoot } from "./_agent-cHGzbDVG.mjs";
2
+ import { a as isEexist, l as writeJson, o as readJson, r as errorMessage } from "./_utils-ECl2je7-.mjs";
3
+ import { r as isDevMode, t as getMonorepoRoot } from "./_agent-Ba5Ykp05.mjs";
4
4
  import { existsSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import fs from "node:fs/promises";
@@ -9,37 +9,70 @@ import { downloadTemplate } from "giget";
9
9
  //#region _templates.ts
10
10
  const GIGET_SOURCE = "github:alexkroman/agent/packages/aai-templates";
11
11
  const GIGET_REF = process.env.AAI_TEMPLATES_REF ?? "main";
12
+ const VALID_REF_RE = /^[\w./-]+$/;
13
+ const noCleanup = async () => void 0;
12
14
  /** Resolve the templates directory — local in dev, giget download in prod. */
13
15
  async function resolveTemplatesDir() {
14
- if (process.env.AAI_TEMPLATES_DIR) return process.env.AAI_TEMPLATES_DIR;
16
+ if (process.env.AAI_TEMPLATES_DIR) return {
17
+ root: process.env.AAI_TEMPLATES_DIR,
18
+ cleanup: noCleanup
19
+ };
15
20
  const monorepoRoot = isDevMode() ? getMonorepoRoot() : null;
16
- if (monorepoRoot) return path.join(monorepoRoot, "packages", "aai-templates");
21
+ if (monorepoRoot) return {
22
+ root: path.join(monorepoRoot, "packages", "aai-templates"),
23
+ cleanup: noCleanup
24
+ };
25
+ if (!VALID_REF_RE.test(GIGET_REF)) throw new Error(`Invalid AAI_TEMPLATES_REF: ${JSON.stringify(GIGET_REF)} is not a git ref.`);
17
26
  const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), "aai-templates-"));
18
- const { dir } = await downloadTemplate(`${GIGET_SOURCE}#${GIGET_REF}`, {
19
- dir: extractDir,
20
- force: true,
21
- forceClean: true
22
- });
23
- return dir;
27
+ const cleanup = async () => {
28
+ await fs.rm(extractDir, {
29
+ recursive: true,
30
+ force: true
31
+ }).catch(() => void 0);
32
+ };
33
+ try {
34
+ const { dir } = await downloadTemplate(`${GIGET_SOURCE}#${GIGET_REF}`, {
35
+ dir: extractDir,
36
+ force: true,
37
+ forceClean: true
38
+ });
39
+ return {
40
+ root: dir,
41
+ cleanup
42
+ };
43
+ } catch (err) {
44
+ await cleanup();
45
+ throw new Error(`Failed to download templates from ${GIGET_SOURCE}#${GIGET_REF}: ${errorMessage(err)}`, { cause: err });
46
+ }
24
47
  }
25
48
  /**
26
49
  * Download a template into targetDir, merging scaffold files underneath.
27
50
  */
28
51
  async function downloadAndMergeTemplate(template, targetDir) {
29
- const root = await resolveTemplatesDir();
30
- const templatesDir = path.join(root, "templates");
31
- const names = (await fs.readdir(templatesDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
32
- if (!names.includes(template)) throw new Error(`Unknown template "${template}". Available templates: ${names.join(", ")}`);
33
- await fs.cp(path.join(templatesDir, template), targetDir, {
34
- recursive: true,
35
- force: true
36
- });
37
- const scaffoldDir = path.join(root, "scaffold");
38
- if (existsSync(scaffoldDir)) await fs.cp(scaffoldDir, targetDir, {
39
- recursive: true,
40
- force: false,
41
- errorOnExist: false
42
- });
52
+ const { root, cleanup } = await resolveTemplatesDir();
53
+ try {
54
+ const templatesDir = path.join(root, "templates");
55
+ let available;
56
+ try {
57
+ available = await fs.readdir(templatesDir, { withFileTypes: true });
58
+ } catch (err) {
59
+ throw new Error(`Templates directory is missing or unreadable at ${templatesDir} (corrupt or incomplete template download?): ${errorMessage(err)}`, { cause: err });
60
+ }
61
+ const names = available.filter((e) => e.isDirectory()).map((e) => e.name);
62
+ if (!names.includes(template)) throw new Error(`Unknown template "${template}". Available templates: ${names.join(", ")}`);
63
+ await fs.cp(path.join(templatesDir, template), targetDir, {
64
+ recursive: true,
65
+ force: true
66
+ });
67
+ const scaffoldDir = path.join(root, "scaffold");
68
+ if (existsSync(scaffoldDir)) await fs.cp(scaffoldDir, targetDir, {
69
+ recursive: true,
70
+ force: false,
71
+ errorOnExist: false
72
+ });
73
+ } finally {
74
+ await cleanup();
75
+ }
43
76
  }
44
77
  //#endregion
45
78
  //#region _init.ts
@@ -95,7 +128,6 @@ async function patchPackageJsonForWorkspace(targetDir) {
95
128
  if (!pkgJson) return;
96
129
  pkgJson.name = path.basename(targetDir);
97
130
  delete pkgJson.packageManager;
98
- const { getMonorepoRoot } = await import("./_agent-cHGzbDVG.mjs").then((n) => n.t);
99
131
  const root = getMonorepoRoot();
100
132
  if (!root) return;
101
133
  const packagesDir = path.join(root, "packages");
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
3
  import fs from "node:fs/promises";
4
- import { parse } from "dotenv";
4
+ import { parseEnv } from "node:util";
5
5
  //#region _server-common.ts
6
6
  /**
7
7
  * Build the `ctx.env` record that agent tools will see at runtime.
@@ -22,7 +22,7 @@ import { parse } from "dotenv";
22
22
  async function resolveServerEnv(cwd, baseEnv) {
23
23
  let fileEntries = {};
24
24
  if (cwd) try {
25
- fileEntries = parse(await fs.readFile(path.join(cwd, ".env"), "utf-8"));
25
+ fileEntries = parseEnv(await fs.readFile(path.join(cwd, ".env"), "utf-8"));
26
26
  } catch {}
27
27
  const source = baseEnv ?? process.env;
28
28
  const env = {};
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ //#region _output.ts
5
+ /**
6
+ * Determine output mode from CLI flags and TTY state.
7
+ *
8
+ * Priority: --json flag > --no-json flag > TTY auto-detection.
9
+ */
10
+ function getOutputMode(args, isTTY = Boolean(process.stdout.isTTY)) {
11
+ if (args.json === true) return "json";
12
+ if (args.json === false) return "human";
13
+ return isTTY ? "human" : "json";
14
+ }
15
+ /**
16
+ * Write a line to stdout, resolving only once it has been flushed.
17
+ *
18
+ * Resolves (never rejects) even when the write fails: the common failure is
19
+ * EPIPE — the consumer closed the pipe (`aai … --json | head -1`) — and there
20
+ * is nothing useful to do about a broken stdout except carry on and exit.
21
+ * Stream-level `'error'` events are handled by {@link installStdoutGuard}.
22
+ */
23
+ function writeLine(line) {
24
+ return new Promise((resolve) => {
25
+ process.stdout.write(line, () => resolve());
26
+ });
27
+ }
28
+ /**
29
+ * Install an `'error'` listener on stdout so a broken pipe doesn't crash the
30
+ * CLI with an unhandled `'error'` event. EPIPE (consumer went away, e.g.
31
+ * `aai … --json | head -1`) exits quietly; anything else is reported on
32
+ * stderr and exits non-zero.
33
+ */
34
+ function installStdoutGuard(stream = process.stdout) {
35
+ stream.on("error", (err) => {
36
+ if (err.code === "EPIPE") process.exit(0);
37
+ else {
38
+ process.stderr.write(`stdout error: ${err.message}\n`);
39
+ process.exit(1);
40
+ }
41
+ });
42
+ }
43
+ /** Create an ok result. */
44
+ function ok(data) {
45
+ return {
46
+ ok: true,
47
+ data
48
+ };
49
+ }
50
+ /** Create an error result. */
51
+ function fail(code, error, hint) {
52
+ return hint ? {
53
+ ok: false,
54
+ error,
55
+ code,
56
+ hint
57
+ } : {
58
+ ok: false,
59
+ error,
60
+ code
61
+ };
62
+ }
63
+ /** Typed CLI error that carries a structured error code and optional hint. */
64
+ var CliError = class extends Error {
65
+ code;
66
+ hint;
67
+ constructor(code, message, hint, options) {
68
+ super(message, options);
69
+ this.name = "CliError";
70
+ this.code = code;
71
+ if (hint !== void 0) this.hint = hint;
72
+ }
73
+ };
74
+ //#endregion
75
+ //#region _ui.ts
76
+ const noop = () => {};
77
+ let silenced = false;
78
+ /** Log instance that delegates to clack (human mode) or no-ops (JSON mode). */
79
+ const log = new Proxy(p.log, { get(target, prop, receiver) {
80
+ return silenced ? noop : Reflect.get(target, prop, receiver);
81
+ } });
82
+ /** Replace all log methods with no-ops. Call once in JSON mode. */
83
+ function silenceOutput() {
84
+ silenced = true;
85
+ }
86
+ /** Unwrap a clack prompt result, exiting cleanly if the user cancelled. */
87
+ function unwrapCancel(result) {
88
+ if (p.isCancel(result)) {
89
+ p.cancel("Setup cancelled");
90
+ process.exit(0);
91
+ }
92
+ return result;
93
+ }
94
+ /** Format a URL for display. */
95
+ function fmtUrl(url) {
96
+ return pc.cyanBright(url);
97
+ }
98
+ /**
99
+ * Parse and validate a port string. Returns the numeric port or throws.
100
+ *
101
+ * Deliberately zod-free: this module loads on every CLI invocation
102
+ * (including `aai --help`), and keeping zod off that path is the same
103
+ * startup-cost invariant `_utils.ts` documents for its error helpers.
104
+ */
105
+ function parsePort(raw) {
106
+ const port = raw.trim() === "" ? NaN : Number(raw);
107
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid port: ${raw}. Must be a number between 0 and 65535.`);
108
+ return port;
109
+ }
110
+ //#endregion
111
+ export { unwrapCancel as a, getOutputMode as c, writeLine as d, silenceOutput as i, installStdoutGuard as l, log as n, CliError as o, parsePort as r, fail as s, fmtUrl as t, ok as u };