@schlenkr/ragents 0.1.3 → 0.1.4

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.
@@ -51,8 +51,9 @@ const resolveUserToken = (
51
51
  if (!isEnvironmentReference(raw) || typeof raw.name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw.name)) {
52
52
  throw new Error(`${location}.token braucht env("ENV_NAME"); ein Token steht nie im Klartext in der Profildatei`);
53
53
  }
54
+ // Ein Benutzer meldet sich weiter mit seinem Passwort an; ohne gesetzte Variable hat er nur keinen persönlichen Token.
54
55
  const token = environment[raw.name];
55
- if (!token) throw new Error(`${location}.token verweist auf die nicht gesetzte oder leere Umgebungsvariable ${raw.name}`);
56
+ if (!token) return undefined;
56
57
  // $ und ! sind tabu, weil der Verbraucher den Token als API-Schlüssel mit Vorlagen- und Befehlssyntax auswertet.
57
58
  if (token.length < MIN_TOKEN_LENGTH || token.length > MAX_TOKEN_LENGTH || !/^[\x21-\x7e]+$/.test(token) || /[$!]/.test(token)) {
58
59
  throw new Error(`${location}.token (${raw.name}) braucht ${MIN_TOKEN_LENGTH} bis ${MAX_TOKEN_LENGTH} druckbare ASCII-Zeichen ohne Leerzeichen, $ und !`);
@@ -0,0 +1,31 @@
1
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export interface HostRecord {
5
+ readonly profile: string;
6
+ readonly url: string;
7
+ readonly pid: number;
8
+ readonly log: string;
9
+ readonly startedAt: string;
10
+ }
11
+
12
+ /** Der gemerkte Host eines Profils; ragents run und ragents start schreiben dieselbe Datei, stop --host liest sie. */
13
+ export const hostRecordFile = (dataDirectory: string): string => path.join(dataDirectory, "host.json");
14
+
15
+ export const readHostRecord = (dataDirectory: string): HostRecord | undefined => {
16
+ const file = hostRecordFile(dataDirectory);
17
+ if (!statSync(file, { throwIfNoEntry: false })?.isFile()) return undefined;
18
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<HostRecord>;
19
+ // Eine PID unter 1 wäre für process.kill die ganze Prozessgruppe; sie darf nie aus dieser Datei kommen.
20
+ if (typeof parsed.url !== "string" || !Number.isInteger(parsed.pid) || (parsed.pid ?? 0) < 1) throw new Error(`${file} nennt keine Adresse oder keine PID des Hosts`);
21
+ return parsed as HostRecord;
22
+ };
23
+
24
+ export const writeHostRecord = (dataDirectory: string, record: HostRecord): void => {
25
+ mkdirSync(dataDirectory, { recursive: true });
26
+ writeFileSync(hostRecordFile(dataDirectory), `${JSON.stringify(record, null, 2)}\n`);
27
+ };
28
+
29
+ export const removeHostRecord = (dataDirectory: string): void => {
30
+ rmSync(hostRecordFile(dataDirectory), { force: true });
31
+ };
@@ -2,7 +2,9 @@ import { statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { isEnvironmentReference } from "./config-definition.js";
5
+ import { profileFileName } from "./config-file.js";
5
6
  import { defaultDataDirectory } from "./data-directory.js";
7
+ import { hostRoot } from "./host-version.js";
6
8
 
7
9
  export interface ProfileTarget {
8
10
  readonly profile: string;
@@ -12,6 +14,29 @@ export interface ProfileTarget {
12
14
  readonly baseUrl: string;
13
15
  }
14
16
 
17
+ export interface LocalProfile {
18
+ readonly profile: string;
19
+ readonly profileFile: string;
20
+ }
21
+
22
+ const PROFILE_FILE = /^ragents\.config\.([a-z0-9]+(?:-[a-z0-9]+)*)\.ts$/;
23
+
24
+ /** pnpm und der bin-Befehl führen die Skripte mit dem Arbeitsverzeichnis apps/server aus; ein relativer Pfad gilt trotzdem ab dem Aufrufer. */
25
+ export const callerDirectory = (): string => process.env.RAGENTS_CWD ?? process.env.INIT_CWD ?? process.cwd();
26
+
27
+ /** Ein Profil ist ein Name neben dem Host (Checkout wie Paket) oder ein Pfad zu einer ragents.config.<profil>.ts an beliebiger Stelle. */
28
+ export const localProfile = (selection: string, root = hostRoot(), caller = callerDirectory()): LocalProfile | undefined => {
29
+ if (!selection.includes("/") && !selection.endsWith(".ts")) {
30
+ const file = path.join(root, profileFileName(selection));
31
+ return statSync(file, { throwIfNoEntry: false })?.isFile() ? { profile: selection, profileFile: file } : undefined;
32
+ }
33
+ const profileFile = path.resolve(caller, selection);
34
+ const match = PROFILE_FILE.exec(path.basename(profileFile));
35
+ if (!match) throw new Error(`Eine Profildatei heißt ragents.config.<profil>.ts, nicht ${path.basename(profileFile)}`);
36
+ if (!statSync(profileFile, { throwIfNoEntry: false })?.isFile()) throw new Error(`Die Profildatei fehlt: ${profileFile}`);
37
+ return { profile: match[1]!, profileFile };
38
+ };
39
+
15
40
  /** Port und Datenordner eines Profils, wie der Server sie später selbst bestimmt: Umgebung vor Profildatei vor Vorgabe. */
16
41
  export const readProfileTarget = async (profile: string, profileFile: string, environment = process.env): Promise<ProfileTarget> => {
17
42
  if (!statSync(profileFile, { throwIfNoEntry: false })?.isFile()) throw new Error(`Die Profildatei fehlt: ${profileFile}`);
@@ -23,3 +48,10 @@ export const readProfileTarget = async (profile: string, profileFile: string, en
23
48
  const dataDirectory = path.resolve(environment.DATA_DIR ?? fromFile ?? defaultDataDirectory(profile));
24
49
  return { profile, profileFile, port, dataDirectory, baseUrl: `http://localhost:${port}` };
25
50
  };
51
+
52
+ /** Die eine Auflösung für alle Befehle: Name oder Pfad zum Profil, danach Port und Datenordner wie beim Server. */
53
+ export const selectProfileTarget = async (selection: string, root = hostRoot(), environment = process.env): Promise<ProfileTarget> => {
54
+ const own = localProfile(selection, root);
55
+ if (!own) throw new Error(`Die Profildatei fehlt: ${path.join(root, profileFileName(selection))}`);
56
+ return readProfileTarget(own.profile, own.profileFile, environment);
57
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schlenkr/ragents",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "RAgents-Host: Server, Engine, Plugins und Werkzeuge ohne Quell-Checkout",
5
5
  "keywords": [
6
6
  "agents",
@@ -2,14 +2,15 @@
2
2
  import "../../apps/server/src/host-resolution.ts";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { coreContracts } from "../../apps/server/src/api/contracts.ts";
9
- import { profileFileName } from "../../apps/server/src/config-file.ts";
9
+ import { hostRecordFile, readHostRecord, removeHostRecord, writeHostRecord } from "../../apps/server/src/host-record.ts";
10
10
  import { hostRoot } from "../../apps/server/src/host-version.ts";
11
- import { readProfileTarget, type ProfileTarget } from "../../apps/server/src/profile-target.ts";
11
+ import { callerDirectory, selectProfileTarget, type ProfileTarget } from "../../apps/server/src/profile-target.ts";
12
12
  import { RpcClient } from "../../apps/web/src/rpc/client.ts";
13
+ import { RpcError } from "../../packages/ragents/src/rpc/protocol.ts";
13
14
  import { WORKSPACE_BINDING_OPTION_ID } from "../../plugins/ragents.workspace/contract.ts";
14
15
  import { JournalReader, journalLines, readJournal, RUN_ID_PATTERN, type JournalEvent } from "./journal.ts";
15
16
 
@@ -18,6 +19,10 @@ const HOST_START_TIMEOUT_MS = 120_000;
18
19
  const HOST_STOP_TIMEOUT_MS = 20_000;
19
20
  const POLL_INTERVAL_MS = 400;
20
21
  const HEALTH_TIMEOUT_MS = 2_000;
22
+ const CHAT_READY_TIMEOUT_MS = 60_000;
23
+
24
+ /** Vorgabeprofil der Agentenbefehle; RAGENTS_PROFILE gilt für alle Befehle derselben Shell. */
25
+ export const defaultProfile = (): string => process.env.RAGENTS_PROFILE ?? DEFAULT_PROFILE;
21
26
 
22
27
  export const usage = (): string => `Verwendung: ragents <befehl> [argumente]
23
28
 
@@ -31,11 +36,13 @@ export const usage = (): string => `Verwendung: ragents <befehl> [argumente]
31
36
  stop <run> [--profile <p>] den laufenden Turn abbrechen
32
37
  stop --host [--profile <p>] den gemerkten Host beenden
33
38
 
34
- Vorgabeprofil ist ${DEFAULT_PROFILE}. Exit-Code: 0 fertig, 2 abgebrochen, 1 fehlgeschlagen oder
35
- Verbindungsproblem. Die letzte Zeile auf stdout ist "run: <id>". Die Adresse kommt aus
36
- <Datenordner>/host.json, sonst aus RAGENTS_URL, sonst aus host.PORT des Profils; RAGENTS_TOKEN
37
- geht als Bearer-Token mit, falls das Profil eine Anmeldung verlangt. Der so gestartete Host baut
38
- die Oberfläche nicht - ein Agent braucht sie nicht; mit Oberfläche startet ragents start <profil>.`;
39
+ <p> ist ein Profilname neben dem Host oder der Pfad zu einer ragents.config.<profil>.ts an
40
+ beliebiger Stelle; ohne --profile gilt RAGENTS_PROFILE, sonst ${DEFAULT_PROFILE}. Exit-Code:
41
+ 0 fertig, 2 abgebrochen, 1 fehlgeschlagen oder Verbindungsproblem. Die letzte Zeile auf stdout
42
+ ist "run: <id>". Die Adresse kommt aus <Datenordner>/host.json, sonst aus RAGENTS_URL, sonst aus
43
+ host.PORT des Profils; RAGENTS_TOKEN geht als Bearer-Token mit, falls das Profil eine Anmeldung
44
+ verlangt. Der so gestartete Host baut die Oberfläche nicht - ein Agent braucht sie nicht; mit
45
+ Oberfläche startet ragents start <profil>.`;
39
46
 
40
47
  export type AgentCommand =
41
48
  | { readonly kind: "run"; readonly profile: string; readonly folder: string; readonly text: string; readonly entry: string | undefined; readonly json: boolean }
@@ -76,7 +83,7 @@ const scan = (argv: readonly string[], allowed: readonly string[]): Flags => {
76
83
  index += 1;
77
84
  }
78
85
  return {
79
- profile: values.get("--profile") ?? DEFAULT_PROFILE,
86
+ profile: values.get("--profile") ?? defaultProfile(),
80
87
  entry: values.get("--entry"),
81
88
  json: switches.has("--json"),
82
89
  tools: switches.has("--tools"),
@@ -125,35 +132,7 @@ export const parseArguments = (argv: readonly string[]): AgentCommand => {
125
132
  throw new Error(command ? `Unbekannter Befehl: ${command}\n\n${usage()}` : usage());
126
133
  };
127
134
 
128
- export interface HostRecord {
129
- readonly profile: string;
130
- readonly url: string;
131
- readonly pid: number;
132
- readonly log: string;
133
- readonly startedAt: string;
134
- }
135
-
136
- export const hostRecordFile = (dataDirectory: string): string => path.join(dataDirectory, "host.json");
137
-
138
- export const readHostRecord = (dataDirectory: string): HostRecord | undefined => {
139
- const file = hostRecordFile(dataDirectory);
140
- if (!statSync(file, { throwIfNoEntry: false })?.isFile()) return undefined;
141
- const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<HostRecord>;
142
- // Eine PID unter 1 wäre für process.kill die ganze Prozessgruppe; sie darf nie aus dieser Datei kommen.
143
- if (typeof parsed.url !== "string" || !Number.isInteger(parsed.pid) || (parsed.pid ?? 0) < 1) throw new Error(`${file} nennt keine Adresse oder keine PID des Hosts`);
144
- return parsed as HostRecord;
145
- };
146
-
147
- export const writeHostRecord = (dataDirectory: string, record: HostRecord): void => {
148
- mkdirSync(dataDirectory, { recursive: true });
149
- writeFileSync(hostRecordFile(dataDirectory), `${JSON.stringify(record, null, 2)}\n`);
150
- };
151
-
152
- export const loadProfile = (profile: string, root = hostRoot()): Promise<ProfileTarget> =>
153
- readProfileTarget(profile, path.join(root, profileFileName(profile)));
154
-
155
- /** pnpm und der bin-Befehl führen dieses Skript mit dem Arbeitsverzeichnis apps/server aus; ein relativer Ordner gilt trotzdem ab dem Aufrufer. */
156
- export const callerDirectory = (): string => process.env.RAGENTS_CWD ?? process.env.INIT_CWD ?? process.cwd();
135
+ export const loadProfile = (selection: string, root = hostRoot()): Promise<ProfileTarget> => selectProfileTarget(selection, root);
157
136
 
158
137
  const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
159
138
 
@@ -239,6 +218,34 @@ const client = (baseUrl: string): RpcClient => {
239
218
  });
240
219
  };
241
220
 
221
+ export const LOGIN_REQUIRED = "Das Profil verlangt eine Anmeldung; setze RAGENTS_TOKEN auf den persönlichen Token deines Benutzers "
222
+ + "(im Profil `token: env(...)`).";
223
+
224
+ /** Ein 401 ist keine Fehlermeldung des Servers wert, sondern der Hinweis auf den persönlichen Token des Profils. */
225
+ export const withLoginHint = async <T>(call: () => Promise<T>): Promise<T> => {
226
+ try {
227
+ return await call();
228
+ } catch (error) {
229
+ if (error instanceof RpcError && error.status === 401) throw new Error(`${LOGIN_REQUIRED} ${error.message}`);
230
+ throw error;
231
+ }
232
+ };
233
+
234
+ /** Ein Einstieg richtet den Run erst ein und bestimmt dabei seinen Chatpartner; bis dahin lehnt der Server die Nachricht ab. */
235
+ const sendWhenChatReady = async (rpc: RpcClient, runId: string, text: string): Promise<void> => {
236
+ const deadline = Date.now() + CHAT_READY_TIMEOUT_MS;
237
+ for (let attempt = 0; ; attempt += 1) {
238
+ try {
239
+ await rpc.call(coreContracts.chat.send, { runId, text });
240
+ return;
241
+ } catch (error) {
242
+ if (!(error instanceof RpcError) || error.domainCode !== "actor-chat-unsupported" || Date.now() >= deadline) throw error;
243
+ if (attempt === 0) note("== Der Einstieg richtet den Run ein; der Auftrag wartet auf seinen Chatpartner.");
244
+ await delay(POLL_INTERVAL_MS);
245
+ }
246
+ }
247
+ };
248
+
242
249
  export type TurnOutcome = "completed" | "interrupted" | "failed";
243
250
 
244
251
  export const EXIT_CODES: Readonly<Record<TurnOutcome, number>> = { completed: 0, interrupted: 2, failed: 1 };
@@ -351,11 +358,18 @@ const runCommand = async (command: Extract<AgentCommand, { kind: "run" }>, write
351
358
  const baseUrl = await ensureHost(target);
352
359
  const rpc = client(baseUrl);
353
360
  const runId = randomUUID();
354
- await rpc.call(coreContracts.startOptions.select, { runId, optionId: WORKSPACE_BINDING_OPTION_ID, value: { kind: "path", path: folder } });
355
- note(`== Run ${runId} auf ${folder} (${baseUrl})`);
361
+ const options = await withLoginHint(() => rpc.call(coreContracts.startOptions.list, { runId }));
362
+ if (options.some((option) => option.id === WORKSPACE_BINDING_OPTION_ID)) {
363
+ await withLoginHint(() => rpc.call(coreContracts.startOptions.select, { runId, optionId: WORKSPACE_BINDING_OPTION_ID, value: { kind: "path", path: folder } }));
364
+ note(`== Run ${runId} auf ${folder} (${baseUrl})`);
365
+ } else {
366
+ note(`== Run ${runId} (${baseUrl}); das Profil ${target.profile} kennt ${WORKSPACE_BINDING_OPTION_ID} nicht und legt seinen Arbeitsbereich selbst an, ${folder} bleibt ungebunden.`);
367
+ }
356
368
  const reader = new JournalReader(target.dataDirectory, runId);
357
- if (command.entry) await rpc.call(coreContracts.chat.start, { runId, entry: command.entry });
358
- await rpc.call(coreContracts.chat.send, { runId, text: command.text });
369
+ if (command.entry) await withLoginHint(() => rpc.call(coreContracts.chat.start, { runId, entry: command.entry }));
370
+ await withLoginHint(() => command.entry
371
+ ? sendWhenChatReady(rpc, runId, command.text)
372
+ : rpc.call(coreContracts.chat.send, { runId, text: command.text }).then(() => undefined));
359
373
  const outcome = await follow({ reader, baseUrl, text: command.text.trim(), json: command.json, write });
360
374
  write(`run: ${runId}`);
361
375
  return EXIT_CODES[outcome];
@@ -368,7 +382,7 @@ const sendCommand = async (command: Extract<AgentCommand, { kind: "send" }>, wri
368
382
  const rpc = client(baseUrl);
369
383
  const reader = new JournalReader(target.dataDirectory, command.runId);
370
384
  reader.next();
371
- await rpc.call(coreContracts.chat.send, { runId: command.runId, text: command.text });
385
+ await withLoginHint(() => rpc.call(coreContracts.chat.send, { runId: command.runId, text: command.text }));
372
386
  const outcome = await follow({ reader, baseUrl, text: command.text.trim(), json: command.json, write });
373
387
  write(`run: ${command.runId}`);
374
388
  return EXIT_CODES[outcome];
@@ -385,7 +399,7 @@ const journalCommand = async (command: Extract<AgentCommand, { kind: "journal" }
385
399
  const stopCommand = async (command: Extract<AgentCommand, { kind: "stop" }>): Promise<number> => {
386
400
  const target = await loadProfile(command.profile);
387
401
  const baseUrl = await addressOf(target);
388
- await client(baseUrl).call(coreContracts.chat.stop, { runId: command.runId });
402
+ await withLoginHint(() => client(baseUrl).call(coreContracts.chat.stop, { runId: command.runId }));
389
403
  note(`== Turn von ${command.runId} abgebrochen`);
390
404
  return 0;
391
405
  };
@@ -404,7 +418,7 @@ const stopHostCommand = async (command: Extract<AgentCommand, { kind: "stop-host
404
418
  const noted = readHostRecord(target.dataDirectory);
405
419
  if (!noted) throw new Error(`Für das Profil ${command.profile} ist kein Host gemerkt (${hostRecordFile(target.dataDirectory)} fehlt).`);
406
420
  if (!alive(noted.pid)) {
407
- rmSync(hostRecordFile(target.dataDirectory), { force: true });
421
+ removeHostRecord(target.dataDirectory);
408
422
  note(`== Der gemerkte Host (PID ${noted.pid}) läuft nicht mehr; der Vermerk ist weg.`);
409
423
  return 0;
410
424
  }
@@ -415,7 +429,7 @@ const stopHostCommand = async (command: Extract<AgentCommand, { kind: "stop-host
415
429
  process.kill(noted.pid, "SIGKILL");
416
430
  await delay(500);
417
431
  }
418
- rmSync(hostRecordFile(target.dataDirectory), { force: true });
432
+ removeHostRecord(target.dataDirectory);
419
433
  note(`== Host ${noted.url} (PID ${noted.pid}) beendet`);
420
434
  return 0;
421
435
  };
@@ -430,10 +444,14 @@ export const execute = async (command: AgentCommand, write: LineWriter = toStdou
430
444
 
431
445
  const main = async (): Promise<number> => {
432
446
  const argv = process.argv.slice(2);
433
- if (argv.length === 0 || argv[0] === "--help") {
447
+ if (argv[0] === "--help" || argv[0] === "help") {
434
448
  console.log(usage());
435
449
  return 0;
436
450
  }
451
+ if (argv.length === 0) {
452
+ console.error(usage());
453
+ return 1;
454
+ }
437
455
  return execute(parseArguments(argv));
438
456
  };
439
457
 
@@ -29,11 +29,21 @@ const usage = `Verwendung: ragents <befehl> [argumente]
29
29
  geholten Stand starten; baut die Oberfläche bei Bedarf
30
30
  provision [<profil>|--workspace] Werkzeuge der Plugins holen
31
31
  workspace-client <server-url> diesen Rechner als Arbeitsplatz anmelden
32
+ --help, help diese Verwendung
33
+
34
+ run, send, journal und stop nehmen mit --profile <profil|pfad> ein anderes Profil: einen Namen
35
+ neben dem Host oder den Pfad zu einer eigenen ragents.config.<profil>.ts. RAGENTS_PROFILE setzt
36
+ dasselbe für alle Befehle einer Shell; verlangt das Profil eine Anmeldung, gehört der
37
+ persönliche Token des Benutzers in RAGENTS_TOKEN.
32
38
  `;
33
39
 
34
40
  const main = () => {
35
41
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
36
42
  const [command, ...rest] = process.argv.slice(2);
43
+ if (command === "--help" || command === "help") {
44
+ console.log(usage);
45
+ process.exit(0);
46
+ }
37
47
  const script = command ? commands[command] : undefined;
38
48
  if (!script) {
39
49
  console.error(command ? `Unbekannter Befehl: ${command}\n\n${usage}` : usage);
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { extract as extractTar } from "tar";
9
9
  import { ragentsDataRoot } from "../../apps/server/src/data-directory.ts";
10
+ import { removeHostRecord, writeHostRecord } from "../../apps/server/src/host-record.ts";
10
11
  import { hostRoot, readHostPackage, readHostVersion } from "../../apps/server/src/host-version.ts";
11
12
  import { readProfileTarget } from "../../apps/server/src/profile-target.ts";
12
13
  import { ensureWebBuild } from "../../apps/server/src/web-build.ts";
@@ -187,8 +188,13 @@ export const hostScript = (script: string, args: readonly string[] = []): { file
187
188
  cwd: path.join(hostRoot(), "apps/server"),
188
189
  });
189
190
 
190
- const run = (command: { file: string; args: readonly string[]; cwd: string }, env: NodeJS.ProcessEnv): Promise<number> => new Promise((resolve, reject) => {
191
+ const run = (
192
+ command: { file: string; args: readonly string[]; cwd: string },
193
+ env: NodeJS.ProcessEnv,
194
+ started?: (pid: number) => void,
195
+ ): Promise<number> => new Promise((resolve, reject) => {
191
196
  const child = spawn(command.file, [...command.args], { cwd: command.cwd, env, stdio: "inherit" });
197
+ if (child.pid) started?.(child.pid);
192
198
  const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
193
199
  const onInterrupt = forward("SIGINT");
194
200
  const onTerminate = forward("SIGTERM");
@@ -233,9 +239,21 @@ export interface StartedProfile {
233
239
  readonly port: number | undefined;
234
240
  }
235
241
 
242
+ /** ragents run und ragents start merken denselben Host im Datenordner des Profils; ein Port 0 sucht sich seinen Port selbst und lässt sich nicht merken. */
243
+ export const noteHost = (profile: string, dataDirectory: string, port: number): ((pid: number) => void) | undefined =>
244
+ port > 0 ? (pid: number): void => writeHostRecord(dataDirectory, {
245
+ profile,
246
+ url: `http://localhost:${port}`,
247
+ pid,
248
+ log: path.join(dataDirectory, "logs", "server.log"),
249
+ startedAt: new Date().toISOString(),
250
+ }) : undefined;
251
+
236
252
  /** Provisioniert die Werkzeuge des Profils, baut sein Web falls nötig und startet den Server damit. */
237
253
  export const startProfile = async (started: StartedProfile): Promise<number> => {
238
- const dataDirectory = started.dataDirectory ?? (await readProfileTarget(started.profile, started.profileFile)).dataDirectory;
254
+ const target = await readProfileTarget(started.profile, started.profileFile);
255
+ const dataDirectory = started.dataDirectory ?? target.dataDirectory;
256
+ const port = started.port ?? target.port;
239
257
  const env: NodeJS.ProcessEnv = {
240
258
  ...process.env,
241
259
  PRODUCT_PROFILE: started.profile,
@@ -254,7 +272,12 @@ export const startProfile = async (started: StartedProfile): Promise<number> =>
254
272
  log: (line) => console.log(line),
255
273
  });
256
274
  console.log(`== Server starten (Web ${webDirectory}, Daten ${dataDirectory})`);
257
- return run(hostScript("apps/server/src/main.ts", started.port !== undefined ? ["--port", String(started.port)] : []), { ...env, WEB_DIST_DIR: webDirectory });
275
+ const note = noteHost(started.profile, dataDirectory, port);
276
+ try {
277
+ return await run(hostScript("apps/server/src/main.ts", started.port !== undefined ? ["--port", String(started.port)] : []), { ...env, WEB_DIST_DIR: webDirectory }, note);
278
+ } finally {
279
+ if (note) removeHostRecord(dataDirectory);
280
+ }
258
281
  };
259
282
 
260
283
  /** Startet einen Stand, den connect geholt hat, mit dem Web aus dem Cache. */
@@ -1,13 +1,10 @@
1
1
  // Der Hook löst @aicontainer/* auch für eine Profildatei außerhalb des Hosts auf; im Paket gibt es dort kein node_modules.
2
2
  import "../../apps/server/src/host-resolution.ts";
3
- import { statSync } from "node:fs";
4
3
  import { readFile, readdir } from "node:fs/promises";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
- import { profileFileName } from "../../apps/server/src/config-file.ts";
8
6
  import { ragentsDataRoot } from "../../apps/server/src/data-directory.ts";
9
- import { hostRoot } from "../../apps/server/src/host-version.ts";
10
- import { callerDirectory } from "../agent/agent-cli.ts";
7
+ import { localProfile } from "../../apps/server/src/profile-target.ts";
11
8
  import { startCached, startProfile, type CachedProfile } from "./connect.ts";
12
9
 
13
10
  const usage = (): string => `Verwendung: ragents start <profil|pfad> [--port <n>]
@@ -64,26 +61,6 @@ export const selectCachedProfile = (cached: readonly CachedProfile[], profile: s
64
61
  + "nimm ragents connect <server-url>.");
65
62
  };
66
63
 
67
- const PROFILE_FILE = /^ragents\.config\.([a-z0-9]+(?:-[a-z0-9]+)*)\.ts$/;
68
-
69
- export interface LocalProfile {
70
- readonly profile: string;
71
- readonly profileFile: string;
72
- }
73
-
74
- /** Ein Profil ist ein Name neben dem Host (Checkout wie Paket) oder ein Pfad zu einer ragents.config.<profil>.ts an beliebiger Stelle. */
75
- export const localProfile = (selection: string, root = hostRoot(), caller = callerDirectory()): LocalProfile | undefined => {
76
- if (!selection.includes("/") && !selection.endsWith(".ts")) {
77
- const file = path.join(root, profileFileName(selection));
78
- return statSync(file, { throwIfNoEntry: false })?.isFile() ? { profile: selection, profileFile: file } : undefined;
79
- }
80
- const profileFile = path.resolve(caller, selection);
81
- const match = PROFILE_FILE.exec(path.basename(profileFile));
82
- if (!match) throw new Error(`Eine Profildatei heißt ragents.config.<profil>.ts, nicht ${path.basename(profileFile)}`);
83
- if (!statSync(profileFile, { throwIfNoEntry: false })?.isFile()) throw new Error(`Die Profildatei fehlt: ${profileFile}`);
84
- return { profile: match[1]!, profileFile };
85
- };
86
-
87
64
  const main = async (): Promise<void> => {
88
65
  const arguments_ = parseArguments(process.argv.slice(2));
89
66
  const own = localProfile(arguments_.selection);