nylorun 0.12.1-beta → 0.13.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 +36 -0
- package/README.md +105 -7
- 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 +25 -0
- package/dist/client/doctor.js +65 -0
- package/dist/client/installation/commands.d.ts +7 -0
- package/dist/client/installation/commands.js +80 -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/package.json +10 -6
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ManagementClient } from "@nylorun/admin";
|
|
2
|
+
import { type ProjectCredentials, type ProjectLink as LinkFile } from "@nylorun/core/project";
|
|
3
|
+
/**
|
|
4
|
+
* The Project link (format 3), which `nylorun start` writes and `nylo` only reads with
|
|
5
|
+
* `@nylorun/core/project`'s reader. `tenantId` is information: the installation serves one
|
|
6
|
+
* Tenant, so nothing selects it.
|
|
7
|
+
*/
|
|
8
|
+
export type ProjectLink = LinkFile & {
|
|
9
|
+
format: 3;
|
|
10
|
+
};
|
|
11
|
+
/** The Project's link; one of an older nylorun (formats 0 to 2) is refused. */
|
|
12
|
+
export declare function readLink(projectRoot: string): Promise<ProjectLink | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* The Project's credentials (mode 0600), which `nylorun start` writes beside the link: the
|
|
15
|
+
* application key and its principal, and the management key and its principal when it issued
|
|
16
|
+
* one. A format 0 file's legacy `executors` map is ignored.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readCredentials(projectRoot: string): Promise<ProjectCredentials | undefined>;
|
|
19
|
+
/** The installation `nylo` acts on: a Runtime URL, an application key and a management key. */
|
|
20
|
+
export interface LinkedConnection {
|
|
21
|
+
url: string;
|
|
22
|
+
/** The application key (Runtime API); absent when only a management key is set. */
|
|
23
|
+
key?: string;
|
|
24
|
+
/** The management key (Management API), when there is one. */
|
|
25
|
+
managementKey?: string;
|
|
26
|
+
/** The Project link it came from, when it came from one. */
|
|
27
|
+
link?: ProjectLink;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The Project's link and credentials, else `NYLORUN_RUNTIME_URL` with `NYLORUN_SERVER_KEY`, or
|
|
31
|
+
* `NYLORUN_MANAGEMENT_KEY`, or both; `NYLORUN_MANAGEMENT_KEY` also stands in for a Project
|
|
32
|
+
* credentials file without one. The installation serves one Tenant, so nothing names it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function linkedConnection(projectRoot: string, env?: Readonly<Record<string, string | undefined>>): Promise<LinkedConnection>;
|
|
35
|
+
/** The Management API client of `connection`; refused without a management key. */
|
|
36
|
+
export declare function managementClient(connection: LinkedConnection): ManagementClient;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { chmod } from "node:fs/promises";
|
|
2
|
+
import { createManagementClient } from "@nylorun/admin";
|
|
3
|
+
import { ProjectFileError, projectCredentialsPath, projectLinkPath, readProjectCredentials, readProjectLink, } from "@nylorun/core/project";
|
|
4
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
/** The Project's link; one of an older nylorun (formats 0 to 2) is refused. */
|
|
6
|
+
export async function readLink(projectRoot) {
|
|
7
|
+
const path = projectLinkPath(projectRoot);
|
|
8
|
+
let link;
|
|
9
|
+
try {
|
|
10
|
+
link = readProjectLink(projectRoot);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (!(error instanceof ProjectFileError))
|
|
14
|
+
throw error;
|
|
15
|
+
throw new CliError(error.reason === "newer"
|
|
16
|
+
? `Project link at ${path} has an unsupported format. Upgrade nylorun.`
|
|
17
|
+
: `Invalid Project link at ${path}. Remove .nylorun/link.json and run "npx nylorun start".`, 1);
|
|
18
|
+
}
|
|
19
|
+
if (link === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (link.format !== 3)
|
|
22
|
+
throw new CliError(`The Project link at ${path} is from an older nylorun. Run "npx nylorun start" in this project to link it again.`, 1);
|
|
23
|
+
return { ...link, format: 3 };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The Project's credentials (mode 0600), which `nylorun start` writes beside the link: the
|
|
27
|
+
* application key and its principal, and the management key and its principal when it issued
|
|
28
|
+
* one. A format 0 file's legacy `executors` map is ignored.
|
|
29
|
+
*/
|
|
30
|
+
export async function readCredentials(projectRoot) {
|
|
31
|
+
const path = projectCredentialsPath(projectRoot);
|
|
32
|
+
let credentials;
|
|
33
|
+
try {
|
|
34
|
+
credentials = readProjectCredentials(projectRoot);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!(error instanceof ProjectFileError))
|
|
38
|
+
throw error;
|
|
39
|
+
throw new CliError(error.reason === "newer"
|
|
40
|
+
? `Project credentials at ${path} have an unsupported format. Upgrade nylorun.`
|
|
41
|
+
: `Invalid Project credentials at ${path}. Remove .nylorun/credentials.json and run "npx nylorun start".`, 1);
|
|
42
|
+
}
|
|
43
|
+
if (credentials)
|
|
44
|
+
await chmod(path, 0o600);
|
|
45
|
+
return credentials;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The Project's link and credentials, else `NYLORUN_RUNTIME_URL` with `NYLORUN_SERVER_KEY`, or
|
|
49
|
+
* `NYLORUN_MANAGEMENT_KEY`, or both; `NYLORUN_MANAGEMENT_KEY` also stands in for a Project
|
|
50
|
+
* credentials file without one. The installation serves one Tenant, so nothing names it.
|
|
51
|
+
*/
|
|
52
|
+
export async function linkedConnection(projectRoot, env = process.env) {
|
|
53
|
+
const link = await readLink(projectRoot);
|
|
54
|
+
const credentials = await readCredentials(projectRoot);
|
|
55
|
+
const managementKey = env.NYLORUN_MANAGEMENT_KEY?.trim();
|
|
56
|
+
if (link && credentials) {
|
|
57
|
+
const management = credentials.managementKey ?? managementKey;
|
|
58
|
+
return {
|
|
59
|
+
url: link.hostUrl,
|
|
60
|
+
key: credentials.applicationKey,
|
|
61
|
+
...(management ? { managementKey: management } : {}),
|
|
62
|
+
link,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const url = env.NYLORUN_RUNTIME_URL?.trim();
|
|
66
|
+
const key = env.NYLORUN_SERVER_KEY?.trim();
|
|
67
|
+
if (url && (key || managementKey))
|
|
68
|
+
return {
|
|
69
|
+
url: url.replace(/\/$/, ""),
|
|
70
|
+
...(key ? { key } : {}),
|
|
71
|
+
...(managementKey ? { managementKey } : {}),
|
|
72
|
+
};
|
|
73
|
+
throw new CliError(`No Project link in ${projectRoot}. Run "npx nylorun start" in this project to start its Tenant and link it, or set NYLORUN_RUNTIME_URL and NYLORUN_SERVER_KEY (or NYLORUN_MANAGEMENT_KEY).`, 1);
|
|
74
|
+
}
|
|
75
|
+
/** The Management API client of `connection`; refused without a management key. */
|
|
76
|
+
export function managementClient(connection) {
|
|
77
|
+
if (!connection.managementKey)
|
|
78
|
+
throw new CliError(`No management key for the Tenant at ${connection.url}: run npx nylorun start, or set NYLORUN_MANAGEMENT_KEY.`, 1);
|
|
79
|
+
return createManagementClient({ url: connection.url, key: connection.managementKey });
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { findProjectRoot } from "@nylorun/core/project";
|
|
2
|
+
import { readCredentials, readLink } from "./connection.js";
|
|
3
|
+
/** Two `export` lines for `nylo env` (was `nylorun status --env`, F2-7). */
|
|
4
|
+
export async function printLinkedEnvExports(projectRoot = process.cwd()) {
|
|
5
|
+
const root = findProjectRoot(projectRoot) ?? projectRoot;
|
|
6
|
+
const link = await readLink(root);
|
|
7
|
+
const credentials = await readCredentials(root);
|
|
8
|
+
if (!link || !credentials) {
|
|
9
|
+
console.log('# No Project link in this directory. Run "npx nylorun start" in this project to start its Tenant and link it.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log(`export NYLORUN_RUNTIME_URL=${shellQuote(link.hostUrl)}`);
|
|
13
|
+
console.log(`export NYLORUN_SERVER_KEY=${shellQuote(credentials.applicationKey)}`);
|
|
14
|
+
}
|
|
15
|
+
export function shellQuote(value) {
|
|
16
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(value))
|
|
17
|
+
return value;
|
|
18
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Both commands refuse native Windows before acting; WSL2 runs them. */
|
|
2
|
+
export declare function refuseNativeWindows(): void;
|
|
3
|
+
/**
|
|
4
|
+
* Run a command (`nylorun` or `nylo`) and exit once stdout and stderr are flushed: with
|
|
5
|
+
* `process.exitCode` (0 when unset), or after printing a failure's message with its
|
|
6
|
+
* `CliError` exit code (else 1).
|
|
7
|
+
*/
|
|
8
|
+
export declare function runCommand(main: () => Promise<void>): void;
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
/** Both commands refuse native Windows before acting; WSL2 runs them. */
|
|
3
|
+
export function refuseNativeWindows() {
|
|
4
|
+
if (process.platform === "win32")
|
|
5
|
+
throw new CliError("Nylorun does not run on native Windows. Use WSL2: install Node 24 and Docker (Docker Desktop's WSL integration) inside your WSL distribution and run nylorun there (https://learn.microsoft.com/windows/wsl/install).", 1);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Run a command (`nylorun` or `nylo`) and exit once stdout and stderr are flushed: with
|
|
9
|
+
* `process.exitCode` (0 when unset), or after printing a failure's message with its
|
|
10
|
+
* `CliError` exit code (else 1).
|
|
11
|
+
*/
|
|
12
|
+
export function runCommand(main) {
|
|
13
|
+
void main().then(() => finish(process.exitCode === undefined ? 0 : Number(process.exitCode)), (error) => {
|
|
14
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
15
|
+
return finish(error instanceof CliError ? error.exitCode : 1);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function finish(code) {
|
|
19
|
+
process.exitCode = code;
|
|
20
|
+
for (const stream of [process.stdout, process.stderr])
|
|
21
|
+
await new Promise((resolve) => stream.write("", () => resolve()));
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Exit codes are part of
|
|
1
|
+
/** Exit codes are part of both commands' contract; see nylorun/README.md ("Exit codes"). */
|
|
2
2
|
export declare class CliError extends Error {
|
|
3
3
|
readonly exitCode: number;
|
|
4
4
|
constructor(message: string, exitCode?: number);
|
package/dist/errors.js
CHANGED
package/dist/nylo.d.ts
ADDED
package/dist/nylo.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "./baseline.js";
|
|
3
|
+
import { runCommand } from "./command.js";
|
|
4
|
+
// `nylo`, the Runtime client (client/). `nylorun`'s entry never imports it, so `npx nylorun start`
|
|
5
|
+
// loads none of it, nor @nylorun/admin.
|
|
6
|
+
runCommand(async () => {
|
|
7
|
+
const { main } = await import("./client/cli.js");
|
|
8
|
+
await main(process.argv.slice(2));
|
|
9
|
+
});
|
package/dist/project/link.d.ts
CHANGED
|
@@ -31,7 +31,8 @@ export interface KeyCredentials {
|
|
|
31
31
|
export declare function readProjectCredentials(projectRoot: string): Promise<KeyCredentials | undefined>;
|
|
32
32
|
/**
|
|
33
33
|
* A credentials file (format 0 or 1): the Project's, or one the nylorun commands keep in the
|
|
34
|
-
* Host root. Undefined when absent or unreadable, so `start` replaces it
|
|
34
|
+
* Host root. Undefined when absent or unreadable, so `start` replaces it; a newer format is an
|
|
35
|
+
* error, as for the link, so this release never rewrites it in its own.
|
|
35
36
|
*/
|
|
36
37
|
export declare function readCredentialsFile(path: string): Promise<KeyCredentials | undefined>;
|
|
37
38
|
/** `.nylorun/credentials.json` (format 1, mode 0600). */
|