@nylorun/runtime 0.2.1-beta → 0.4.0-beta

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - fa1860a: Use standard MODEL_PROVIDER, MODEL, MODEL_PROVIDER_API_KEY, and MODEL_PROVIDER_BASE_URL environment configuration. Export starter Hono apps and provide CLI development and production Node launchers. Existing starters require manual migration. Release preparation must update the creator Runtime compatibility pin with this release.
8
+
9
+ ## 0.3.0-beta
10
+
11
+ ### Minor Changes
12
+
13
+ - 9c350be: Provide `nylorun dev` with optional Studio and browser opening, automatic development loopback CORS, and inferred Hono mount paths. Move local model selection to `.env/model.json` with legacy fallback and migration. Generate starters without copied launcher scripts, a top-level config directory, or a separate TypeScript build config. Release preparation must update the creator's Runtime compatibility pin together with these changes.
14
+
3
15
  ## 0.2.1-beta
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -8,7 +8,7 @@ import { Runtime, serveAgents } from "@nylorun/runtime";
8
8
  const runtime = new Runtime();
9
9
  app.route(
10
10
  "/agents",
11
- serveAgents({ agents, runtime, basePath: "/agents" })
11
+ serveAgents({ agents, runtime })
12
12
  );
13
13
  ```
14
14
 
@@ -16,22 +16,50 @@ app.route(
16
16
 
17
17
  The application owns Hono composition, authentication, CORS, logging, process lifecycle, and deployment. Runtime owns agent sessions, durability, media, and AG-UI/session protocol routes. Graceful shutdown is optional: if the application installs signal handlers and wants to drain live sessions, flush pending journal writes, and run optional agent cleanup, it should await `runtime.close()`. An application that does not install handlers exits normally on its host's shutdown policy; `runtime.close()` does not run on crash, OOM, or SIGKILL.
18
18
 
19
- Runtime publishes root-relative discovery and endpoint URLs. `basePath` must match the Hono mount path so advertised manifests and AG-UI endpoints resolve. Agent-scoped routes are `/:id/...` inside the router, so mounting at `/agents` with `basePath: "/agents"` yields `/agents/:id/...`. Pass the same prefix for other mounts:
19
+ Runtime publishes root-relative discovery and endpoint URLs. It infers the Hono
20
+ mount from each request URL (so a separate consumer `hono` install still works).
21
+ Mount at `/agents` or `/api/agents` without repeating that path in `serveAgents`.
22
+ Pass explicit `basePath` when a reverse proxy rewrites the public prefix.
20
23
 
21
- ```ts
22
- app.route(
23
- "/api/agents",
24
- serveAgents({ agents, runtime, basePath: "/api/agents" })
25
- );
26
- ```
24
+ `nylorun dev` enables local Studio connections automatically by setting `NYLORUN_DEV=1` for its child application. This allows HTTP/HTTPS browser origins on `localhost`, `127.0.0.1`, or `[::1]`, including Studio's fallback ports. Ordinary production startup does not enable this policy; the application owns production CORS and authorization.
27
25
 
28
26
  `getActor(context)` supplies an optional actor id and session context for newly created sessions. `getRequestMetadata(context)` supplies JSON-safe metadata for inbound messages. Application middleware remains responsible for authorizing every agent route.
29
27
 
30
28
  ## Commands
31
29
 
32
30
  - `nylorun configure`
31
+ - `nylorun dev [--no-studio] [--no-open]`
32
+ - `nylorun start [entry]` (default: `dist/src/index.js`)
33
33
  - `nylorun studio --agent-url http://localhost:3000/agents [--port 4161] [--no-open]`
34
34
 
35
- Studio attaches to an application you run. Use your own TypeScript/build tooling and a Node adapter such as `@hono/node-server` when applicable. `projectAsset("agents/skills/catalog")` resolves bundled application assets from source or a compiled `dist/` deployment.
35
+ Export your Hono application with `export default app`. The CLI supplies the Node server adapter. `nylorun dev` watches `src/index.ts`, waits for `/agents/v1/agents`, and starts project-local Studio. `PORT` defaults to 3000. `--no-studio` runs only the application; `--no-open` keeps the browser closed. Ctrl-C stops both processes. `nylorun start` serves the built app without Studio or development CORS.
36
+
37
+ ### Environment configuration
38
+
39
+ Copy `.env.example` to `.env` and fill it in, or run `nylorun configure` before your agent graph is importable:
40
+
41
+ ```dotenv
42
+ MODEL_PROVIDER=custom
43
+ MODEL=your-model-id
44
+ MODEL_PROVIDER_API_KEY=your-key
45
+ MODEL_PROVIDER_BASE_URL=https://your-provider.example/v1
46
+ ```
47
+
48
+ `MODEL_PROVIDER_BASE_URL` is required only for `MODEL_PROVIDER=custom`; omit it for built-in providers. `configure`, `dev`, and `start` load `.env` before importing the app. Existing process variables win; a missing `.env` is valid. `.env.local` is ignored by Git but is not automatically loaded. Direct imports of Runtime do not load dotenv files.
49
+
50
+ Explicit `piModel({ selection })` options take precedence over environment selection. Environment selection takes precedence over legacy files; incomplete selection produces an error. `MODEL_PROVIDER_API_KEY` overrides provider-native variables such as `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`, which override stored credentials. API-key deployments require no credential files. Supply the same variables through your hosting provider's environment settings.
51
+
52
+ The optional wizard writes selection and entered API keys to `.env`, preserving unrelated configuration. It reuses environment credentials without copying them into the file. OAuth is an alternative where supported; its credentials and refresh state live in ignored `.nylorun/auth.json`. Keep `.env` and `.nylorun/` private.
53
+
54
+ ### Upgrading an existing starter
55
+
56
+ Migration is manual; the CLI will not replace a legacy `.env` directory.
57
+
58
+ 1. Back up the existing `.env/` directory to a private location outside the project before replacing it with a file. Preserve any `config/model.json` too.
59
+ 2. Translate `model.json` fields `provider`, `model`, and `custom.baseUrl` into `MODEL_PROVIDER`, `MODEL`, and `MODEL_PROVIDER_BASE_URL`. Copy API keys into `MODEL_PROVIDER_API_KEY` or provider-native variables. Replace the old `NYLO_CUSTOM_API_KEY` variable with `MODEL_PROVIDER_API_KEY`.
60
+ 3. Merge `integrations.env` variables into `.env`. Move OAuth records into `.nylorun/auth.json`. Add `.env`, `.env.local`, and `.nylorun/` to `.gitignore`; remove the old `.env/` exceptions.
61
+ 4. Replace the entrypoint's `serve(...)` call and Node adapter import with `export default app`. Set scripts to `"dev": "nylorun dev"` and `"start": "nylorun start"`. Remove the application's `@hono/node-server` dependency if unused elsewhere. Keep your build and asset-copy steps.
62
+
63
+ Runtime retains legacy `.env/model.json`, `config/model.json`, and `.env/auth.json` reads for existing applications using their own launcher. It does not automatically move or delete these files. Use the matching Runtime release pinned by the new creator; older releases cannot launch an exported app.
36
64
 
37
- Provider credentials are stored in `.env/auth.json`, and selection in `config/model.json`. `nylorun configure` can run before an agent graph is importable.
65
+ The exported app follows Hono composition conventions. This change does not establish verified serverless persistence, execution lifetime, or Workers support. `projectAsset()` resolves bundled assets from source or compiled deployments.
package/dist/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
3
- import { loadEnvFile } from "node:process";
4
2
  import { join } from "node:path";
5
3
  import { pathToFileURL } from "node:url";
6
4
  import { createRequire } from "node:module";
7
- import { ConfigurationCancelled, configureProvider } from "./model/configure.js";
8
- const usage = `nylorun <configure|studio>
5
+ import { ConfigurationCancelled, configureProvider, } from "./model/configure.js";
6
+ import { loadProjectEnvironment } from "./environment.js";
7
+ import { develop } from "./dev.js";
8
+ const usage = `nylorun <configure|dev|start|studio>
9
+ dev [--no-studio] [--no-open]
10
+ start [entry]
9
11
  configure
10
12
  studio --agent-url <http(s)-url> [--port <n>] [--no-open]`;
11
13
  async function startStudio(agentServerUrl, open, port) {
@@ -17,7 +19,11 @@ async function startStudio(agentServerUrl, open, port) {
17
19
  throw new Error("Install @nylorun/studio to use the Studio dashboard.");
18
20
  }
19
21
  const studio = await import(pathToFileURL(entry).href);
20
- return studio.startStudio({ agentServerUrl, open, ...(port === undefined ? {} : { port }) });
22
+ return studio.startStudio({
23
+ agentServerUrl,
24
+ open,
25
+ ...(port === undefined ? {} : { port }),
26
+ });
21
27
  }
22
28
  function parsePort(value) {
23
29
  if (value === undefined)
@@ -31,6 +37,18 @@ async function main() {
31
37
  const [command, ...args] = process.argv.slice(2);
32
38
  if (!command || command === "--help" || command === "-h")
33
39
  return void console.log(usage);
40
+ if (["configure", "dev", "start"].includes(command))
41
+ loadProjectEnvironment();
42
+ if (command === "start") {
43
+ if (args.length > 1 || args[0]?.startsWith("--"))
44
+ throw new Error(usage);
45
+ await (await import("./launcher.js")).start(args[0]);
46
+ return;
47
+ }
48
+ if (command === "dev") {
49
+ process.exitCode = await develop(args);
50
+ return;
51
+ }
34
52
  if (command === "configure") {
35
53
  if (args.length)
36
54
  throw new Error(usage);
@@ -38,9 +56,6 @@ async function main() {
38
56
  const cancel = (signal) => controller.abort(new ConfigurationCancelled(signal));
39
57
  process.once("SIGINT", () => cancel("SIGINT"));
40
58
  process.once("SIGTERM", () => cancel("SIGTERM"));
41
- const integrations = join(process.cwd(), ".env", "integrations.env");
42
- if (existsSync(integrations))
43
- loadEnvFile(integrations);
44
59
  await configureProvider({ signal: controller.signal });
45
60
  return;
46
61
  }
@@ -80,5 +95,6 @@ async function main() {
80
95
  }
81
96
  void main().catch((error) => {
82
97
  console.error(error instanceof Error ? error.message : String(error));
83
- process.exitCode = error instanceof ConfigurationCancelled ? error.exitCode : 1;
98
+ process.exitCode =
99
+ error instanceof ConfigurationCancelled ? error.exitCode : 1;
84
100
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { start } from "./launcher.js";
2
+ await start(process.argv[2] ?? "src/index.ts", true);
package/dist/dev.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Runs project development tooling without copying a supervisor into each application. */
2
+ export declare function develop(args: readonly string[]): Promise<number>;
package/dist/dev.js ADDED
@@ -0,0 +1,126 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ /** Runs project development tooling without copying a supervisor into each application. */
7
+ export async function develop(args) {
8
+ const usage = "Usage: nylorun dev [--no-studio] [--no-open]";
9
+ if (new Set(args).size !== args.length ||
10
+ args.some((arg) => !["--no-studio", "--no-open"].includes(arg)))
11
+ throw new Error(usage);
12
+ const port = Number(process.env.PORT ?? "3000");
13
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
14
+ throw new Error("PORT must be an integer between 1 and 65535.");
15
+ const require = createRequire(join(process.cwd(), "package.json"));
16
+ let tsx;
17
+ try {
18
+ tsx = require.resolve("tsx/cli");
19
+ }
20
+ catch {
21
+ throw new Error("Install tsx in your project to use nylorun dev.");
22
+ }
23
+ if (!args.includes("--no-studio")) {
24
+ try {
25
+ require.resolve("@nylorun/studio");
26
+ }
27
+ catch {
28
+ throw new Error("Install @nylorun/studio or use nylorun dev --no-studio.");
29
+ }
30
+ }
31
+ const controller = new AbortController();
32
+ const children = new Set();
33
+ const exits = [];
34
+ let result = 0;
35
+ let force;
36
+ const stop = (code, signal = "SIGTERM") => {
37
+ if (controller.signal.aborted)
38
+ return;
39
+ result = code;
40
+ controller.abort();
41
+ for (const child of children)
42
+ child.kill(signal);
43
+ force = setTimeout(() => {
44
+ for (const child of children)
45
+ child.kill("SIGKILL");
46
+ }, 5_000);
47
+ force.unref();
48
+ };
49
+ const interrupt = () => stop(130, "SIGINT");
50
+ const terminate = () => stop(143);
51
+ process.once("SIGINT", interrupt);
52
+ process.once("SIGTERM", terminate);
53
+ const launch = (argv) => {
54
+ const child = spawn(process.execPath, argv, {
55
+ stdio: "inherit",
56
+ env: { ...process.env, NYLORUN_DEV: "1" },
57
+ });
58
+ children.add(child);
59
+ exits.push(new Promise((resolve) => {
60
+ child.once("error", (error) => {
61
+ console.error(`Could not start development process: ${error.message}`);
62
+ stop(1);
63
+ });
64
+ child.once("close", (code, signal) => {
65
+ children.delete(child);
66
+ stop(code ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1));
67
+ resolve();
68
+ });
69
+ }));
70
+ };
71
+ try {
72
+ launch([
73
+ tsx,
74
+ "watch",
75
+ fileURLToPath(new URL("./dev-entry.js", import.meta.url)),
76
+ "src/index.ts",
77
+ ]);
78
+ if (!args.includes("--no-studio")) {
79
+ const url = `http://127.0.0.1:${port}/agents/v1/agents`;
80
+ const deadline = Date.now() + 20_000;
81
+ let ready = false;
82
+ while (!controller.signal.aborted && Date.now() < deadline) {
83
+ try {
84
+ const response = await fetch(url, {
85
+ signal: AbortSignal.any([
86
+ controller.signal,
87
+ AbortSignal.timeout(500),
88
+ ]),
89
+ });
90
+ ready = response.ok;
91
+ await response.body?.cancel();
92
+ if (ready)
93
+ break;
94
+ }
95
+ catch {
96
+ /* Watch mode may still be compiling or restarting the app. */
97
+ }
98
+ await delay(50, undefined, { signal: controller.signal }).catch(() => { });
99
+ }
100
+ if (!controller.signal.aborted) {
101
+ if (!ready)
102
+ throw new Error(`Application did not become ready at ${url} within 20 seconds.`);
103
+ launch([
104
+ fileURLToPath(new URL("./cli.js", import.meta.url)),
105
+ "studio",
106
+ "--agent-url",
107
+ `http://localhost:${port}/agents`,
108
+ ...(args.includes("--no-open") ? ["--no-open"] : []),
109
+ ]);
110
+ }
111
+ }
112
+ await Promise.all(exits);
113
+ }
114
+ catch (error) {
115
+ stop(1);
116
+ await Promise.all(exits);
117
+ throw error;
118
+ }
119
+ finally {
120
+ if (force !== undefined)
121
+ clearTimeout(force);
122
+ process.removeListener("SIGINT", interrupt);
123
+ process.removeListener("SIGTERM", terminate);
124
+ }
125
+ return result;
126
+ }
@@ -0,0 +1,2 @@
1
+ export declare function loadProjectEnvironment(root?: string): void;
2
+ export declare function saveEnvironment(root: string, updates: Record<string, string | undefined>, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,64 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { writeFile, rename, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { loadEnvFile } from "node:process";
6
+ export function loadProjectEnvironment(root = process.cwd()) {
7
+ const file = join(root, ".env");
8
+ try {
9
+ if (statSync(file).isDirectory())
10
+ throw new Error("The .env directory must be migrated manually: back it up, create a .env file with MODEL_PROVIDER, MODEL and MODEL_PROVIDER_API_KEY, and move OAuth credentials to .nylorun/auth.json. See the Runtime migration guide.");
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return;
15
+ throw error;
16
+ }
17
+ loadEnvFile(file);
18
+ }
19
+ // Match complete dotenv assignments, including quoted multiline values.
20
+ const assignment = /^(?:export\s+)?([\w]+)[\t ]*=[\t ]*(?:"[^"]*"|'[^']*'|`[^`]*`|[^#\r\n]*)([^\r\n]*)(?:\r?\n|$)/gm;
21
+ export async function saveEnvironment(root, updates, signal) {
22
+ const file = join(root, ".env");
23
+ let contents = "";
24
+ try {
25
+ contents = readFileSync(file, "utf8");
26
+ }
27
+ catch (error) {
28
+ if (error.code !== "ENOENT")
29
+ throw error;
30
+ }
31
+ const encode = (value) => {
32
+ // Node's dotenv parser has no general quote-escaping syntax. Select a
33
+ // delimiter absent from the value rather than changing the credential.
34
+ for (const quote of ["'", '"', "`"]) {
35
+ if (!value.includes(quote) && !(quote === '"' && /\\[nr]/.test(value)))
36
+ return quote + value + quote;
37
+ }
38
+ throw new Error("This value contains all dotenv quote delimiters; set it through your process environment instead.");
39
+ };
40
+ const remaining = new Set(Object.keys(updates));
41
+ contents = contents.replace(assignment, (whole, key, suffix) => {
42
+ if (!(key in updates))
43
+ return whole;
44
+ if (!remaining.delete(key))
45
+ return "";
46
+ return updates[key] === undefined
47
+ ? ""
48
+ : `${key}=${encode(updates[key])}${suffix}\n`;
49
+ });
50
+ if (contents && !contents.endsWith("\n"))
51
+ contents += "\n";
52
+ for (const key of remaining)
53
+ if (updates[key] !== undefined)
54
+ contents += `${key}=${encode(updates[key])}\n`;
55
+ const temporary = join(root, `.env-${randomUUID()}.tmp`);
56
+ try {
57
+ await writeFile(temporary, contents, { mode: 0o600, signal });
58
+ signal?.throwIfAborted();
59
+ await rename(temporary, file);
60
+ }
61
+ finally {
62
+ await rm(temporary, { force: true });
63
+ }
64
+ }
@@ -0,0 +1 @@
1
+ export declare function start(entry?: string, development?: boolean): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { loadProjectEnvironment } from "./environment.js";
5
+ export async function start(entry = "dist/src/index.js", development = false) {
6
+ loadProjectEnvironment();
7
+ if (development)
8
+ process.env.NYLORUN_DEV = "1";
9
+ else
10
+ delete process.env.NYLORUN_DEV;
11
+ const port = Number(process.env.PORT ?? "3000");
12
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
13
+ throw new Error("PORT must be an integer between 1 and 65535.");
14
+ const { default: app } = await import(pathToFileURL(resolve(entry)).href);
15
+ if (!app || typeof app.fetch !== "function")
16
+ throw new Error(`${entry} must export a Hono application with 'export default app' and a callable fetch.`);
17
+ const server = serve({ fetch: app.fetch.bind(app), port }, (info) => {
18
+ console.log(`Server is running on http://localhost:${info.port}`);
19
+ });
20
+ const stop = () => {
21
+ process.removeListener("SIGINT", stop);
22
+ process.removeListener("SIGTERM", stop);
23
+ server.close(() => process.exit(0));
24
+ setTimeout(() => process.exit(0), 5_000).unref();
25
+ };
26
+ process.once("SIGINT", stop);
27
+ process.once("SIGTERM", stop);
28
+ }
@@ -2,7 +2,8 @@ import type { Credential, CredentialInfo, CredentialStore } from "@earendil-work
2
2
  export declare class ProjectCredentialStore implements CredentialStore {
3
3
  #private;
4
4
  private readonly file;
5
- constructor(file?: string);
5
+ private readonly legacyFile?;
6
+ constructor(file?: string, legacyFile?: string | undefined);
6
7
  read(providerId: string): Promise<Credential | undefined>;
7
8
  list(): Promise<readonly CredentialInfo[]>;
8
9
  modify(providerId: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>): Promise<Credential | undefined>;
@@ -1,10 +1,13 @@
1
+ var _a;
1
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  export class ProjectCredentialStore {
4
5
  file;
6
+ legacyFile;
5
7
  #chain = Promise.resolve();
6
- constructor(file = join(process.cwd(), ".env", "auth.json")) {
8
+ constructor(file = join(process.cwd(), ".nylorun", "auth.json"), legacyFile) {
7
9
  this.file = file;
10
+ this.legacyFile = legacyFile;
8
11
  }
9
12
  async read(providerId) {
10
13
  return (await this.#all())[providerId];
@@ -54,8 +57,11 @@ export class ProjectCredentialStore {
54
57
  return JSON.parse(await readFile(this.file, "utf8"));
55
58
  }
56
59
  catch (error) {
57
- if (error.code === "ENOENT")
60
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? "")) {
61
+ if (this.legacyFile)
62
+ return new _a(this.legacyFile).#all();
58
63
  return {};
64
+ }
59
65
  throw error;
60
66
  }
61
67
  }
@@ -83,3 +89,4 @@ export class ProjectCredentialStore {
83
89
  }
84
90
  }
85
91
  }
92
+ _a = ProjectCredentialStore;
@@ -1,7 +1,6 @@
1
- import { mkdir, writeFile, rename, rm } from "node:fs/promises";
2
- import { randomUUID } from "node:crypto";
3
1
  import { join } from "node:path";
4
2
  import { createInterface } from "node:readline/promises";
3
+ import { saveEnvironment } from "../environment.js";
5
4
  import { ProjectCredentialStore } from "./auth-store.js";
6
5
  import { modelsFor } from "./models.js";
7
6
  export class ConfigurationCancelled extends Error {
@@ -21,7 +20,25 @@ export async function configureProvider(options = {}) {
21
20
  const signal = controller.signal;
22
21
  const forwardAbort = () => controller.abort(options.signal.reason);
23
22
  options.signal?.throwIfAborted();
24
- const store = new ProjectCredentialStore(join(root, ".env", "auth.json"));
23
+ let enteredKey;
24
+ let enteredEnvironment = {};
25
+ const oauthStore = new ProjectCredentialStore(join(root, ".nylorun", "auth.json"), join(root, ".env", "auth.json"));
26
+ const store = {
27
+ read: (id) => oauthStore.read(id),
28
+ list: () => oauthStore.list(),
29
+ delete: (id) => oauthStore.delete(id),
30
+ async modify(id, fn) {
31
+ const next = await fn(await oauthStore.read(id));
32
+ if (next?.type === "api_key") {
33
+ if (next.key === "")
34
+ throw new Error("An API key is required.");
35
+ enteredKey = next.key;
36
+ enteredEnvironment = { ...next.env };
37
+ return next;
38
+ }
39
+ return oauthStore.modify(id, async () => next);
40
+ },
41
+ };
25
42
  const models = modelsFor({ provider: "", model: "" }, store);
26
43
  const providers = models.getProviders();
27
44
  const prompt = createInterface({
@@ -59,7 +76,8 @@ export async function configureProvider(options = {}) {
59
76
  custom: { baseUrl },
60
77
  };
61
78
  const customModels = modelsFor(selection, store);
62
- await customModels.login("custom", "api_key", interaction());
79
+ if (!(await customModels.checkAuth("custom", { signal })))
80
+ await customModels.login("custom", "api_key", interaction());
63
81
  await save(selection);
64
82
  }
65
83
  else {
@@ -72,7 +90,17 @@ export async function configureProvider(options = {}) {
72
90
  if (!model)
73
91
  throw new Error("Choose a listed model.");
74
92
  if (!(await models.checkAuth(chosen.id, { signal }))) {
75
- await models.login(chosen.id, chosen.auth.oauth ? "oauth" : "api_key", interaction());
93
+ let method = chosen.auth.apiKey
94
+ ? "api_key"
95
+ : "oauth";
96
+ if (chosen.auth.apiKey && chosen.auth.oauth) {
97
+ const answer = (await question("Choose authentication: 1. API key (default), 2. OAuth: ")).trim();
98
+ if (answer && !["1", "2"].includes(answer))
99
+ throw new Error("Choose authentication 1 or 2.");
100
+ if (answer === "2")
101
+ method = "oauth";
102
+ }
103
+ await models.login(chosen.id, method, interaction());
76
104
  }
77
105
  await save({ provider: chosen.id, model: model.id });
78
106
  }
@@ -92,24 +120,36 @@ export async function configureProvider(options = {}) {
92
120
  function interaction() {
93
121
  return {
94
122
  signal,
95
- prompt: async (item) => question(item.message + ": "),
96
- notify: (event) => output.write((event.url ?? event.verificationUri ?? event.message) + "\n"),
123
+ prompt: async (item) => {
124
+ if (item.type !== "select")
125
+ return question(item.message + ": ");
126
+ item.options.forEach((option, index) => output.write(`${index + 1}. ${option.label}\n`));
127
+ const answer = (await question(item.message + " ")).trim();
128
+ const option = item.options.find((option) => option.id === answer) ??
129
+ item.options[Number(answer) - 1];
130
+ if (!option)
131
+ throw new Error("Choose a listed authentication option.");
132
+ return option.id;
133
+ },
134
+ notify: (event) => {
135
+ output.write(("url" in event
136
+ ? event.url
137
+ : "verificationUri" in event
138
+ ? event.verificationUri
139
+ : event.message) + "\n");
140
+ },
97
141
  };
98
142
  }
99
143
  async function save(selection) {
100
144
  signal.throwIfAborted();
101
- const directory = join(root, "config");
102
- const temporary = join(directory, `.model-${randomUUID()}.json`);
103
- try {
104
- await mkdir(directory, { recursive: true });
105
- await writeFile(temporary, JSON.stringify(selection, null, 2) + "\n", {
106
- signal,
107
- });
108
- signal.throwIfAborted();
109
- await rename(temporary, join(directory, "model.json"));
110
- }
111
- finally {
112
- await rm(temporary, { force: true });
113
- }
145
+ await saveEnvironment(root, {
146
+ ...enteredEnvironment,
147
+ MODEL_PROVIDER: selection.provider,
148
+ MODEL: selection.model,
149
+ MODEL_PROVIDER_BASE_URL: selection.custom?.baseUrl,
150
+ ...(enteredKey === undefined
151
+ ? {}
152
+ : { MODEL_PROVIDER_API_KEY: enteredKey }),
153
+ }, signal);
114
154
  }
115
155
  }
@@ -1,4 +1,4 @@
1
- import type { ProjectCredentialStore } from "./auth-store.js";
1
+ import { type CredentialStore } from "@earendil-works/pi-ai";
2
2
  export type Selection = Readonly<{
3
3
  provider: string;
4
4
  model: string;
@@ -6,4 +6,4 @@ export type Selection = Readonly<{
6
6
  baseUrl: string;
7
7
  }>;
8
8
  }>;
9
- export declare function modelsFor(selection: Selection, credentials: ProjectCredentialStore): import("@earendil-works/pi-ai").MutableModels;
9
+ export declare function modelsFor(selection: Selection, credentials: CredentialStore): import("@earendil-works/pi-ai").MutableModels;
@@ -1,8 +1,29 @@
1
- import { createProvider, envApiKeyAuth, } from "@earendil-works/pi-ai";
1
+ import { createProvider, defaultProviderAuthContext, envApiKeyAuth, } from "@earendil-works/pi-ai";
2
2
  import { stream, streamSimple, } from "@earendil-works/pi-ai/api/openai-completions";
3
3
  import { builtinModels } from "@earendil-works/pi-ai/providers/all";
4
4
  export function modelsFor(selection, credentials) {
5
- const models = builtinModels({ credentials });
5
+ const environmentFirst = {
6
+ async read(providerId, options) {
7
+ const explicit = process.env.MODEL_PROVIDER_API_KEY;
8
+ if (explicit &&
9
+ (!selection.provider || selection.provider === providerId))
10
+ return { type: "api_key", key: explicit };
11
+ const provider = models
12
+ .getProviders()
13
+ .find((item) => item.id === providerId);
14
+ const ambient = await provider?.auth.apiKey?.resolve({
15
+ ctx: defaultProviderAuthContext(),
16
+ signal: options?.signal ?? new AbortController().signal,
17
+ });
18
+ if (ambient)
19
+ return undefined;
20
+ return credentials.read(providerId, options);
21
+ },
22
+ list: (options) => credentials.list(options),
23
+ modify: (id, fn, options) => credentials.modify(id, fn, options),
24
+ delete: (id, options) => credentials.delete(id, options),
25
+ };
26
+ const models = builtinModels({ credentials: environmentFirst });
6
27
  if (!selection.custom)
7
28
  return models;
8
29
  const model = {
@@ -22,7 +43,7 @@ export function modelsFor(selection, credentials) {
22
43
  name: "Custom OpenAI-compatible",
23
44
  baseUrl: selection.custom.baseUrl,
24
45
  auth: {
25
- apiKey: envApiKeyAuth("Custom API key", ["NYLO_CUSTOM_API_KEY"]),
46
+ apiKey: envApiKeyAuth("Custom API key", ["MODEL_PROVIDER_API_KEY"]),
26
47
  },
27
48
  models: [model],
28
49
  api: { stream, streamSimple },
@@ -17,7 +17,7 @@ export function piModel(options = {}) {
17
17
  context.signal.throwIfAborted();
18
18
  const root = options.root ?? process.cwd();
19
19
  const selection = options.selection ?? modelSelection(root);
20
- const registry = modelsFor(selection, new ProjectCredentialStore(join(root, ".env", "auth.json")));
20
+ const registry = modelsFor(selection, new ProjectCredentialStore(join(root, ".nylorun", "auth.json"), join(root, ".env", "auth.json")));
21
21
  const selected = registry.getModel(selection.provider, selection.model);
22
22
  if (!selected)
23
23
  throw new Error("Unknown model. Run nylorun configure.");
@@ -1,8 +1,40 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  export function modelSelection(root = process.cwd()) {
4
+ const { MODEL_PROVIDER: provider, MODEL: model, MODEL_PROVIDER_BASE_URL: baseUrl, } = process.env;
5
+ if (provider !== undefined || model !== undefined || baseUrl !== undefined) {
6
+ if (!provider?.trim() || !model?.trim())
7
+ throw new Error("Set both MODEL_PROVIDER and MODEL, or run nylorun configure.");
8
+ if (provider === "custom" && !baseUrl?.trim())
9
+ throw new Error("Set MODEL_PROVIDER_BASE_URL for MODEL_PROVIDER=custom.");
10
+ if (baseUrl && provider !== "custom")
11
+ throw new Error("MODEL_PROVIDER_BASE_URL requires MODEL_PROVIDER=custom.");
12
+ if (baseUrl) {
13
+ let url;
14
+ try {
15
+ url = new URL(baseUrl);
16
+ }
17
+ catch {
18
+ throw new Error("MODEL_PROVIDER_BASE_URL must be an HTTP(S) URL.");
19
+ }
20
+ if (!["http:", "https:"].includes(url.protocol))
21
+ throw new Error("MODEL_PROVIDER_BASE_URL must be an HTTP(S) URL.");
22
+ }
23
+ return { provider, model, ...(baseUrl ? { custom: { baseUrl } } : {}) };
24
+ }
4
25
  try {
5
- const value = JSON.parse(readFileSync(join(root, "config", "model.json"), "utf8"));
26
+ let contents;
27
+ try {
28
+ contents = readFileSync(join(root, ".env", "model.json"), "utf8");
29
+ }
30
+ catch (error) {
31
+ if (!(error instanceof Error) ||
32
+ !("code" in error) ||
33
+ !["ENOENT", "ENOTDIR"].includes(String(error.code)))
34
+ throw error;
35
+ contents = readFileSync(join(root, "config", "model.json"), "utf8");
36
+ }
37
+ const value = JSON.parse(contents);
6
38
  if (typeof value.provider === "string" &&
7
39
  value.provider &&
8
40
  typeof value.model === "string" &&
@@ -19,17 +51,18 @@ export function projectSecrets(root = process.cwd()) {
19
51
  const values = Object.entries(process.env)
20
52
  .filter(([key]) => /key|token|secret|password|credential/i.test(key))
21
53
  .flatMap(([, value]) => (value ? [value] : []));
22
- try {
23
- const collect = (value) => {
24
- if (typeof value === "string")
25
- values.push(value);
26
- else if (value && typeof value === "object")
27
- Object.values(value).forEach(collect);
28
- };
29
- collect(JSON.parse(readFileSync(join(root, ".env", "auth.json"), "utf8")));
30
- }
31
- catch {
32
- /* The vault may not exist before setup. */
33
- }
54
+ for (const directory of [".nylorun", ".env"])
55
+ try {
56
+ const collect = (value) => {
57
+ if (typeof value === "string")
58
+ values.push(value);
59
+ else if (value && typeof value === "object")
60
+ Object.values(value).forEach(collect);
61
+ };
62
+ collect(JSON.parse(readFileSync(join(root, directory, "auth.json"), "utf8")));
63
+ }
64
+ catch {
65
+ /* The vault may not exist before setup. */
66
+ }
34
67
  return values;
35
68
  }
@@ -6,7 +6,7 @@ export type RuntimeActor = Readonly<{
6
6
  context?: Record<string, JsonValue>;
7
7
  }>;
8
8
  export type AgentRouterOptions = Readonly<{
9
- /** Public URL prefix of this router, matching the Hono mount path. */
9
+ /** Override the public URL prefix; defaults to the current Hono mount path. */
10
10
  basePath?: string;
11
11
  getActor?: (context: Context) => RuntimeActor | undefined | Promise<RuntimeActor | undefined>;
12
12
  getRequestMetadata?: (context: Context) => Record<string, JsonValue> | undefined | Promise<Record<string, JsonValue> | undefined>;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Hono } from "hono";
3
+ import { cors } from "hono/cors";
3
4
  import { HTTPException } from "hono/http-exception";
4
5
  import { agUiEvents, sse } from "./ag-ui.js";
5
6
  import { observedPayload } from "./digests.js";
@@ -38,9 +39,34 @@ export class Runtime {
38
39
  const configuredObserver = this.#config.observer;
39
40
  const redact = (value) => scrub(value, projectSecrets());
40
41
  const live = new Map();
41
- let publicPath = (path) => path;
42
42
  const routerOptions = options;
43
43
  const app = new Hono();
44
+ if (process.env.NYLORUN_DEV === "1") {
45
+ app.use("*", cors({
46
+ origin: (origin) => {
47
+ try {
48
+ const url = new URL(origin);
49
+ return ["http:", "https:"].includes(url.protocol) &&
50
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) &&
51
+ url.origin === origin
52
+ ? origin
53
+ : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ },
59
+ allowMethods: [
60
+ "GET",
61
+ "HEAD",
62
+ "POST",
63
+ "PUT",
64
+ "PATCH",
65
+ "DELETE",
66
+ "OPTIONS",
67
+ ],
68
+ }));
69
+ }
44
70
  app.onError((error, context) => context.json({ error: String(redact(error.message)) }, error instanceof HTTPException ? error.status : 500));
45
71
  app.use("*", async (context, next) => {
46
72
  await next();
@@ -52,19 +78,22 @@ export class Runtime {
52
78
  });
53
79
  }
54
80
  });
55
- publicPath = (path) => `${normalizeBasePath(routerOptions.basePath)}${path}`;
81
+ // Infer the public mount from this request URL and the local route path.
82
+ // Do not use hono/route basePath: consumers often install a separate `hono`
83
+ // copy, and that helper's match-result Symbol then misses the parent's match.
84
+ const publicPath = (context, routePath, path) => `${normalizeBasePath(routerOptions.basePath ?? inferMountPath(context, routePath))}${path}`;
56
85
  app.get("/v1/agents", (context) => context.json({
57
86
  protocolVersion: 2,
58
87
  agents: agents.map((agent) => ({
59
88
  id: agent.id,
60
- manifestUrl: publicPath(`/${agent.id}/manifest.json`),
89
+ manifestUrl: publicPath(context, "/v1/agents", `/${agent.id}/manifest.json`),
61
90
  })),
62
91
  }));
63
92
  app.get("/:agentId/manifest.json", (context) => {
64
93
  const agent = byId.get(context.req.param("agentId"));
65
94
  return agent === undefined
66
95
  ? context.json({ error: "unknown agent" }, 404)
67
- : context.json(manifest(agent, media !== undefined, publicPath));
96
+ : context.json(manifest(agent, media !== undefined, (path) => publicPath(context, "/:agentId/manifest.json", path)));
68
97
  });
69
98
  app.get("/:agentId/v1/media/:session/:assetId", async (context) => {
70
99
  const agent = requireAgent(context.req.param("agentId"));
@@ -602,3 +631,20 @@ function normalizeBasePath(value) {
602
631
  throw new Error("basePath must start with / and must not end with /.");
603
632
  return value;
604
633
  }
634
+ /** Public mount prefix for this request, derived without hono/route Symbols. */
635
+ function inferMountPath(context, routePath) {
636
+ const pathname = new URL(context.req.url).pathname;
637
+ const suffix = routePath.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
638
+ const value = context.req.param(key);
639
+ if (value === undefined)
640
+ throw new Error(`Unable to infer Runtime mount path from ${pathname}. Pass basePath matching the Hono mount.`);
641
+ return value;
642
+ });
643
+ if (suffix !== "/" && pathname.endsWith(suffix)) {
644
+ const base = pathname.slice(0, -suffix.length);
645
+ return base === "" ? "/" : base;
646
+ }
647
+ if (pathname === suffix || `${pathname}/` === suffix)
648
+ return "/";
649
+ throw new Error(`Unable to infer Runtime mount path from ${pathname}. Pass basePath matching the Hono mount.`);
650
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.2.1-beta",
3
+ "version": "0.4.0-beta",
4
4
  "description": "Portable agent runtime, Hono protocol router, model providers, and the Nylorun CLI.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -39,7 +39,8 @@
39
39
  "prepack": "npm run build"
40
40
  },
41
41
  "dependencies": {
42
- "@earendil-works/pi-ai": "0.85.1"
42
+ "@earendil-works/pi-ai": "0.85.1",
43
+ "@hono/node-server": "^2.1.1"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "hono": "^4.13.7"