@alexkroman1/aai-cli 5.10.1 → 5.11.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { c as readJson, d as writeJson, s as isEexist } from "./_utils-8KKw-bzi.mjs";
3
3
  import { r as isDevMode, t as getMonorepoRoot } from "./_agent-DS2PUJcl.mjs";
4
- import { REPO_URL, downloadAndMergeTemplate } from "./_templates-BWJOiWOO.mjs";
4
+ import { REPO_URL, downloadAndMergeTemplate } from "./_templates-DmdG-voA.mjs";
5
5
  import path from "node:path";
6
6
  import fs from "node:fs/promises";
7
7
  //#region _init.ts
@@ -119,6 +119,18 @@ async function readJsonFile(file) {
119
119
  }
120
120
  }
121
121
  /**
122
+ * Directory holding the base scaffold — the files every project gets
123
+ * underneath its template (package.json, tsconfig, `server.mjs`, …).
124
+ *
125
+ * Exported so `aai eject` can copy one file out of it rather than carrying a
126
+ * second copy of that file's contents: the scaffold is the single definition
127
+ * of the self-hosted entrypoint, and a project retrofitted with `eject` gets
128
+ * byte-identical output to one `aai init` scaffolded.
129
+ */
130
+ function scaffoldDir() {
131
+ return path.join(resolveTemplatesDir(), "scaffold");
132
+ }
133
+ /**
122
134
  * Layer the base scaffold (package.json, tsconfig, …) into targetDir
123
135
  * WITHOUT overwriting anything already there. Shared by `aai init`
124
136
  * (underneath a template) and `aai pull` (underneath the studio workspace
@@ -130,14 +142,14 @@ async function readJsonFile(file) {
130
142
  * {@link layerScaffoldManifest}.
131
143
  */
132
144
  async function layerScaffold(targetDir) {
133
- const scaffoldDir = path.join(resolveTemplatesDir(), "scaffold");
134
- if (!existsSync(scaffoldDir)) return;
135
- await fs.cp(scaffoldDir, targetDir, {
145
+ const dir = scaffoldDir();
146
+ if (!existsSync(dir)) return;
147
+ await fs.cp(dir, targetDir, {
136
148
  recursive: true,
137
149
  force: false,
138
150
  errorOnExist: false
139
151
  });
140
- await layerScaffoldManifest(scaffoldDir, targetDir);
152
+ await layerScaffoldManifest(dir, targetDir);
141
153
  }
142
154
  /**
143
155
  * Copy a template into targetDir, merging scaffold files underneath.
@@ -154,4 +166,4 @@ async function downloadAndMergeTemplate(template, targetDir) {
154
166
  await layerScaffold(targetDir);
155
167
  }
156
168
  //#endregion
157
- export { REPO_URL, downloadAndMergeTemplate, layerScaffold, listTemplates };
169
+ export { REPO_URL, downloadAndMergeTemplate, layerScaffold, listTemplates, scaffoldDir };
@@ -35,6 +35,16 @@ type Manifest = Record<string, unknown>;
35
35
  * Returns null when nothing was missing, so the common case writes no file.
36
36
  */
37
37
  export declare function mergeScaffoldManifest(existing: Manifest, scaffold: Manifest): Manifest | null;
38
+ /**
39
+ * Directory holding the base scaffold — the files every project gets
40
+ * underneath its template (package.json, tsconfig, `server.mjs`, …).
41
+ *
42
+ * Exported so `aai eject` can copy one file out of it rather than carrying a
43
+ * second copy of that file's contents: the scaffold is the single definition
44
+ * of the self-hosted entrypoint, and a project retrofitted with `eject` gets
45
+ * byte-identical output to one `aai init` scaffolded.
46
+ */
47
+ export declare function scaffoldDir(): string;
38
48
  /**
39
49
  * Layer the base scaffold (package.json, tsconfig, …) into targetDir
40
50
  * WITHOUT overwriting anything already there. Shared by `aai init`
package/dist/cli.mjs CHANGED
@@ -166,7 +166,7 @@ const list = defineCommand({
166
166
  async run({ args }) {
167
167
  await runCommand(args, async () => {
168
168
  const cwd = resolveCwd();
169
- const { executeList } = await import("./studio-LNvXtWak.mjs");
169
+ const { executeList } = await import("./studio-DPDFKZ9i.mjs");
170
170
  return executeList({
171
171
  cwd,
172
172
  server: args.server
@@ -201,7 +201,7 @@ const pull = defineCommand({
201
201
  async run({ args }) {
202
202
  await runCommand(args, async () => {
203
203
  const cwd = resolveCwd();
204
- const { executePull } = await import("./studio-LNvXtWak.mjs");
204
+ const { executePull } = await import("./studio-DPDFKZ9i.mjs");
205
205
  return executePull({
206
206
  cwd,
207
207
  project: args.project,
@@ -229,7 +229,7 @@ const push = defineCommand({
229
229
  async run({ args }) {
230
230
  await runCommand(args, async () => {
231
231
  const cwd = await setup({ agent: true });
232
- const { executePush } = await import("./studio-LNvXtWak.mjs");
232
+ const { executePush } = await import("./studio-DPDFKZ9i.mjs");
233
233
  return executePush({
234
234
  cwd,
235
235
  server: args.server,
@@ -259,7 +259,7 @@ const publish = defineCommand({
259
259
  async run({ args }) {
260
260
  await runCommand(args, async () => {
261
261
  const cwd = await setup({ agent: true });
262
- const { executePublish } = await import("./studio-LNvXtWak.mjs");
262
+ const { executePublish } = await import("./studio-DPDFKZ9i.mjs");
263
263
  return executePublish({
264
264
  cwd,
265
265
  server: args.server,
@@ -317,7 +317,7 @@ const init = defineCommand({
317
317
  },
318
318
  async run({ args }) {
319
319
  await runCommand(args, async (mode) => {
320
- const { executeInit } = await import("./init-GQsNWkLJ.mjs");
320
+ const { executeInit } = await import("./init-BBAoRI_f.mjs");
321
321
  return executeInit({
322
322
  dir: args.dir,
323
323
  force: args.force,
@@ -396,6 +396,30 @@ const build = defineCommand({
396
396
  });
397
397
  }
398
398
  });
399
+ const eject = defineCommand({
400
+ meta: {
401
+ name: "eject",
402
+ description: "Add the self-hosted server.mjs entrypoint to an older project"
403
+ },
404
+ args: {
405
+ force: {
406
+ type: "boolean",
407
+ alias: "f",
408
+ description: "Replace an existing server.mjs"
409
+ },
410
+ json: sharedArgs.json
411
+ },
412
+ async run({ args }) {
413
+ await runCommand(args, async () => {
414
+ const cwd = await setup({ agent: true });
415
+ const { executeEject } = await import("./eject-DSrAp8PL.mjs");
416
+ return executeEject({
417
+ cwd,
418
+ force: args.force
419
+ });
420
+ });
421
+ }
422
+ });
399
423
  const deploy = defineCommand({
400
424
  meta: {
401
425
  name: "deploy",
@@ -624,7 +648,7 @@ const templates = defineCommand({
624
648
  args: { json: sharedArgs.json },
625
649
  async run({ args }) {
626
650
  await runCommand(args, async (mode) => {
627
- const { listTemplates } = await import("./_templates-BWJOiWOO.mjs");
651
+ const { listTemplates } = await import("./_templates-DmdG-voA.mjs");
628
652
  const names = await listTemplates();
629
653
  if (mode === "human") {
630
654
  for (const name of names) log.message(name);
@@ -648,6 +672,7 @@ const mainCommand = defineCommand({
648
672
  dev,
649
673
  test,
650
674
  build,
675
+ eject,
651
676
  list,
652
677
  pull,
653
678
  push,
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { a as ok, t as CliError } from "./_output-CC300DzW.mjs";
3
+ import { n as log } from "./_ui-DfwfDbT-.mjs";
4
+ import { a as errorMessage, c as readJson, d as writeJson, o as fileExists } from "./_utils-8KKw-bzi.mjs";
5
+ import { scaffoldDir } from "./_templates-DmdG-voA.mjs";
6
+ import path from "node:path";
7
+ import fs from "node:fs/promises";
8
+ //#region eject.ts
9
+ /**
10
+ * `aai eject` — retrofit the self-hosted entrypoint into an existing project.
11
+ *
12
+ * Every project scaffolded from this CLI version onwards already has
13
+ * `server.mjs` and an `npm start` script: the scaffold ships them, so
14
+ * self-hosting is the default rather than something to opt into. This command
15
+ * exists for the projects that predate that — `aai init` before it, or a
16
+ * workspace pulled from a studio that was created earlier — where the files
17
+ * are simply missing.
18
+ *
19
+ * It COPIES from the scaffold rather than writing its own contents. Two
20
+ * definitions of "the self-hosted entrypoint" would drift, and the one nobody
21
+ * runs locally is the one that would rot; this way an ejected project is
22
+ * byte-identical to a freshly scaffolded one.
23
+ */
24
+ /** The file `npm start` runs. Named once — the scaffold ships it under this name. */
25
+ const SERVER_ENTRY = "server.mjs";
26
+ /** The `scripts.start` value that runs {@link SERVER_ENTRY}. */
27
+ const START_SCRIPT = `node ${SERVER_ENTRY}`;
28
+ /**
29
+ * Add `scripts.start` unless the project already declares one.
30
+ *
31
+ * An existing `start` is left alone even under `--force`: `--force` is about
32
+ * replacing the entrypoint file, and silently rewriting the command a project
33
+ * boots with is a different, larger act. The mismatch is reported instead, so
34
+ * the choice stays the author's.
35
+ */
36
+ async function ensureStartScript(cwd) {
37
+ const manifestPath = path.join(cwd, "package.json");
38
+ const manifest = await readJson(manifestPath);
39
+ if (!manifest) {
40
+ log.warn(`No package.json here — add "start": "${START_SCRIPT}" yourself to get \`npm start\`.`);
41
+ return false;
42
+ }
43
+ const existing = manifest.scripts?.start;
44
+ if (existing === START_SCRIPT) return false;
45
+ if (existing !== void 0) {
46
+ log.warn(`Kept your existing "start" script (${existing}) — run \`node ${SERVER_ENTRY}\`.`);
47
+ return false;
48
+ }
49
+ manifest.scripts = {
50
+ ...manifest.scripts,
51
+ start: START_SCRIPT
52
+ };
53
+ await writeJson(manifestPath, manifest);
54
+ return true;
55
+ }
56
+ /**
57
+ * A missing scaffold source means a broken install (or an `AAI_TEMPLATES_DIR`
58
+ * pointed somewhere wrong), not anything the user did — so the message names
59
+ * the file and where it was looked for.
60
+ *
61
+ * Built here and thrown by the caller, the shape `build.ts` uses for the same
62
+ * reason: `useErrorCause` reads a `throw new Error` inside a `catch` as
63
+ * dropping the cause, and cannot see that `CliError` takes its options fourth.
64
+ */
65
+ function scaffoldMissingError(source, err) {
66
+ return new CliError("scaffold_missing", `Could not read the scaffold's ${SERVER_ENTRY} at ${source}: ${errorMessage(err)}`, "Reinstall @alexkroman1/aai-cli.", { cause: err });
67
+ }
68
+ async function executeEject(opts) {
69
+ const { cwd, force } = opts;
70
+ const target = path.join(cwd, SERVER_ENTRY);
71
+ const overwritten = await fileExists(target);
72
+ if (overwritten && !force) throw new CliError("server_exists", `${SERVER_ENTRY} already exists — this project can already be self-hosted.`, `Run \`npm start\`, or re-run with --force to replace it with the current scaffold's copy.`);
73
+ const source = path.join(scaffoldDir(), SERVER_ENTRY);
74
+ try {
75
+ await fs.copyFile(source, target);
76
+ } catch (err) {
77
+ throw scaffoldMissingError(source, err);
78
+ }
79
+ const addedStartScript = await ensureStartScript(cwd);
80
+ log.success(`Wrote ${SERVER_ENTRY}${overwritten ? " (replaced)" : ""}`);
81
+ if (await fileExists(path.join(cwd, "client.tsx"))) log.info("This project has a custom UI: run `aai build` first so it is served.");
82
+ log.info("Next: npm start (PORT and HOST override the 127.0.0.1:3000 default)");
83
+ return ok({
84
+ file: target,
85
+ overwritten,
86
+ addedStartScript
87
+ });
88
+ }
89
+ //#endregion
90
+ export { executeEject };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `aai eject` — retrofit the self-hosted entrypoint into an existing project.
3
+ *
4
+ * Every project scaffolded from this CLI version onwards already has
5
+ * `server.mjs` and an `npm start` script: the scaffold ships them, so
6
+ * self-hosting is the default rather than something to opt into. This command
7
+ * exists for the projects that predate that — `aai init` before it, or a
8
+ * workspace pulled from a studio that was created earlier — where the files
9
+ * are simply missing.
10
+ *
11
+ * It COPIES from the scaffold rather than writing its own contents. Two
12
+ * definitions of "the self-hosted entrypoint" would drift, and the one nobody
13
+ * runs locally is the one that would rot; this way an ejected project is
14
+ * byte-identical to a freshly scaffolded one.
15
+ */
16
+ import { type CommandResult } from "./_output.ts";
17
+ /** The file `npm start` runs. Named once — the scaffold ships it under this name. */
18
+ export declare const SERVER_ENTRY = "server.mjs";
19
+ /** The `scripts.start` value that runs {@link SERVER_ENTRY}. */
20
+ export declare const START_SCRIPT = "node server.mjs";
21
+ type EjectData = {
22
+ /** Absolute path of the entrypoint written. */
23
+ file: string;
24
+ /** True when an existing `server.mjs` was replaced (`--force`). */
25
+ overwritten: boolean;
26
+ /** True when `scripts.start` was added to package.json by this run. */
27
+ addedStartScript: boolean;
28
+ };
29
+ export declare function executeEject(opts: {
30
+ cwd: string;
31
+ force?: boolean | undefined;
32
+ }): Promise<CommandResult<EjectData>>;
33
+ export {};
@@ -75,7 +75,7 @@ function resolveTargetDir(dir) {
75
75
  }
76
76
  /** Publish after init and return deploy metadata if successful. */
77
77
  async function tryPublish(cwd, server) {
78
- const { executePublish } = await import("./studio-LNvXtWak.mjs");
78
+ const { executePublish } = await import("./studio-DPDFKZ9i.mjs");
79
79
  try {
80
80
  const result = await executePublish({
81
81
  cwd,
@@ -94,7 +94,7 @@ async function tryPublish(cwd, server) {
94
94
  }
95
95
  /** Scaffold the project, optionally showing a spinner. */
96
96
  async function scaffoldProject(dir, cwd, template, silent) {
97
- const { runInit } = await import("./_init-CYLvYU-B.mjs");
97
+ const { runInit } = await import("./_init-5hHkHe-V.mjs");
98
98
  const s = silent ? void 0 : p.spinner();
99
99
  s?.start(`Creating ${dir}`);
100
100
  await runInit({
@@ -54,6 +54,29 @@ The scaffold's `package.json` exposes `dev`, `build`, `test`, and `deploy`
54
54
  as `pnpm <name>` shortcuts. Other commands (`init`, `templates`, `delete`, `secret`)
55
55
  are CLI-only.
56
56
 
57
+ ## Running it yourself (`npm start`)
58
+
59
+ `server.mjs` serves this agent from a plain Node process — no CLI, no
60
+ bundler, no platform account. It is the deployment counterpart of `aai dev`:
61
+
62
+ ```sh
63
+ npm start # http://127.0.0.1:3000
64
+ PORT=8080 HOST=0.0.0.0 npm start # bind every interface, e.g. in a container
65
+ ```
66
+
67
+ Secrets work the same as everywhere else: `ctx.env` holds the keys declared
68
+ in `.env` (or `.env.example`), and a real environment variable of that name
69
+ wins — so `docker run -e MY_API_KEY=…` needs no `.env` in the image.
70
+
71
+ Two things to know. It binds **loopback by default**, because this server has
72
+ no request authentication of its own; set `HOST=0.0.0.0` only behind your own
73
+ proxy or auth. And with a custom `client.tsx`, run `npm run build` first —
74
+ otherwise it serves the default UI and says so at startup.
75
+
76
+ Deleting `server.mjs` costs nothing: `aai dev`, `aai publish` and the managed
77
+ platform never read it. `run_code` is the one feature that does not follow —
78
+ it needs the platform's sandbox and refuses outside one.
79
+
57
80
  ## Project structure
58
81
 
59
82
  ```text
@@ -6,20 +6,21 @@
6
6
  },
7
7
  "scripts": {
8
8
  "dev": "aai dev",
9
+ "start": "node server.mjs",
9
10
  "build": "aai build",
10
11
  "test": "aai test",
11
12
  "publish:agent": "aai publish"
12
13
  },
13
14
  "dependencies": {
14
- "@alexkroman1/aai": "^5.10.1",
15
- "@alexkroman1/aai-ui": "^5.10.1",
15
+ "@alexkroman1/aai": "^5.11.0",
16
+ "@alexkroman1/aai-ui": "^5.11.0",
16
17
  "react": "^19.2.8",
17
18
  "react-dom": "^19.2.8",
18
19
  "tailwindcss": "^4.0.0",
19
20
  "zod": "^4.4.3"
20
21
  },
21
22
  "devDependencies": {
22
- "@alexkroman1/aai-cli": "^5.10.1",
23
+ "@alexkroman1/aai-cli": "^5.11.0",
23
24
  "@tailwindcss/vite": "^4.3.3",
24
25
  "@types/node": "^26.1.1",
25
26
  "@types/react": "^19.2.17",
@@ -0,0 +1,156 @@
1
+ // Self-hosted entrypoint — this is what `npm start` runs.
2
+ //
3
+ // It serves the agent over HTTP + WebSocket from your own Node process: no
4
+ // platform account, no CLI at run time, no bundler. `aai dev` is the
5
+ // development counterpart (file watching, Vite, a browser that opens itself);
6
+ // this file is the deployment.
7
+ //
8
+ // npm start # http://127.0.0.1:3000
9
+ // PORT=8080 HOST=0.0.0.0 npm start # bind every interface, e.g. in a container
10
+ //
11
+ // Anything that can run Node can host it: copy the project, install
12
+ // dependencies, provide the secrets, run this file. Deleting it costs nothing
13
+ // — `aai dev`, `aai publish` and the managed platform never read it.
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { readFile } from "node:fs/promises";
17
+ import { registerHooks } from "node:module";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { parseEnv } from "node:util";
21
+ import { createAgentServer, withHostCredentialFallback } from "@alexkroman1/aai/runtime";
22
+ import { defaultClientDir } from "@alexkroman1/aai-ui/client-dir";
23
+
24
+ const root = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ /**
27
+ * Teach Node the two non-JavaScript import shapes the `aai` bundler supports,
28
+ * so one `agent.ts` runs unchanged under `aai dev`, `aai publish`, and here.
29
+ *
30
+ * - `import prompt from "./system-prompt.md?raw"` — a Vite convention; Node
31
+ * would look for a file literally named `system-prompt.md?raw`.
32
+ * - `import data from "./data.json"` with no import attribute — TypeScript's
33
+ * `resolveJsonModule` allows it; Node requires `with { type: "json" }` and
34
+ * otherwise fails with ERR_IMPORT_ATTRIBUTE_MISSING. An import that DOES
35
+ * carry the attribute is left to Node, whose own handling is correct.
36
+ *
37
+ * Nothing is transformed beyond that: `.ts` itself needs no help, because Node
38
+ * strips the types natively (this project needs Node 24+). That is why there
39
+ * is no build step here, and no second copy of the agent in JavaScript that
40
+ * could drift from the one you deploy.
41
+ */
42
+ registerHooks({
43
+ resolve(specifier, context, nextResolve) {
44
+ if (specifier.startsWith(".") && specifier.endsWith("?raw")) {
45
+ // Resolved by hand rather than through nextResolve: the default
46
+ // resolver has no format for `.md` and the query would be lost anyway.
47
+ return {
48
+ url: new URL(specifier, context.parentURL).href,
49
+ format: "module",
50
+ shortCircuit: true,
51
+ };
52
+ }
53
+ return nextResolve(specifier, context);
54
+ },
55
+ load(url, context, nextLoad) {
56
+ const asRaw = url.endsWith("?raw");
57
+ const asBareJson = url.endsWith(".json") && context.importAttributes?.type !== "json";
58
+ if (!(asRaw || asBareJson)) return nextLoad(url, context);
59
+ const text = readFileSync(fileURLToPath(asRaw ? url.slice(0, -"?raw".length) : url), "utf-8");
60
+ return {
61
+ format: "module",
62
+ // `export default <literal>` for both: a JSON document is already a
63
+ // valid JS expression, and JSON.stringify makes any file safe to embed
64
+ // as a string. Emitting `format: "json"` instead would put the import
65
+ // back under the attribute check this exists to satisfy.
66
+ source: `export default ${asRaw ? JSON.stringify(text) : text};`,
67
+ shortCircuit: true,
68
+ };
69
+ },
70
+ });
71
+
72
+ // Imported dynamically, and this is load-bearing: static `import` statements
73
+ // are hoisted and evaluated BEFORE any statement in this file, so an
74
+ // `import agent from "./agent.ts"` at the top would load the agent — and every
75
+ // `?raw` import inside it — before the hooks above were ever registered.
76
+ const { default: agent } = await import("./agent.ts");
77
+
78
+ /** Parse a dotenv-syntax file into a record; `{}` when it does not exist. */
79
+ async function readEnvFile(file) {
80
+ try {
81
+ return parseEnv(await readFile(path.join(root, file), "utf-8"));
82
+ } catch (err) {
83
+ // Absent is normal — `.env` is gitignored, and a container usually has
84
+ // neither file. Unreadable is not: the agent would boot with no
85
+ // credentials and fail later as an opaque provider auth error.
86
+ if (err.code !== "ENOENT") throw err;
87
+ return {};
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Build `ctx.env` — what this agent's own tool code reads.
93
+ *
94
+ * The rule is the one `aai dev` follows: only DECLARED keys are exposed, and a
95
+ * real environment variable wins over the file's value. Nothing else from
96
+ * process.env comes along, so the agent cannot come to depend on a variable
97
+ * (PATH, HOME, …) that will not exist wherever you deploy it.
98
+ *
99
+ * `.env.example` counts as a declaration too, which is what lets a container
100
+ * run with no `.env` at all: the committed file names the secrets the agent
101
+ * needs, and `docker run -e MY_API_KEY=…` supplies the values. Declare
102
+ * `DATABASE_URL` the same way to give your tools `ctx.db`.
103
+ */
104
+ async function resolveAgentEnv() {
105
+ const declared = { ...(await readEnvFile(".env.example")), ...(await readEnvFile(".env")) };
106
+ const env = {};
107
+ for (const [key, fileValue] of Object.entries(declared)) {
108
+ const value = process.env[key] ?? fileValue;
109
+ // An empty value is worse than a missing one: a provider would try to
110
+ // authenticate with "" rather than report the credential as absent. The
111
+ // example file is full of them by design (`BRAVE_API_KEY=`).
112
+ if (value !== "") env[key] = value;
113
+ }
114
+ return env;
115
+ }
116
+
117
+ /**
118
+ * Static assets served at `/`: this project's own UI once `client.tsx` has been
119
+ * built (`npm run build` leaves it in `.aai/client`), otherwise the prebuilt
120
+ * default client that ships inside @alexkroman1/aai-ui.
121
+ */
122
+ function resolveClientDir() {
123
+ const built = path.join(root, ".aai", "client");
124
+ if (existsSync(path.join(built, "index.html"))) return built;
125
+ if (existsSync(path.join(root, "client.tsx"))) {
126
+ console.warn("client.tsx is not built — serving the default UI. Run `npm run build` first.");
127
+ }
128
+ return defaultClientDir();
129
+ }
130
+
131
+ const env = await resolveAgentEnv();
132
+
133
+ const server = createAgentServer({
134
+ agent,
135
+ env,
136
+ // Provider credentials may ALSO arrive straight from the environment without
137
+ // being declared, and without becoming ctx.env — the ordinary way to hand
138
+ // ASSEMBLYAI_API_KEY to a container. Anything in `env` still wins.
139
+ providerEnv: withHostCredentialFallback(env),
140
+ clientDir: resolveClientDir(),
141
+ });
142
+
143
+ // Loopback by default: this server has no request authentication of its own,
144
+ // so exposing it is a deliberate act. Set HOST=0.0.0.0 to bind every interface
145
+ // behind your own proxy or auth. An empty HOST means unset, not "everything".
146
+ const host = process.env.HOST?.trim() || undefined;
147
+ await server.listen(Number(process.env.PORT ?? 3000), host);
148
+ console.log(`${agent.name} listening on http://${host ?? "127.0.0.1"}:${server.port}`);
149
+
150
+ for (const signal of ["SIGINT", "SIGTERM"]) {
151
+ process.once(signal, async () => {
152
+ // close() shuts the runtime down too — no separate runtime.shutdown().
153
+ await server.close();
154
+ process.exit(0);
155
+ });
156
+ }
@@ -4,7 +4,7 @@ import { n as log, t as fmtUrl } from "./_ui-DfwfDbT-.mjs";
4
4
  import { s as updateProjectConfig } from "./_config-DMyolIk9.mjs";
5
5
  import { t as resolveServerEnv } from "./_server-common-DX8Bfrf5.mjs";
6
6
  import { i as resolveDeployTarget } from "./_agent-DS2PUJcl.mjs";
7
- import { layerScaffold } from "./_templates-BWJOiWOO.mjs";
7
+ import { layerScaffold } from "./_templates-DmdG-voA.mjs";
8
8
  import { n as apiRequest } from "./_api-client-B-upMGkc.mjs";
9
9
  import path from "node:path";
10
10
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
@@ -93,19 +93,30 @@ async function collectSourceFiles(dir) {
93
93
  const files = {};
94
94
  const warnings = [];
95
95
  if (paths.length > 100) warnings.push(`Project has ${paths.length} files; only the first 100 sync to the studio.`);
96
- for (const rel of paths.slice(0, 100)) {
96
+ const read = await Promise.all(paths.slice(0, 100).map(async (rel) => {
97
97
  const abs = path.join(dir, rel);
98
98
  const st = await stat(abs);
99
- if (st.size > 256e3) {
100
- warnings.push(`${rel} is ${st.size} bytes (max ${MAX_STUDIO_FILE_BYTES}) — not synced.`);
101
- continue;
102
- }
99
+ if (st.size > 256e3) return {
100
+ ok: false,
101
+ warning: `${rel} is ${st.size} bytes (max ${MAX_STUDIO_FILE_BYTES}) — not synced.`
102
+ };
103
103
  const content = decodeUtf8(await readFile(abs));
104
- if (content === null) {
105
- warnings.push(`${rel} is not valid UTF-8 (binary file?) — not synced.`);
104
+ if (content === null) return {
105
+ ok: false,
106
+ warning: `${rel} is not valid UTF-8 (binary file?) — not synced.`
107
+ };
108
+ return {
109
+ ok: true,
110
+ rel,
111
+ content
112
+ };
113
+ }));
114
+ for (const entry of read) {
115
+ if (!entry.ok) {
116
+ warnings.push(entry.warning);
106
117
  continue;
107
118
  }
108
- files[rel.split(path.sep).join("/")] = content;
119
+ files[entry.rel.split(path.sep).join("/")] = entry.content;
109
120
  }
110
121
  return {
111
122
  files,
@@ -188,17 +199,40 @@ async function executeList(opts) {
188
199
  }
189
200
  /** Write a pulled file map under `dir`, refusing paths that escape it. */
190
201
  async function materializeFiles(dir, files) {
191
- for (const [rel, content] of Object.entries(files)) {
202
+ const targets = Object.entries(files).map(([rel, content]) => {
192
203
  const abs = path.resolve(dir, rel);
193
204
  if (abs !== dir && !abs.startsWith(dir + path.sep)) throw new Error(`Pulled file path escapes the project directory: ${rel}`);
205
+ return {
206
+ abs,
207
+ content
208
+ };
209
+ });
210
+ await Promise.all(targets.map(async ({ abs, content }) => {
194
211
  await mkdir(path.dirname(abs), { recursive: true });
195
212
  await writeFile(abs, content, "utf-8");
196
- }
213
+ }));
214
+ }
215
+ /**
216
+ * The hint for a pull that found nothing, which is where the two causes have
217
+ * to be told apart — and only the project LIST can do it. A typo has other
218
+ * projects beside it; an empty list means this login sees no projects at all,
219
+ * i.e. the CLI is authenticated as a different account than the browser the
220
+ * project was created in (the account's key is what decides studio scope —
221
+ * see `resolveBearer` server-side). Naming the visible projects is also the
222
+ * answer to a typo, so the round trip pays for itself either way. Best
223
+ * effort: the list is a second request on an already-failing path, and its
224
+ * own failure must not replace the 404 the user needs to see.
225
+ */
226
+ async function notFoundHint(serverUrl, apiKey) {
227
+ const projects = await listStudioProjects(serverUrl, apiKey).catch(() => null);
228
+ if (projects === null) return "Run `aai list` to see your projects.";
229
+ if (projects.length === 0) return "This login has no studio projects at all. If yours are in the studio, the CLI is linked to a different account — run `aai login` again, approve it in a browser signed in to the account that owns the project, then `aai list`.";
230
+ return `Your projects: ${projects.slice(0, 10).join(", ")}${projects.length > 10 ? `, and ${projects.length - 10} more` : ""}.`;
197
231
  }
198
232
  async function executePull(opts) {
199
233
  const { serverUrl, apiKey } = await resolveDeployTarget(opts.cwd, opts.server);
200
234
  const remote = await fetchStudioProject(serverUrl, apiKey, opts.project);
201
- if (!remote) throw new CliError("not_found", `No studio project named "${opts.project}".`, "Run `aai list` to see your projects.");
235
+ if (!remote) throw new CliError("not_found", `No studio project named "${opts.project}".`, await notFoundHint(serverUrl, apiKey));
202
236
  const target = path.resolve(opts.cwd, opts.dir ?? opts.project);
203
237
  if ((await readdir(target).catch(() => [])).length > 0 && !opts.force) throw new CliError("dir_not_empty", `${target} is not empty.`, "Pull into a fresh directory, or pass --force to overwrite files in place.");
204
238
  await materializeFiles(target, remote.files);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "5.10.1",
3
+ "version": "5.11.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -38,8 +38,8 @@
38
38
  "p-timeout": "^7.0.1",
39
39
  "vite": "^8.1.5",
40
40
  "zod": "^4.4.3",
41
- "@alexkroman1/aai": "5.10.1",
42
- "@alexkroman1/aai-ui": "5.10.1"
41
+ "@alexkroman1/aai": "5.11.0",
42
+ "@alexkroman1/aai-ui": "5.11.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "playwright": "^1.61.1",