nylorun 0.12.2-beta → 0.14.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 +74 -0
- package/README.md +142 -37
- package/dist/cli.js +13 -18
- package/dist/client/access/commands.d.ts +8 -0
- package/dist/client/access/commands.js +33 -0
- package/dist/client/cli.d.ts +2 -0
- package/dist/client/cli.js +106 -0
- package/dist/client/doctor.d.ts +26 -0
- package/dist/client/doctor.js +72 -0
- package/dist/client/installation/commands.d.ts +7 -0
- package/dist/client/installation/commands.js +82 -0
- package/dist/client/model/configure.d.ts +58 -0
- package/dist/client/model/configure.js +294 -0
- package/dist/client/model/host-model.d.ts +4 -0
- package/dist/client/model/host-model.js +13 -0
- package/dist/client/model/pi-ai.d.ts +26 -0
- package/dist/client/model/pi-ai.js +104 -0
- package/dist/client/project/connection.d.ts +36 -0
- package/dist/client/project/connection.js +80 -0
- package/dist/client/project/env.d.ts +3 -0
- package/dist/client/project/env.js +19 -0
- package/dist/command.d.ts +8 -0
- package/dist/command.js +23 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/nylo.d.ts +2 -0
- package/dist/nylo.js +9 -0
- package/dist/project/link.d.ts +2 -1
- package/dist/project/link.js +5 -2
- package/dist/stack/commands.js +1 -1
- package/dist/stack/compose-file.js +10 -1
- package/dist/stack/mcp.d.ts +5 -3
- package/dist/stack/mcp.js +30 -18
- package/dist/stack/sandbox.d.ts +1 -1
- package/dist/stack/sandbox.js +23 -22
- package/dist/stack/versions.d.ts +2 -2
- package/dist/stack/versions.js +3 -3
- package/package.json +13 -9
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { release } from "node:os";
|
|
2
|
+
import { findProjectRoot } from "@nylorun/core/project";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { linkedConnection, managementClient } from "./project/connection.js";
|
|
5
|
+
function platformLine() {
|
|
6
|
+
const os = process.platform === "darwin"
|
|
7
|
+
? "macOS"
|
|
8
|
+
: process.env.WSL_DISTRO_NAME
|
|
9
|
+
? `Linux (WSL: ${process.env.WSL_DISTRO_NAME})`
|
|
10
|
+
: process.platform;
|
|
11
|
+
return `${os} (kernel ${release()}) · ${process.arch}`;
|
|
12
|
+
}
|
|
13
|
+
/** The Tenant's sandbox report (`GET /v1/tenant/sandbox`, Management API). */
|
|
14
|
+
async function fetchSandboxReport() {
|
|
15
|
+
const connection = await linkedConnection(findProjectRoot() ?? process.cwd());
|
|
16
|
+
return (await managementClient(connection).settings.sandbox.get());
|
|
17
|
+
}
|
|
18
|
+
/** `nylo doctor sandbox`: Tenant sandbox report via the Management API (F2-4). */
|
|
19
|
+
export async function doctorSandbox(options) {
|
|
20
|
+
let report;
|
|
21
|
+
try {
|
|
22
|
+
report = await fetchSandboxReport();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new CliError(error instanceof Error
|
|
26
|
+
? error.message
|
|
27
|
+
: `Could not read Tenant sandbox status: ${String(error)}`, 1);
|
|
28
|
+
}
|
|
29
|
+
if (options.json) {
|
|
30
|
+
console.log(JSON.stringify({ platform: platformLine(), ...report }, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const rows = [["platform", platformLine()]];
|
|
34
|
+
for (const probe of report.probes)
|
|
35
|
+
rows.push([
|
|
36
|
+
probe.name,
|
|
37
|
+
`${probe.available ? "✓" : "✗"} ${probe.reason ?? ""}${probe.version ? ` · ${probe.version}` : ""}`,
|
|
38
|
+
]);
|
|
39
|
+
rows.push([
|
|
40
|
+
"preference",
|
|
41
|
+
report.preference === "auto"
|
|
42
|
+
? "auto (npx nylorun start seeds the Tenant's sandbox.backend from .env NYLORUN_SANDBOX)"
|
|
43
|
+
: report.preference,
|
|
44
|
+
]);
|
|
45
|
+
rows.push([
|
|
46
|
+
"sessions",
|
|
47
|
+
"get a sandbox only on an Environment (createSession({ environment }) names one)",
|
|
48
|
+
]);
|
|
49
|
+
const limits = report.config?.limits;
|
|
50
|
+
if (limits)
|
|
51
|
+
rows.push([
|
|
52
|
+
"limits",
|
|
53
|
+
[
|
|
54
|
+
limits.idle ? `idle ${limits.idle}` : undefined,
|
|
55
|
+
limits.environments === undefined ? undefined : `${limits.environments} environments`,
|
|
56
|
+
limits.network ? `${limits.network.length} allowed hosts` : undefined,
|
|
57
|
+
]
|
|
58
|
+
.filter((item) => item !== undefined)
|
|
59
|
+
.join(", ") || "-",
|
|
60
|
+
]);
|
|
61
|
+
rows.push([
|
|
62
|
+
"selected",
|
|
63
|
+
report.backend
|
|
64
|
+
? `${report.backend} (${report.isolation} isolation)`
|
|
65
|
+
: `none: ${report.reason}`,
|
|
66
|
+
]);
|
|
67
|
+
const width = Math.max(...rows.map(([key]) => key.length)) + 2;
|
|
68
|
+
for (const [key, value] of rows)
|
|
69
|
+
console.log(` ${key.padEnd(width)}${value}`);
|
|
70
|
+
if (report.backend === "virtual")
|
|
71
|
+
console.log("\n The virtual shell emulates bash in the Runtime process; it is not a VM boundary.");
|
|
72
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nylo status [--json]`: the Tenant, its checks and counts. A Tenant that is not open does not
|
|
3
|
+
* answer: `npx nylorun status` reports why.
|
|
4
|
+
*/
|
|
5
|
+
export declare function statusCommand(args: readonly string[]): Promise<void>;
|
|
6
|
+
/** `nylo reset [--sessions|--environments|--all] [--yes]`; `--all` asks first. */
|
|
7
|
+
export declare function resetCommand(args: readonly string[]): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nylo status` and `nylo reset`: the linked installation's one Tenant, through the Management
|
|
3
|
+
* API (the Project's management key).
|
|
4
|
+
*/
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { findProjectRoot } from "@nylorun/core/project";
|
|
7
|
+
import { CliError } from "../../errors.js";
|
|
8
|
+
import { linkedConnection, managementClient } from "../project/connection.js";
|
|
9
|
+
const projectRoot = () => findProjectRoot() ?? process.cwd();
|
|
10
|
+
/**
|
|
11
|
+
* `nylo status [--json]`: the Tenant, its checks and counts. A Tenant that is not open does not
|
|
12
|
+
* answer: `npx nylorun status` reports why.
|
|
13
|
+
*/
|
|
14
|
+
export async function statusCommand(args) {
|
|
15
|
+
const json = args.includes("--json");
|
|
16
|
+
if (args.some((a) => a !== "--json"))
|
|
17
|
+
throw new CliError("Usage: nylo status [--json]", 2);
|
|
18
|
+
const root = projectRoot();
|
|
19
|
+
const connection = await linkedConnection(root);
|
|
20
|
+
const admin = managementClient(connection);
|
|
21
|
+
let body;
|
|
22
|
+
try {
|
|
23
|
+
body = await admin.tenant.status();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new CliError(`${(error instanceof Error ? error.message : `The Tenant at ${connection.url} is not reachable`).replace(/\.?$/, ".")} Run "npx nylorun status" to see the Tenant's state and why it is not open.`, 1);
|
|
27
|
+
}
|
|
28
|
+
if (json) {
|
|
29
|
+
console.log(JSON.stringify(body, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`${body.tenant.name} ${body.tenant.id}`);
|
|
33
|
+
console.log(`runtime ${connection.url}`);
|
|
34
|
+
console.log(`path ${body.path}`);
|
|
35
|
+
console.log(`checks ${Object.entries(body.checks)
|
|
36
|
+
.map(([k, v]) => `${k}=${v ? "ok" : "fail"}`)
|
|
37
|
+
.join(" ")}`);
|
|
38
|
+
console.log(`counts sessions=${body.counts.sessions} running=${body.counts.runningSessions} uncertain=${body.counts.uncertainEffects}`);
|
|
39
|
+
console.log(`sandbox ${body.sandbox.backend ?? "none"}`);
|
|
40
|
+
}
|
|
41
|
+
const RESET_USAGE = "Usage: nylo reset [--sessions|--environments|--all] [--yes]";
|
|
42
|
+
const SCOPES = { "--sessions": "sessions", "--environments": "environments", "--all": "all" };
|
|
43
|
+
/** `nylo reset [--sessions|--environments|--all] [--yes]`; `--all` asks first. */
|
|
44
|
+
export async function resetCommand(args) {
|
|
45
|
+
const yes = args.includes("--yes");
|
|
46
|
+
const flags = args.filter((a) => a !== "--yes");
|
|
47
|
+
if (flags.includes("--sandboxes"))
|
|
48
|
+
throw new CliError("--sandboxes is now --environments (protocol 11): nylo reset --environments deletes the Tenant's Environments, their vaults and files.", 2);
|
|
49
|
+
if (flags.some((f) => !(f in SCOPES)) || args.filter((a) => a === "--yes").length > 1)
|
|
50
|
+
throw new CliError(RESET_USAGE, 2);
|
|
51
|
+
if (flags.length > 1)
|
|
52
|
+
throw new CliError("Pass only one of --sessions, --environments, --all.", 2);
|
|
53
|
+
const scope = flags.length ? SCOPES[flags[0]] : "sessions";
|
|
54
|
+
const connection = await linkedConnection(projectRoot());
|
|
55
|
+
const tenant = connection.link?.tenant;
|
|
56
|
+
const target = tenant ? `Tenant ${tenant} (${connection.url})` : `the Tenant on ${connection.url}`;
|
|
57
|
+
const admin = managementClient(connection);
|
|
58
|
+
if (scope === "all" && !yes)
|
|
59
|
+
await confirmOrThrow(`Reset ALL data of ${target}? The Project link and credentials are kept. [y/N] `);
|
|
60
|
+
try {
|
|
61
|
+
await admin.tenant.reset({ scope, activeWork: "drain" });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new CliError(error instanceof Error ? error.message : `Tenant reset failed: ${String(error)}`, 1);
|
|
65
|
+
}
|
|
66
|
+
console.log(`Reset ${target} (${scope}).`);
|
|
67
|
+
}
|
|
68
|
+
async function confirmOrThrow(prompt) {
|
|
69
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
70
|
+
throw new CliError("Confirmation required; pass --yes for non-interactive use.", 2);
|
|
71
|
+
}
|
|
72
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
73
|
+
try {
|
|
74
|
+
const answer = (await rl.question(prompt)).trim().toLowerCase();
|
|
75
|
+
if (answer !== "y" && answer !== "yes") {
|
|
76
|
+
throw new CliError("Cancelled.", 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
rl.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import type { Credential } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ManagementClient } from "@nylorun/admin";
|
|
4
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
/** Exit 130 on SIGINT, 143 on SIGTERM. */
|
|
6
|
+
export declare class ConfigurationCancelled extends CliError {
|
|
7
|
+
readonly signal: "SIGINT" | "SIGTERM";
|
|
8
|
+
constructor(signal: "SIGINT" | "SIGTERM");
|
|
9
|
+
}
|
|
10
|
+
export type PromptedModel = {
|
|
11
|
+
provider: string;
|
|
12
|
+
model: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** A custom endpoint's context window and output limit, when given. */
|
|
15
|
+
settings?: {
|
|
16
|
+
contextWindow?: number;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
};
|
|
19
|
+
auth: Credential;
|
|
20
|
+
};
|
|
21
|
+
export type ModelCatalog = {
|
|
22
|
+
providers: {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
models: {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}[];
|
|
29
|
+
auth?: {
|
|
30
|
+
apiKey?: unknown;
|
|
31
|
+
oauth?: unknown;
|
|
32
|
+
};
|
|
33
|
+
}[];
|
|
34
|
+
};
|
|
35
|
+
export type Selection = Readonly<{
|
|
36
|
+
provider: string;
|
|
37
|
+
model: string;
|
|
38
|
+
custom?: Readonly<{
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
}>;
|
|
41
|
+
}>;
|
|
42
|
+
/** The Tenant's model catalog (`GET /v1/tenant/models`, Management API). */
|
|
43
|
+
export declare function fetchModelCatalog(admin: ManagementClient): Promise<ModelCatalog>;
|
|
44
|
+
export declare function configureProvider(options?: {
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
root?: string;
|
|
47
|
+
input?: Readable;
|
|
48
|
+
output?: Writable;
|
|
49
|
+
/** Tenant API catalog from GET /v1/tenant/models. */
|
|
50
|
+
catalog?: ModelCatalog;
|
|
51
|
+
/** This installation's stable id, for OAuth flows that need one (OpenAI). */
|
|
52
|
+
deviceId?: () => string;
|
|
53
|
+
}): Promise<PromptedModel>;
|
|
54
|
+
/**
|
|
55
|
+
* A stable id for this CLI installation, created on first use under the Host root.
|
|
56
|
+
* OpenAI's "Sign in with ChatGPT" sends it as the agent host id.
|
|
57
|
+
*/
|
|
58
|
+
export declare function installationId(home?: string): string;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { nylorunHome } from "@nylorun/core/project";
|
|
6
|
+
import { CliError } from "../../errors.js";
|
|
7
|
+
import { loadPiAi } from "./pi-ai.js";
|
|
8
|
+
/** Exit 130 on SIGINT, 143 on SIGTERM. */
|
|
9
|
+
export class ConfigurationCancelled extends CliError {
|
|
10
|
+
signal;
|
|
11
|
+
constructor(signal) {
|
|
12
|
+
super(`Provider configuration cancelled (${signal}).`, signal === "SIGINT" ? 130 : 143);
|
|
13
|
+
this.signal = signal;
|
|
14
|
+
this.name = "ConfigurationCancelled";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a pi-ai registry for interactive login. Catalog listing prefers the
|
|
19
|
+
* Tenant API payload when provided (F2-4).
|
|
20
|
+
*/
|
|
21
|
+
function modelsFor({ builtinModels, createProvider, stream, streamSimple }, selection, credentials, catalog) {
|
|
22
|
+
const models = builtinModels({ credentials });
|
|
23
|
+
if (selection.custom) {
|
|
24
|
+
const model = {
|
|
25
|
+
id: selection.model,
|
|
26
|
+
name: selection.model,
|
|
27
|
+
api: "openai-completions",
|
|
28
|
+
provider: "custom",
|
|
29
|
+
baseUrl: selection.custom.baseUrl,
|
|
30
|
+
reasoning: false,
|
|
31
|
+
input: ["text", "image"],
|
|
32
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
33
|
+
contextWindow: 128000,
|
|
34
|
+
maxTokens: 16384,
|
|
35
|
+
};
|
|
36
|
+
models.setProvider(createProvider({
|
|
37
|
+
id: "custom",
|
|
38
|
+
name: "Custom OpenAI-compatible",
|
|
39
|
+
baseUrl: selection.custom.baseUrl,
|
|
40
|
+
auth: {
|
|
41
|
+
apiKey: {
|
|
42
|
+
name: "Custom API key",
|
|
43
|
+
async login(interaction) {
|
|
44
|
+
const key = await interaction.prompt({
|
|
45
|
+
type: "secret",
|
|
46
|
+
message: "API key",
|
|
47
|
+
});
|
|
48
|
+
return { type: "api_key", key: key.trim() };
|
|
49
|
+
},
|
|
50
|
+
async resolve() {
|
|
51
|
+
const credential = await credentials.read("custom");
|
|
52
|
+
if (!credential || credential.type !== "api_key")
|
|
53
|
+
return undefined;
|
|
54
|
+
const key = credential.key;
|
|
55
|
+
if (!key)
|
|
56
|
+
return undefined;
|
|
57
|
+
return { type: "api_key", auth: { apiKey: key } };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
models: [model],
|
|
62
|
+
api: { stream, streamSimple },
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
models,
|
|
67
|
+
listProviders() {
|
|
68
|
+
if (catalog?.providers.length) {
|
|
69
|
+
return catalog.providers.map((provider) => {
|
|
70
|
+
const live = models.getProvider(provider.id);
|
|
71
|
+
return {
|
|
72
|
+
id: provider.id,
|
|
73
|
+
name: provider.name,
|
|
74
|
+
auth: {
|
|
75
|
+
...(live?.auth?.apiKey || provider.auth?.apiKey
|
|
76
|
+
? { apiKey: live?.auth?.apiKey ?? {} }
|
|
77
|
+
: {}),
|
|
78
|
+
...(live?.auth?.oauth || provider.auth?.oauth
|
|
79
|
+
? { oauth: live?.auth?.oauth ?? {} }
|
|
80
|
+
: {}),
|
|
81
|
+
...(!live?.auth?.apiKey &&
|
|
82
|
+
!live?.auth?.oauth &&
|
|
83
|
+
!provider.auth?.apiKey &&
|
|
84
|
+
!provider.auth?.oauth
|
|
85
|
+
? { apiKey: {} }
|
|
86
|
+
: {}),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return models.getProviders().map((provider) => ({
|
|
92
|
+
id: provider.id,
|
|
93
|
+
name: provider.name,
|
|
94
|
+
auth: {
|
|
95
|
+
...(provider.auth?.apiKey ? { apiKey: provider.auth.apiKey } : {}),
|
|
96
|
+
...(provider.auth?.oauth ? { oauth: provider.auth.oauth } : {}),
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
},
|
|
100
|
+
listModels(providerId) {
|
|
101
|
+
const fromCatalog = catalog?.providers.find((p) => p.id === providerId);
|
|
102
|
+
if (fromCatalog?.models.length)
|
|
103
|
+
return fromCatalog.models;
|
|
104
|
+
return models.getModels(providerId).map((model) => ({
|
|
105
|
+
id: model.id,
|
|
106
|
+
name: model.name,
|
|
107
|
+
}));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The Tenant's model catalog (`GET /v1/tenant/models`, Management API). */
|
|
112
|
+
export async function fetchModelCatalog(admin) {
|
|
113
|
+
return await admin.models.catalog();
|
|
114
|
+
}
|
|
115
|
+
// Internal options also allow isolated prompt tests without changing process globals.
|
|
116
|
+
export async function configureProvider(options = {}) {
|
|
117
|
+
const login = { getDeviceId: options.deviceId ?? installationId };
|
|
118
|
+
const output = options.output ?? process.stdout;
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const signal = controller.signal;
|
|
121
|
+
const forwardAbort = () => controller.abort(options.signal.reason);
|
|
122
|
+
options.signal?.throwIfAborted();
|
|
123
|
+
const ai = await loadPiAi();
|
|
124
|
+
options.signal?.throwIfAborted();
|
|
125
|
+
let captured;
|
|
126
|
+
const store = {
|
|
127
|
+
async read() {
|
|
128
|
+
return undefined;
|
|
129
|
+
},
|
|
130
|
+
async list() {
|
|
131
|
+
return [];
|
|
132
|
+
},
|
|
133
|
+
async delete() { },
|
|
134
|
+
async modify(_id, fn) {
|
|
135
|
+
const next = await fn(captured);
|
|
136
|
+
if (next?.type === "api_key" && !next.key)
|
|
137
|
+
throw new Error("An API key is required.");
|
|
138
|
+
if (next)
|
|
139
|
+
captured = next;
|
|
140
|
+
return next ?? captured;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
const registry = modelsFor(ai, { provider: "", model: "" }, store, options.catalog);
|
|
144
|
+
const providers = registry.listProviders();
|
|
145
|
+
const prompt = createInterface({
|
|
146
|
+
input: options.input ?? process.stdin,
|
|
147
|
+
output,
|
|
148
|
+
});
|
|
149
|
+
const onInt = () => controller.abort(new ConfigurationCancelled("SIGINT"));
|
|
150
|
+
const onClose = () => controller.abort(new Error("Configuration input closed before setup completed."));
|
|
151
|
+
const closeOnAbort = () => prompt.close();
|
|
152
|
+
prompt.on("SIGINT", onInt);
|
|
153
|
+
prompt.on("close", onClose);
|
|
154
|
+
signal.addEventListener("abort", closeOnAbort, { once: true });
|
|
155
|
+
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
156
|
+
if (options.signal?.aborted)
|
|
157
|
+
forwardAbort();
|
|
158
|
+
async function question(message) {
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
return prompt.question(message, { signal });
|
|
161
|
+
}
|
|
162
|
+
/** A positive whole number of tokens, or undefined for the default. */
|
|
163
|
+
async function tokens(message) {
|
|
164
|
+
const answer = (await question(message)).trim();
|
|
165
|
+
if (!answer)
|
|
166
|
+
return undefined;
|
|
167
|
+
const value = Number(answer);
|
|
168
|
+
if (!Number.isInteger(value) || value < 1)
|
|
169
|
+
throw new Error(`${message.split(" (")[0]} must be a whole number of tokens.`);
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
output.write("0. Custom OpenAI-compatible provider\n");
|
|
175
|
+
providers.forEach((provider, index) => output.write(`${index + 1}. ${provider.name} (${provider.id})\n`));
|
|
176
|
+
const choice = Number(await question("Choose a provider: "));
|
|
177
|
+
if (choice === 0) {
|
|
178
|
+
const baseUrl = (await question("OpenAI-compatible base URL: "))
|
|
179
|
+
.trim()
|
|
180
|
+
.replace(/\/$/, "");
|
|
181
|
+
const model = (await question("Model id: ")).trim();
|
|
182
|
+
if (!baseUrl || !model)
|
|
183
|
+
throw new Error("A base URL and model id are required.");
|
|
184
|
+
const contextWindow = await tokens("Context window in tokens (Enter for 32768): ");
|
|
185
|
+
const maxTokens = await tokens("Max output tokens (Enter for 8192): ");
|
|
186
|
+
const settings = {
|
|
187
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
188
|
+
...(maxTokens ? { maxTokens } : {}),
|
|
189
|
+
};
|
|
190
|
+
const selection = {
|
|
191
|
+
provider: "custom",
|
|
192
|
+
model,
|
|
193
|
+
custom: { baseUrl },
|
|
194
|
+
};
|
|
195
|
+
const custom = modelsFor(ai, selection, store, options.catalog);
|
|
196
|
+
if (!(await custom.models.checkAuth("custom", { signal })))
|
|
197
|
+
await custom.models.login("custom", "api_key", interaction(), login);
|
|
198
|
+
return {
|
|
199
|
+
...prompted(selection),
|
|
200
|
+
...(Object.keys(settings).length ? { settings } : {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const chosen = providers[choice - 1];
|
|
205
|
+
if (!chosen)
|
|
206
|
+
throw new Error("Choose a listed provider.");
|
|
207
|
+
const available = registry.listModels(chosen.id);
|
|
208
|
+
available.forEach((model, index) => output.write(`${index + 1}. ${model.name} (${model.id})\n`));
|
|
209
|
+
const model = available[Number(await question("Choose a model: ")) - 1];
|
|
210
|
+
if (!model)
|
|
211
|
+
throw new Error("Choose a listed model.");
|
|
212
|
+
if (!(await registry.models.checkAuth(chosen.id, { signal }))) {
|
|
213
|
+
let method = chosen.auth.apiKey
|
|
214
|
+
? "api_key"
|
|
215
|
+
: "oauth";
|
|
216
|
+
if (chosen.auth.apiKey && chosen.auth.oauth) {
|
|
217
|
+
const answer = (await question("Choose authentication: 1. API key (default), 2. OAuth: ")).trim();
|
|
218
|
+
if (answer && !["1", "2"].includes(answer))
|
|
219
|
+
throw new Error("Choose authentication 1 or 2.");
|
|
220
|
+
if (answer === "2")
|
|
221
|
+
method = "oauth";
|
|
222
|
+
}
|
|
223
|
+
await registry.models.login(chosen.id, method, interaction(), login);
|
|
224
|
+
}
|
|
225
|
+
return prompted({ provider: chosen.id, model: model.id });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
throw signal.aborted ? signal.reason : error;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
options.signal?.removeEventListener("abort", forwardAbort);
|
|
233
|
+
signal.removeEventListener("abort", closeOnAbort);
|
|
234
|
+
prompt.removeListener("SIGINT", onInt);
|
|
235
|
+
prompt.removeListener("close", onClose);
|
|
236
|
+
prompt.close();
|
|
237
|
+
}
|
|
238
|
+
function interaction() {
|
|
239
|
+
return {
|
|
240
|
+
signal,
|
|
241
|
+
prompt: async (item) => {
|
|
242
|
+
if (item.type !== "select")
|
|
243
|
+
return question(item.message + ": ");
|
|
244
|
+
item.options.forEach((option, index) => output.write(`${index + 1}. ${option.label}\n`));
|
|
245
|
+
const answer = (await question(item.message + " ")).trim();
|
|
246
|
+
const option = item.options.find((option) => option.id === answer) ??
|
|
247
|
+
item.options[Number(answer) - 1];
|
|
248
|
+
if (!option)
|
|
249
|
+
throw new Error("Choose a listed authentication option.");
|
|
250
|
+
return option.id;
|
|
251
|
+
},
|
|
252
|
+
notify: (event) => {
|
|
253
|
+
output.write(("url" in event
|
|
254
|
+
? event.url
|
|
255
|
+
: "verificationUri" in event
|
|
256
|
+
? event.verificationUri
|
|
257
|
+
: event.message) + "\n");
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function prompted(selection) {
|
|
262
|
+
signal.throwIfAborted();
|
|
263
|
+
if (!captured)
|
|
264
|
+
throw new Error("An API key or OAuth credential is required.");
|
|
265
|
+
output.write("Provider configuration saved.\n");
|
|
266
|
+
return {
|
|
267
|
+
provider: selection.provider,
|
|
268
|
+
model: selection.model,
|
|
269
|
+
...(selection.custom?.baseUrl
|
|
270
|
+
? { baseUrl: selection.custom.baseUrl }
|
|
271
|
+
: {}),
|
|
272
|
+
auth: captured,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* A stable id for this CLI installation, created on first use under the Host root.
|
|
278
|
+
* OpenAI's "Sign in with ChatGPT" sends it as the agent host id.
|
|
279
|
+
*/
|
|
280
|
+
export function installationId(home = nylorunHome()) {
|
|
281
|
+
const file = join(home, "cli-installation-id");
|
|
282
|
+
try {
|
|
283
|
+
const existing = readFileSync(file, "utf8").trim();
|
|
284
|
+
if (existing)
|
|
285
|
+
return existing;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
/* Created below on first use. */
|
|
289
|
+
}
|
|
290
|
+
const id = randomUUID();
|
|
291
|
+
mkdirSync(home, { recursive: true });
|
|
292
|
+
writeFileSync(file, `${id}\n`, { mode: 0o600 });
|
|
293
|
+
return id;
|
|
294
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ManagementClient } from "@nylorun/admin";
|
|
2
|
+
import type { PromptedModel } from "./configure.js";
|
|
3
|
+
/** Set the installation's Tenant model provider (`PUT /v1/tenant/model`, Management API). */
|
|
4
|
+
export declare function putHostModel(admin: ManagementClient, model: PromptedModel): ReturnType<ManagementClient["models"]["put"]>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/** Set the installation's Tenant model provider (`PUT /v1/tenant/model`, Management API). */
|
|
3
|
+
export async function putHostModel(admin, model) {
|
|
4
|
+
return await admin.models.put({
|
|
5
|
+
idempotencyKey: randomUUID(),
|
|
6
|
+
provider: model.provider,
|
|
7
|
+
model: model.model,
|
|
8
|
+
...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
|
|
9
|
+
...(model.settings ? { settings: model.settings } : {}),
|
|
10
|
+
// A prompted credential is complete: pi-ai's type leaves its fields optional.
|
|
11
|
+
auth: model.auth,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi-ai `nylo configure` loads; nylorun's devDependency has the same version
|
|
3
|
+
* (scripts/check-package.mjs).
|
|
4
|
+
*/
|
|
5
|
+
export declare const PI_AI_VERSION = "0.99.1";
|
|
6
|
+
/** What `nylo configure` uses of pi-ai: its provider registry and login flows. */
|
|
7
|
+
export interface PiAi {
|
|
8
|
+
createProvider: typeof import("@earendil-works/pi-ai").createProvider;
|
|
9
|
+
builtinModels: typeof import("@earendil-works/pi-ai/providers/all").builtinModels;
|
|
10
|
+
stream: typeof import("@earendil-works/pi-ai/api/openai-completions").stream;
|
|
11
|
+
streamSimple: typeof import("@earendil-works/pi-ai/api/openai-completions").streamSimple;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* pi-ai, with its provider SDKs about 100 MB, is not a dependency of nylorun, so
|
|
15
|
+
* `npx nylorun` stays small: only `nylo configure` needs it. It loads pi-ai from beside nylorun
|
|
16
|
+
* when it resolves there (the workspace, or a project that installs it), else from
|
|
17
|
+
* `<Nylorun home>/lib/pi-ai-<version>/`, which it installs with npm on first use.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadPiAi(options?: {
|
|
20
|
+
home?: string;
|
|
21
|
+
/** Import pi-ai from beside nylorun (tests). */
|
|
22
|
+
packages?: () => Promise<PiAi>;
|
|
23
|
+
/** Install `PI_AI_VERSION` into a directory holding its package.json (tests). */
|
|
24
|
+
install?: (directory: string) => Promise<void>;
|
|
25
|
+
log?: (line: string) => void;
|
|
26
|
+
}): Promise<PiAi>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { nylorunHome } from "@nylorun/core/project";
|
|
8
|
+
import { CliError } from "../../errors.js";
|
|
9
|
+
/**
|
|
10
|
+
* The pi-ai `nylo configure` loads; nylorun's devDependency has the same version
|
|
11
|
+
* (scripts/check-package.mjs).
|
|
12
|
+
*/
|
|
13
|
+
export const PI_AI_VERSION = "0.99.1";
|
|
14
|
+
const PI_AI = "@earendil-works/pi-ai";
|
|
15
|
+
/** The module written beside an on-demand install, which resolves pi-ai from there. */
|
|
16
|
+
const ENTRY = "pi-ai.js";
|
|
17
|
+
const ENTRY_SOURCE = `export { createProvider } from "${PI_AI}";
|
|
18
|
+
export { builtinModels } from "${PI_AI}/providers/all";
|
|
19
|
+
export { stream, streamSimple } from "${PI_AI}/api/openai-completions";
|
|
20
|
+
`;
|
|
21
|
+
async function fromPackages() {
|
|
22
|
+
const [ai, all, completions] = await Promise.all([
|
|
23
|
+
import("@earendil-works/pi-ai"),
|
|
24
|
+
import("@earendil-works/pi-ai/providers/all"),
|
|
25
|
+
import("@earendil-works/pi-ai/api/openai-completions"),
|
|
26
|
+
]);
|
|
27
|
+
return {
|
|
28
|
+
createProvider: ai.createProvider,
|
|
29
|
+
builtinModels: all.builtinModels,
|
|
30
|
+
stream: completions.stream,
|
|
31
|
+
streamSimple: completions.streamSimple,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const notInstalled = (error) => error?.code === "ERR_MODULE_NOT_FOUND" &&
|
|
35
|
+
String(error.message).includes(`'${PI_AI}'`);
|
|
36
|
+
/**
|
|
37
|
+
* pi-ai, with its provider SDKs about 100 MB, is not a dependency of nylorun, so
|
|
38
|
+
* `npx nylorun` stays small: only `nylo configure` needs it. It loads pi-ai from beside nylorun
|
|
39
|
+
* when it resolves there (the workspace, or a project that installs it), else from
|
|
40
|
+
* `<Nylorun home>/lib/pi-ai-<version>/`, which it installs with npm on first use.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadPiAi(options = {}) {
|
|
43
|
+
try {
|
|
44
|
+
return await (options.packages ?? fromPackages)();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!notInstalled(error))
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
const directory = join(nylorunHome(options.home), "lib", `pi-ai-${PI_AI_VERSION}`);
|
|
51
|
+
if (!existsSync(join(directory, ENTRY))) {
|
|
52
|
+
(options.log ?? ((line) => console.error(line)))(`nylo configure uses ${PI_AI}@${PI_AI_VERSION} for the model providers' sign-in, which nylorun does not install. Installing it once into ${directory}…`);
|
|
53
|
+
await installInto(directory, options.install ?? npmInstall);
|
|
54
|
+
}
|
|
55
|
+
return (await import(pathToFileURL(join(directory, ENTRY)).href));
|
|
56
|
+
}
|
|
57
|
+
/** Install beside `directory`, then move it in place; a concurrent install that won is kept. */
|
|
58
|
+
async function installInto(directory, install) {
|
|
59
|
+
const temporary = `${directory}.${randomBytes(4).toString("hex")}.tmp`;
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(temporary, { recursive: true });
|
|
62
|
+
await writeFile(join(temporary, "package.json"), `${JSON.stringify({ private: true, type: "module", dependencies: { [PI_AI]: PI_AI_VERSION } }, null, 2)}\n`);
|
|
63
|
+
await install(temporary);
|
|
64
|
+
await writeFile(join(temporary, ENTRY), ENTRY_SOURCE);
|
|
65
|
+
await rename(temporary, directory).catch((error) => {
|
|
66
|
+
if (!existsSync(join(directory, ENTRY)))
|
|
67
|
+
throw error;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new CliError(`Could not install ${PI_AI}@${PI_AI_VERSION} for nylo configure: ${error instanceof Error ? error.message : String(error)}. Install it beside nylorun (npm install ${PI_AI}@${PI_AI_VERSION}), or set the model provider in Studio.`, 1);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await rm(temporary, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The npm that runs this command (`npx` sets npm_execpath), else the one beside node, else PATH's. */
|
|
78
|
+
function npm() {
|
|
79
|
+
const candidates = [
|
|
80
|
+
process.env.npm_execpath,
|
|
81
|
+
join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"),
|
|
82
|
+
join(dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
|
|
83
|
+
];
|
|
84
|
+
const cli = candidates.find((path) => path?.endsWith("npm-cli.js") && existsSync(path));
|
|
85
|
+
return cli ? { command: process.execPath, args: [cli] } : { command: "npm", args: [] };
|
|
86
|
+
}
|
|
87
|
+
/** `npm install` without scripts; its output goes to stderr, so stdout stays the prompts'. */
|
|
88
|
+
function npmInstall(directory) {
|
|
89
|
+
const { command, args } = npm();
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const child = spawn(command, [
|
|
92
|
+
...args,
|
|
93
|
+
"install",
|
|
94
|
+
"--omit=dev",
|
|
95
|
+
"--ignore-scripts",
|
|
96
|
+
"--no-audit",
|
|
97
|
+
"--no-fund",
|
|
98
|
+
"--no-update-notifier",
|
|
99
|
+
"--loglevel=error",
|
|
100
|
+
], { cwd: directory, stdio: ["ignore", 2, 2] });
|
|
101
|
+
child.once("error", reject);
|
|
102
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`npm install exited with ${code}`)));
|
|
103
|
+
});
|
|
104
|
+
}
|