oft-agt 0.0.0-stage → 0.5.1

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/cli.mjs ADDED
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ import { localStatus, startBackground, stopBackground, managedDaemon, readLogs, removeLocalInstance, ensureLocalLauncher } from './runtime/lifecycle.mjs';
3
+ import { completeCredentialRotation, rotateCredential } from './runtime/agent-auth.mjs';
4
+ import { authorizeDevice } from './runtime/device-login.mjs';
5
+ import { generateKeyPairSync, privateDecrypt, constants, createDecipheriv } from 'node:crypto';
6
+ import { readFile, writeFile, mkdir, rename, access, symlink, open, unlink, cp } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import path from 'node:path';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { spawn } from 'node:child_process';
12
+ import WebSocket from 'ws';
13
+ import { writeBot, compile } from './compiler.mjs';
14
+ import { acceptSecret } from './secret.mjs';
15
+ import { createUsageCollector } from './usage.mjs';
16
+ import { instanceDirectory, resolveInstance, listInstances, rememberInstance } from './instance.mjs';
17
+ import { AGENT_VERSION } from './version.mjs';
18
+ import { installUpdate } from './update.mjs';
19
+ import { supervise } from './supervisor.mjs';
20
+
21
+ const baseHome = process.env.OFTONE_AGENT_HOME || path.join(homedir(), '.oftone-agent');
22
+ let home = baseHome, configPath, secretPath, keyPath;
23
+ function useHome(directory) { home = directory; configPath = path.join(home, 'agent.json'); secretPath = path.join(home, 'discord-secret.json'); keyPath = path.join(home, 'private.pem'); }
24
+ useHome(baseHome);
25
+ const require = createRequire(import.meta.url);
26
+ let child;
27
+ let active;
28
+ let usage;
29
+ let usageReady = true;
30
+ function watchUsage(process) { process.on('message', message => { try { usage?.record(message); } catch { console.error('Mesure statistique ignorée.'); } }); return process; }
31
+ async function exists(p) { try { await access(p); return true; } catch { return false; } }
32
+ async function save(p, data) { await writeFile(p + '.tmp', typeof data === 'string' ? data : JSON.stringify(data), { mode: 0o600 }); await rename(p + '.tmp', p); }
33
+ function decrypt(envelope, privateKey) {
34
+ const key = privateDecrypt({ key: privateKey, oaepHash: 'sha256', padding: constants.RSA_PKCS1_OAEP_PADDING }, Buffer.from(envelope.key, 'base64'));
35
+ const bytes = Buffer.from(envelope.ciphertext, 'base64');
36
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
37
+ decipher.setAuthTag(bytes.subarray(-16));
38
+ return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(0, -16)), decipher.final()]).toString('utf8'));
39
+ }
40
+ function run(executable, args, options) {
41
+ return new Promise((resolve, reject) => { const process = spawn(executable, args, { ...options, windowsHide: true, shell: false }); process.on('error', reject); process.on('exit', code => code === 0 ? resolve() : reject(new Error('PROCESS_FAILED'))); });
42
+ }
43
+ async function credentials(config) {
44
+ const secret = decrypt(JSON.parse(await readFile(secretPath, 'utf8')), await readFile(keyPath, 'utf8'));
45
+ if (secret.agentId !== config.agentId || !/^\d{17,20}$/.test(secret.applicationId) || typeof secret.token !== 'string') throw new Error('SECRET_INVALID');
46
+ return secret;
47
+ }
48
+ async function stopBot() {
49
+ const current = child;
50
+ if (!current || current.exitCode !== null || current.signalCode) return;
51
+ await new Promise(resolve => { const timer = setTimeout(() => current.kill('SIGKILL'), 5000); current.once('exit', () => { clearTimeout(timer); resolve(); }); current.kill('SIGTERM'); });
52
+ }
53
+ async function restartBot(config) {
54
+ if (!await exists(path.join(home, 'active.json'))) throw new Error('NO_DEPLOYMENT');
55
+ const release = JSON.parse(await readFile(path.join(home, 'active.json'), 'utf8'));
56
+ if (!/^[a-f0-9-]{36}$/.test(release.id)) throw new Error('INVALID_RELEASE');
57
+ const secret = await credentials(config);
58
+ await stopBot();
59
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: path.join(home, 'releases', release.id), env: { ...Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP'].filter(key => process.env[key]).map(key => [key, process.env[key]])), DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], windowsHide: true, shell: false }));
60
+ secret.token = '';
61
+ child.stderr.on('data', () => {});
62
+ await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill(); reject(new Error('BOT_START_TIMEOUT')); }, 45000); const fail = () => { clearTimeout(timer); reject(new Error('BOT_START_FAILED')); }; child.once('error', fail); child.once('exit', fail); child.stdout.on('data', bytes => { if (bytes.toString().includes('Bot connecté :')) { clearTimeout(timer); child.off('error', fail); child.off('exit', fail); resolve(); } }); });
63
+ active = release.id;
64
+ }
65
+ async function deploy(config, message, notify) {
66
+ if (!/^[a-f0-9-]{36}$/.test(message.id)) throw new Error('DEPLOYMENT_INVALID');
67
+ compile(message.snapshot); // Never execute a plan or JavaScript supplied by the remote service.
68
+ notify('received');
69
+ const target = path.join(home, 'releases', message.id);
70
+ const state = await exists(path.join(home, 'active.json')) ? JSON.parse(await readFile(path.join(home, 'active.json'), 'utf8')) : null;
71
+ if (state?.id === message.id && child && child.exitCode === null && !child.signalCode) { notify('running'); return; }
72
+ notify('installing');
73
+ if (!await exists(target)) {
74
+ await writeBot(message.snapshot, target);
75
+ // Reuse the trusted agent package's dependencies; deployments never run npm or shell scripts.
76
+ const modules = path.resolve(path.dirname(require.resolve('discord.js')), '../..');
77
+ await symlink(modules, path.join(target, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir');
78
+ }
79
+ const secret = await credentials(config);
80
+ const env = { ...Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'TZ'].filter(key => process.env[key]).map(key => [key, process.env[key]])), DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId };
81
+ // Remove credentials unrelated to running the Discord bot.
82
+ for (const key of Object.keys(env)) if (/OFTONE|AGENT_RELAY|CLOUDFLARE|SESSION_SECRET/.test(key)) delete env[key];
83
+ await run(process.execPath, ['bot.mjs', '--deploy'], { cwd: target, env, stdio: 'ignore' });
84
+ const previous = child;
85
+ const previousId = active;
86
+ if (previous && previous.exitCode === null && !previous.signalCode) {
87
+ await new Promise(resolve => { const timeout = setTimeout(() => { previous.kill('SIGKILL'); }, 5000); previous.once('exit', () => { clearTimeout(timeout); resolve(); }); previous.kill('SIGTERM'); });
88
+ }
89
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: target, env, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], windowsHide: true, shell: false }));
90
+ child.stderr.on('data', () => {});
91
+ try { await new Promise((resolve, reject) => {
92
+ const timer = setTimeout(() => { child.kill(); reject(new Error('BOT_START_TIMEOUT')); }, 45000);
93
+ const failed = () => { clearTimeout(timer); reject(new Error('BOT_START_FAILED')); };
94
+ child.once('error', failed); child.once('exit', failed);
95
+ child.stdout.on('data', data => { if (data.toString().includes('Bot connecté :')) { clearTimeout(timer); child.off('exit', failed); child.off('error', failed); resolve(); } });
96
+ }); } catch (error) {
97
+ if (child.exitCode === null) child.kill();
98
+ if (previousId && previousId !== message.id) {
99
+ const previousTarget = path.join(home, 'releases', previousId);
100
+ await run(process.execPath, ['bot.mjs', '--deploy'], { cwd: previousTarget, env, stdio: 'ignore' }).catch(() => {});
101
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: previousTarget, env, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, shell: false }));
102
+ child.on('error', () => {});
103
+ }
104
+ throw error;
105
+ }
106
+ child.once('exit', () => { if (active === message.id) notify('failed'); });
107
+ active = message.id;
108
+ await save(path.join(home, 'active.json'), { id: message.id });
109
+ notify('running');
110
+ }
111
+ async function start(config) {
112
+ const lockPath = path.join(home, 'agent.lock');
113
+ if (await exists(lockPath)) {
114
+ const pid = Number(await readFile(lockPath, 'utf8'));
115
+ let alive = false;
116
+ try { process.kill(pid, 0); alive = true; } catch (error) { if (error.code === 'EPERM') alive = true; }
117
+ if (alive) throw new Error('AGENT_ACTIVE');
118
+ await unlink(lockPath);
119
+ }
120
+ const lock = await open(lockPath, 'wx', 0o600);
121
+ await lock.writeFile(String(process.pid));
122
+ process.once('exit', () => {});
123
+ let stopped = false, shutdownBot = false, updating = false, activeSocket;
124
+ const shutdown = () => { stopped = true; shutdownBot = true; activeSocket?.close(); };
125
+ const localControl = message => { if (message?.type === 'shutdown') shutdown(); };
126
+ process.on('message', localControl);
127
+ process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
128
+ if (process.connected) process.once('disconnect', shutdown);
129
+ usage = await createUsageCollector(home).catch(() => { usageReady = false; console.error('Statistiques locales indisponibles. Le bot continue à fonctionner.'); return { record() {}, acknowledge() {}, async flush() {} }; });
130
+ if (await exists(path.join(home, 'active.json'))) active = JSON.parse(await readFile(path.join(home, 'active.json'), 'utf8')).id;
131
+ let delay = 1000;
132
+ await mkdir(path.join(home, 'releases'), { recursive: true, mode: 0o700 });
133
+ // An existing bot is restarted locally even when the relay is unavailable.
134
+ if (await exists(path.join(home, 'active.json')) && await exists(secretPath) && !await exists(path.join(home, 'paused.json'))) {
135
+ const previous = JSON.parse(await readFile(path.join(home, 'active.json'), 'utf8'));
136
+ if (!/^[a-f0-9-]{36}$/.test(previous.id)) throw new Error('État local invalide.');
137
+ const secret = await credentials(config);
138
+ const target = path.join(home, 'releases', previous.id);
139
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: target, env: { PATH: process.env.PATH, DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, shell: false }));
140
+ child.on('error', () => {});
141
+ active = previous.id;
142
+ }
143
+ let pending = Promise.resolve();
144
+ while (!stopped) {
145
+ await new Promise(resolve => {
146
+ const socket = new WebSocket(`${config.relay.replace(/^https:/, 'wss:')}/v1/agents/${config.object}/socket`, { headers: { Authorization: `Bearer ${config.credential}` }, maxPayload: 512000 });
147
+ activeSocket = socket;
148
+ let heartbeat, usageTimer;
149
+ const send = value => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value)); };
150
+ socket.on('open', async () => {
151
+ delay = 1000;
152
+ let profile;
153
+ if (await exists(secretPath)) {
154
+ try {
155
+ const secret = await credentials(config);
156
+ try { const response = await fetch('https://discord.com/api/v10/users/@me', { headers: { Authorization: 'Bot ' + secret.token }, signal: AbortSignal.timeout(15000) }); if (response.ok) { const user = await response.json(); profile = { id: user.id, username: user.username, avatar: user.avatar }; } } finally { secret.token = ''; }
157
+ } catch {}
158
+ }
159
+ send({ type: 'hello', agentVersion: AGENT_VERSION, updatesVersion: 1, protocol: 2, errorBranch: true, diagnosticsVersion: 2, optionsVersion: 1, conditionsVersion: 1, metricsVersion: usageReady ? 1 : 0, profile, running: Boolean(child && child.exitCode === null && !child.signalCode), hasToken: await exists(secretPath), deploymentId: active });
160
+ if (process.connected) process.send?.({ type: 'agent-ready' }, () => {});
161
+ await usage.flush(send).catch(() => console.error('Statistiques locales indisponibles.'));
162
+ usageTimer = setInterval(() => usage.flush(send).catch(() => console.error('Statistiques locales indisponibles.')), 60000);
163
+ heartbeat = setInterval(() => socket.ping(), 45000); console.log(`[${new Date().toISOString()}] Agent ${AGENT_VERSION} connecté.`);
164
+ });
165
+ socket.on('message', raw => {
166
+ pending = pending.then(async () => {
167
+ const message = JSON.parse(raw.toString());
168
+ if (message.type === 'update-available') { console.log('Une mise à jour de l’agent est disponible. Installez-la depuis Paramètres > Liaison du serveur.'); return; }
169
+ if (message.type === 'usage-ack') { usage.acknowledge(message.hour, message.revision); return; }
170
+ if (message.type === 'update') {
171
+ console.log(`[${new Date().toISOString()}] Mise à jour de l’agent demandée.`);
172
+ try {
173
+ await installUpdate(message.release, { home, currentVersion: AGENT_VERSION, source: path.dirname(fileURLToPath(import.meta.url)), report: state => { send({ type: 'update-status', state }); console.log(({ downloading: 'Téléchargement du paquet signé.', installing: 'Signature vérifiée. Installation de la mise à jour.', restarting: 'Installation vérifiée. Redémarrage de l’agent.' })[state]); } });
174
+ updating = true; stopped = true; shutdownBot = true; socket.close(1000, 'Mise à jour');
175
+ } catch { send({ type: 'update-status', state: 'failed' }); console.error('Mise à jour refusée ou échouée. La version actuelle est conservée.'); }
176
+ } else if (message.type === 'secret') {
177
+ try {
178
+ let profile;
179
+ if (!await exists(secretPath) || message.replace === true) {
180
+ const wasRunning = child && child.exitCode === null && !child.signalCode;
181
+ const previous = await exists(secretPath) ? await credentials(config) : null;
182
+ if (previous) previous.token = '';
183
+ profile = await acceptSecret(message.envelope, { agentId: config.agentId, previousApplicationId: previous?.applicationId, decrypt: async envelope => decrypt(envelope, await readFile(keyPath, 'utf8')), save: envelope => save(secretPath, envelope) });
184
+ send({ type: 'secret-ready', profile }); console.log('Configuration Discord validée et conservée localement.');
185
+ if (wasRunning) { try { await restartBot(config); send({ type: 'runtime-status', state: 'running' }); } catch { send({ type: 'runtime-status', state: 'failed' }); } }
186
+ } else send({ type: 'secret-ready' });
187
+ } catch { send({ type: 'secret-error' }); }
188
+ } else if (message.type === 'runtime') {
189
+ try {
190
+ console.log(message.command === 'stop' ? 'Arrêt du bot demandé.' : 'Démarrage ou redémarrage du bot demandé.');
191
+ if (message.command === 'stop') { await save(path.join(home, 'paused.json'), {}); await stopBot(); await usage.flush(send).catch(() => {}); send({ type: 'runtime-status', state: 'stopped' }); }
192
+ else if (message.command === 'restart') { await restartBot(config); if (await exists(path.join(home, 'paused.json'))) await unlink(path.join(home, 'paused.json')); send({ type: 'runtime-status', state: 'running' }); }
193
+ } catch { send({ type: 'runtime-status', state: 'failed' }); }
194
+ } else if (message.type === 'deploy') {
195
+ console.log(`[${new Date().toISOString()}] Nouveau déploiement reçu.`);
196
+ if (await exists(path.join(home, 'paused.json'))) await unlink(path.join(home, 'paused.json'));
197
+ try { await deploy(config, message, state => { send({ type: 'deployment-status', id: message.id, state }); console.log(({ received: 'Déploiement validé.', installing: 'Préparation du bot.', running: 'Déploiement terminé. Bot démarré.', failed: 'Échec du déploiement.' })[state] || 'État du déploiement modifié.'); }); }
198
+ catch { send({ type: 'deployment-status', id: message.id, state: 'failed' }); console.error('Déploiement refusé ou échoué.'); }
199
+ }
200
+ }).catch(() => console.error('Message refusé.'));
201
+ });
202
+ socket.on('error', () => {});
203
+ socket.on('close', code => { clearInterval(heartbeat); clearInterval(usageTimer); usage.flush().catch(() => {}); if (code === 4003) { stopped = true; console.error('Accès révoqué. Le bot local continue à fonctionner.'); } resolve(); });
204
+ socket.on('unexpected-response', (_, response) => { if (response.statusCode === 401) stopped = true; response.resume(); socket.terminate(); });
205
+ });
206
+ if (!stopped) { const until = Date.now() + delay + Math.random() * 1000; while (!stopped && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 200)); }
207
+ delay = Math.min(delay * 2, 60000);
208
+ }
209
+ await pending;
210
+ if (shutdownBot) await stopBot();
211
+ await usage.flush().catch(() => {});
212
+ process.off('SIGTERM', shutdown); process.off('SIGINT', shutdown);
213
+ process.off('disconnect', shutdown); process.off('message', localControl);
214
+ await lock.close();
215
+ await unlink(lockPath);
216
+ if (updating) process.exitCode = 75;
217
+ }
218
+ function printHelp() {
219
+ console.log(`oft-agt ${AGENT_VERSION} — Node.js 22+
220
+ login | connect Se connecter avec Discord et choisir le bot dans le navigateur
221
+ start | run Démarrer en arrière-plan, sans ressaisir le token
222
+ stop Arrêter l’agent et son bot, conserver la configuration
223
+ restart Redémarrer en arrière-plan
224
+ status Afficher l’état local
225
+ logs Lire les 100 dernières lignes du journal
226
+ token [--stdin] Régénérer l’authentification agent ; --stdin lit 64 caractères hexadécimaux
227
+ remove --yes Révoquer la liaison et supprimer les données locales de cette instance
228
+ list Afficher les instances installées
229
+ help Afficher cette aide
230
+ Options : --bot=IDENTIFIANT_COURT (voir list) pour choisir un bot ; start --foreground pour diagnostiquer.
231
+ Après un redémarrage du serveur : start. La configuration est conservée sur disque.
232
+ Documentation : https://doc-builder.oftone.fr/agent`);
233
+ }
234
+ async function main() {
235
+ const [requestedCommand, ...args] = process.argv.slice(2);
236
+ const command = requestedCommand || 'login';
237
+ const source = path.dirname(fileURLToPath(import.meta.url));
238
+ if (['help', '--help', '-h'].includes(command)) { printHelp(); return; }
239
+ if (command === 'list') { for (const instance of await listInstances(baseHome)) console.log(`${instance.agentId.slice(0, 8)} · ${(await localStatus(instance.home)).running ? 'actif' : 'arrêté'}`); return; }
240
+ if (command === 'daemon') { useHome(baseHome); return managedDaemon(home, source, args.find(arg => arg.startsWith('--run-id='))?.slice(9), supervise); }
241
+ if (command === 'check') { if (args.includes('--version=' + AGENT_VERSION)) return; throw new Error('UPDATE_CHECK'); }
242
+ if (command === 'worker') {
243
+ useHome(process.env.OFTONE_AGENT_HOME || baseHome);
244
+ await ensureLocalLauncher(home, source);
245
+ // A pre-0.4 supervisor can launch the updated worker after a dashboard update.
246
+ // Hand over before taking the worker lock, then let that foreground supervisor exit.
247
+ if (!process.env.OFTONE_MANAGED_DAEMON && !process.env.OFTONE_FOREGROUND) { await startBackground(home, source); console.log('Agent transféré en arrière-plan. Vous pouvez fermer ce terminal.'); return; }
248
+ return start(JSON.parse(await readFile(configPath, 'utf8')));
249
+ }
250
+ if (command === 'connect' || command === 'login') {
251
+ const values = Object.fromEntries(args.map(arg => { const index = arg.indexOf('='); return [arg.slice(2, index), arg.slice(index + 1)]; }));
252
+ let config, privateKey, pendingFile;
253
+ if (!values.token) { const authorization = await authorizeDevice(baseHome, { dashboard: values.dashboard, noOpen: args.includes('--no-open') }); ({ config, privateKey, pendingFile } = authorization); } else {
254
+ if (!values.relay && !values.object && /^[a-f0-9]{64}\.[a-f0-9]{64}$/.test(values.token || '')) {
255
+ [values.object, values.token] = values.token.split('.');
256
+ values.relay = 'https://oftone-agent-relay.hugocoutant2008.workers.dev';
257
+ }
258
+ let relay; try { relay = new URL(values.relay); } catch { throw new Error('PAIRING_INVALID'); }
259
+ if (relay.protocol !== 'https:' || relay.username || relay.password || relay.search || relay.pathname !== '/' || !/^[a-f0-9]{64}$/.test(values.object || '') || !/^[a-f0-9]{64}$/.test(values.token || '')) throw new Error('PAIRING_INVALID');
260
+ const keys = generateKeyPairSync('rsa', { modulusLength: 4096 });
261
+ const response = await fetch(`${relay.origin}/v1/agents/${values.object}/claim`, { method: 'POST', headers: { Authorization: `Bearer ${values.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ publicKey: keys.publicKey.export({ format: 'jwk' }) }), signal: AbortSignal.timeout(20000) });
262
+ if (!response.ok) throw new Error('PAIRING_REJECTED');
263
+ const result = await response.json();
264
+ if (!/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('PAIRING_RESPONSE_INVALID');
265
+ config = { relay: relay.origin, object: values.object, agentId: result.agentId, credential: result.token };
266
+ privateKey = keys.privateKey.export({ type: 'pkcs8', format: 'pem' });
267
+ }
268
+ useHome(instanceDirectory(baseHome, config.object, config.agentId));
269
+ await mkdir(home, { recursive: true, mode: 0o700 });
270
+ if (await exists(configPath)) { const previous = JSON.parse(await readFile(configPath, 'utf8')); if (previous.credential !== config.credential || previous.agentId !== config.agentId) throw new Error('INSTANCE_EXISTS'); }
271
+ await save(keyPath, privateKey);
272
+ await save(configPath, config);
273
+ const install = path.join(home, 'installation');
274
+ await mkdir(install, { recursive: true, mode: 0o700 });
275
+ const source = path.dirname(fileURLToPath(import.meta.url));
276
+ for (const entry of ['cli.mjs', 'instance.mjs', 'version.mjs', 'release-key.mjs', 'update.mjs', 'supervisor.mjs', 'secret.mjs', 'usage.mjs', 'compiler.mjs', 'compile-plan.mjs', 'package.json', 'npm-shrinkwrap.json', 'runtime', 'templates']) await cp(path.join(source, entry), path.join(install, entry), { recursive: true });
277
+ const modules = path.resolve(path.dirname(require.resolve('discord.js')), '../..');
278
+ await cp(modules, path.join(install, 'node_modules'), { recursive: true, filter: filename => path.resolve(filename) !== path.resolve(source) });
279
+ await rememberInstance(baseHome, config);
280
+ console.log('Agent lié : ' + config.agentId.slice(0, 8) + '.');
281
+ if (pendingFile) await unlink(pendingFile).catch(() => {});
282
+ if (args.includes('--foreground')) { process.env.OFTONE_FOREGROUND = '1'; return supervise(home, source); }
283
+ await startBackground(home, install); console.log('Agent démarré.'); return;
284
+ }
285
+ if (['run', 'start', 'stop', 'restart', 'status', 'logs', 'remove', 'token', 'upgrade'].includes(command)) {
286
+ const selector = args.find(arg => arg.startsWith('--bot='))?.slice(6) || args.find(arg => arg.startsWith('--instance='))?.slice('--instance='.length);
287
+ useHome(await resolveInstance(baseHome, path.dirname(fileURLToPath(import.meta.url)), selector));
288
+ if (selector) await rememberInstance(baseHome, JSON.parse(await readFile(configPath, 'utf8')));
289
+ }
290
+ if (command === 'upgrade') {
291
+ await stopBackground(home);
292
+ const source = path.dirname(fileURLToPath(import.meta.url));
293
+ const install = path.join(home, 'installation');
294
+ await mkdir(install, { recursive: true, mode: 0o700 });
295
+ if (path.resolve(source) !== path.resolve(install)) for (const entry of ['cli.mjs', 'instance.mjs', 'version.mjs', 'release-key.mjs', 'update.mjs', 'supervisor.mjs', 'secret.mjs', 'usage.mjs', 'compiler.mjs', 'compile-plan.mjs', 'package.json', 'npm-shrinkwrap.json', 'runtime', 'templates']) await cp(path.join(source, entry), path.join(install, entry), { recursive: true });
296
+ await save(path.join(home, 'agent-release.json'), { directory: 'installation' });
297
+ console.log('Agent mis à jour.');
298
+ if (args.includes('--foreground')) { process.env.OFTONE_FOREGROUND = '1'; return supervise(home, source); }
299
+ await startBackground(home, source); console.log('Agent démarré en arrière-plan.'); return;
300
+ }
301
+ if (['run', 'start', 'restart'].includes(command)) {
302
+ if (command === 'restart') await stopBackground(home);
303
+ await completeCredentialRotation(home, JSON.parse(await readFile(configPath, 'utf8')));
304
+ if (args.includes('--foreground')) { process.env.OFTONE_FOREGROUND = '1'; return supervise(home, source); }
305
+ await startBackground(home, source); console.log('Agent actif en arrière-plan. Aucun token à ressaisir.'); return;
306
+ }
307
+ if (command === 'stop') { await stopBackground(home); console.log('Agent arrêté. Sa configuration est conservée.'); return; }
308
+ if (command === 'status') { const state = await localStatus(home); console.log(state.legacy ? 'Agent actif au premier plan (ancienne installation).' : state.running ? 'Agent actif en arrière-plan.' : state.starting ? 'Agent en cours de démarrage.' : 'Agent arrêté.'); return; }
309
+ if (command === 'logs') { process.stdout.write(await readLogs(home)); return; }
310
+ if (command === 'token') {
311
+ const state = await localStatus(home); await stopBackground(home);
312
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
313
+ let credential;
314
+ if (args.includes('--stdin')) { const chunks = []; let length = 0; for await (const chunk of process.stdin) { length += chunk.length; if (length > 128) throw new Error('AUTH_ROTATION_INVALID'); chunks.push(chunk); } credential = Buffer.concat(chunks).toString('utf8').trim(); }
315
+ await rotateCredential(home, config, credential);
316
+ if (state.daemon) await startBackground(home, source);
317
+ console.log('Token d’authentification remplacé et enregistré localement. Aucun token Discord modifié.'); return;
318
+ }
319
+ if (command === 'remove') {
320
+ if (!args.includes('--yes')) { console.log('Supprime l’agent sélectionné et ses données locales, y compris son token Discord. Relancez avec remove --yes pour confirmer.'); return; }
321
+ await stopBackground(home); const config = JSON.parse(await readFile(configPath, 'utf8'));
322
+ let revoked = false;
323
+ try { const response = await fetch(`${config.relay}/v1/agents/${config.object}/remove`, { method: 'POST', headers: { Authorization: `Bearer ${config.credential}` }, signal: AbortSignal.timeout(15000) }); revoked = response.ok; } catch {}
324
+ await removeLocalInstance(home, baseHome, config);
325
+ console.log('Agent sélectionné supprimé localement.' + (revoked ? ' Liaison révoquée.' : ' Dissociez aussi le serveur dans le dashboard si nécessaire.')); return;
326
+ }
327
+ throw new Error('COMMAND_UNKNOWN');
328
+ }
329
+ main().catch(error => { const messages = { LOGIN_BUSY: 'Une connexion Discord est déjà en cours sur cette machine.', LOGIN_EXPIRED: 'La demande a expiré. Relancez npx oft-agt login.', LOGIN_START_FAILED: 'Impossible de démarrer la connexion. Réessayez dans quelques minutes.', LOGIN_FAILED: 'Connexion refusée. Relancez npx oft-agt login.', AUTH_ROTATION_BUSY: 'Une modification de l’authentification est déjà en cours pour cette instance. Réessayez après sa fin.', COMMAND_UNKNOWN: 'Commande inconnue. Consultez oftone-agent help.', LEGACY_FOREGROUND: 'Un ancien agent fonctionne au premier plan. Arrêtez-le avec Ctrl+C une seule fois, puis utilisez start.', DAEMON_START_FAILED: 'L’agent n’a pas démarré. Consultez logs.', DAEMON_START_TIMEOUT: 'Démarrage non confirmé. Consultez status et logs avant de réessayer.', DAEMON_STOP_TIMEOUT: 'L’arrêt n’est pas encore confirmé. Consultez status et logs.', AUTH_ROTATION_FAILED: 'Changement d’authentification non confirmé. Réessayez token ou start pour reprendre sans perdre la liaison.', AUTH_ROTATION_INVALID: 'Token d’authentification invalide : 64 caractères hexadécimaux attendus.', PAIRING_INVALID: 'Commande de liaison invalide. Copiez à nouveau la commande complète depuis le dashboard.', PAIRING_REJECTED: 'Commande de liaison expirée, déjà utilisée ou révoquée. Générez-en une nouvelle depuis le bot concerné.', INSTANCE_INVALID: 'Identifiant d’instance invalide. Copiez la commande depuis les paramètres du bot concerné.', INSTANCE_NOT_FOUND: 'Cette liaison n’est pas installée sur cette machine. Utilisez la commande locale de cette instance ou configurez une nouvelle liaison.', INSTANCE_REQUIRED: 'Précisez l’instance avec la commande affichée dans les paramètres du bot ou la commande locale affichée à l’installation.', INSTANCE_EXISTS: 'Cette instance existe déjà. Utilisez sa commande locale de démarrage.', AGENT_ACTIVE: 'Cet agent est déjà actif. Arrêtez uniquement le processus de cette instance avant de le relancer.' }; console.error(messages[error.code || error.message] || 'Échec de l’agent. Vérifiez la liaison et la configuration locale. Aucun secret affiché.'); process.exitCode = 1; });
330
+
@@ -0,0 +1,133 @@
1
+ import { isCondition, validateCondition } from './runtime/conditions.mjs';
2
+ import { isOption, compileOptions } from './runtime/command-options.mjs';
3
+ import { normalizeMessageConfig, messageConfigError } from './runtime/message-config.mjs';
4
+
5
+ const ID = /^\d{17,20}$/;
6
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value);
7
+ function fail(message) { throw new Error(message); }
8
+ function text(value, name, max, required = false) {
9
+ if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail(`${name} : texte ${required ? 'non vide ' : ''}de ${max} caractères maximum attendu.`);
10
+ return value;
11
+ }
12
+
13
+ /** Pure JSON -> validated function call plan. No filesystem, network or code evaluation. */
14
+ export function compile(input) {
15
+ if (!plain(input) || !Array.isArray(input.commands) || input.commands.length > 100) fail('commands doit être un tableau de 100 commandes maximum.');
16
+ const names = new Set();
17
+ const commands = input.commands.map((entry, commandIndex) => {
18
+ const command = Array.isArray(entry?.blocks) ? entry : entry?.config;
19
+ if (!plain(command)) fail(`Commande ${commandIndex + 1} : configuration absente.`);
20
+ const name = text(command.name ?? entry.name, 'Nom de commande', 32, true);
21
+ if (!/^[a-z0-9_-]{1,32}$/.test(name)) fail(`/${name} : utilisez des minuscules, chiffres, tirets ou underscores.`);
22
+ if (names.has(name)) fail(`Commande dupliquée : /${name}.`);
23
+ names.add(name);
24
+ const description = text(command.description ?? entry.description, `/${name} description`, 100, true);
25
+ if (!Array.isArray(command.blocks) || !command.blocks.length || command.blocks.length > 500 || !Array.isArray(command.edges)) fail(`/${name} : blocs ou liaisons invalides (500 blocs maximum).`);
26
+ const blocks = new Map();
27
+ for (const block of command.blocks) {
28
+ if (!plain(block) || typeof block.id !== 'string' || !block.id || block.id.length > 128 || blocks.has(block.id)) fail(`/${name} : identifiant de bloc absent, invalide ou dupliqué.`);
29
+ if (!isOption(block.type) && !isCondition(block.type) && !['root', 'errorRoot', 'conditionBranch', 'sendMessage', 'wait', 'deleteMessage'].includes(block.type)) fail(`/${name}/${block.id} : type de bloc inconnu : ${block.type}.`);
30
+ if (!plain(block.config || {})) fail(`/${name}/${block.id} : paramètres invalides.`);
31
+ blocks.set(block.id, block);
32
+ }
33
+ const roots = [...blocks.values()].filter(block => block.type === 'root');
34
+ if (roots.length !== 1) fail(`/${name} doit contenir exactement un bloc racine.`);
35
+ const errorRoots = [...blocks.values()].filter(block => block.type === 'errorRoot');
36
+ if (errorRoots.length > 1) fail(`/${name} : une seule racine d’erreur est autorisée.`);
37
+ const options = compileOptions(command.blocks);
38
+ const optionLinks = new Set();
39
+ const conditionConfigs = new Map([...blocks.values()].filter(block => isCondition(block.type)).map(block => [block.id, validateCondition(block.type, block.config)]));
40
+ const branchLinks = new Map();
41
+ const next = new Map();
42
+ const incoming = new Set();
43
+ for (const edge of command.edges) {
44
+ if (plain(edge) && isOption(blocks.get(edge.source)?.type)) {
45
+ if (edge.target !== roots[0].id || optionLinks.has(edge.source)) fail(`/${name} : chaque option doit être liée uniquement à la racine principale.`);
46
+ optionLinks.add(edge.source); continue;
47
+ }
48
+ if (plain(edge) && isOption(blocks.get(edge.target)?.type)) fail(`/${name} : une action ne peut pas être liée à une option.`);
49
+ if (!plain(edge) || !blocks.has(edge.source) || !blocks.has(edge.target) || ['root', 'errorRoot'].includes(blocks.get(edge.target)?.type)) fail(`/${name} : liaison invalide.`);
50
+ if (isCondition(blocks.get(edge.source).type)) {
51
+ const branch = blocks.get(edge.target);
52
+ const ruleId = branch.config?.ruleId;
53
+ const allowed = ['else', ...conditionConfigs.get(edge.source).rules.map(rule => rule.id)];
54
+ const linked = branchLinks.get(edge.source) || new Map();
55
+ if (branch.type !== 'conditionBranch' || branch.config.parentId !== edge.source || !allowed.includes(ruleId) || linked.has(ruleId) || incoming.has(edge.target)) fail(`/${name} : branche de condition invalide.`);
56
+ linked.set(ruleId, edge.target); branchLinks.set(edge.source, linked); incoming.add(edge.target); continue;
57
+ }
58
+ if (blocks.get(edge.target).type === 'conditionBranch') fail(`/${name} : les branches restent liées à leur condition.`);
59
+ if (next.has(edge.source)) fail(`/${name}/${edge.source} : plusieurs sorties ne sont pas encore prises en charge. Reliez les actions en séquence.`);
60
+ if (incoming.has(edge.target)) fail(`/${name}/${edge.target} : plusieurs entrées ne sont pas prises en charge.`);
61
+ next.set(edge.source, edge.target);
62
+ incoming.add(edge.target);
63
+ }
64
+ if (optionLinks.size !== options.length) fail(`/${name} : toutes les options doivent être liées à la racine.`);
65
+ for (const [id, config] of conditionConfigs) if (branchLinks.get(id)?.size !== config.rules.length + 1) fail(`/${name} : chaque condition nécessite ses branches et une branche Sinon.`);
66
+ const visited = new Set(options.map(option => option.id));
67
+ function compilePath(rootId, inheritedMessages = new Set(), inheritedWait = 0, depth = 0) {
68
+ if (depth > 32) fail(`/${name} : conditions imbriquées trop profondément (32 maximum).`);
69
+ const emittedMessages = new Set(inheritedMessages);
70
+ const calls = [];
71
+ let current = rootId;
72
+ let waitBudget = inheritedWait;
73
+ while (current) {
74
+ if (visited.has(current)) fail(`/${name} : boucle détectée.`);
75
+ visited.add(current);
76
+ const block = blocks.get(current);
77
+ const config = block.config || {};
78
+ let args;
79
+ let fn;
80
+ if (isCondition(block.type)) {
81
+ const branches = branchLinks.get(current);
82
+ const rules = conditionConfigs.get(current).rules;
83
+ calls.push({ id: current, fn: 'condition', args: { kind: block.type, rules }, branches: rules.map(rule => ({ id: branches.get(rule.id), ruleId: rule.id, calls: compilePath(branches.get(rule.id), emittedMessages, waitBudget, depth + 1) })), otherwise: { id: branches.get('else'), calls: compilePath(branches.get('else'), emittedMessages, waitBudget, depth + 1) } });
84
+ } else if (block.type === 'sendMessage') {
85
+ text(config.text ?? '', `/${name}/${current} message`, 2000);
86
+ if (config.embeds !== undefined && (!Array.isArray(config.embeds) || config.embeds.some(embed => !plain(embed)))) fail(`/${name}/${current} : embeds invalides.`);
87
+ const normal = normalizeMessageConfig(config);
88
+ if (!['reply', 'channel', 'dm'].includes(normal.sendMode)) fail(`/${name}/${current} : type d’envoi inconnu.`);
89
+ if (config.sendMode && config.replyMode && !['command', 'block', 'message'].includes(config.replyMode)) fail(`/${name}/${current} : mode de réponse inconnu.`);
90
+ if (config.channelMode && !['current', 'id'].includes(config.channelMode)) fail(`/${name}/${current} : mode de salon inconnu.`);
91
+ if (config.dmMode && !['author', 'id'].includes(config.dmMode)) fail(`/${name}/${current} : destinataire MP inconnu.`);
92
+ const error = messageConfigError(normal, command.blocks);
93
+ if (error) fail(`/${name}/${current} : ${error}`);
94
+ if (normal.sendMode === 'reply' && normal.replyMode === 'block' && !emittedMessages.has(normal.targetBlockId)) fail(`/${name}/${current} : le message cible doit être envoyé avant la réponse.`);
95
+ // Only accepted arguments are emitted; tokens and unrelated JSON properties are discarded.
96
+ args = Object.fromEntries(['text', 'embeds', 'sendMode', 'replyMode', 'targetBlockId', 'messageId', 'channelMode', 'channelId', 'dmMode', 'userId', 'ephemeral'].map(key => [key, normal[key]]));
97
+ args.embeds = normal.embeds.map(embed => ({
98
+ ...Object.fromEntries(['title', 'description', 'url', 'color', 'timestamp'].filter(key => embed[key] !== undefined).map(key => [key, embed[key]])),
99
+ ...(embed.author ? { author: Object.fromEntries(['name', 'url', 'icon_url'].filter(key => embed.author[key] !== undefined).map(key => [key, embed.author[key]])) } : {}),
100
+ ...(embed.footer ? { footer: Object.fromEntries(['text', 'icon_url'].filter(key => embed.footer[key] !== undefined).map(key => [key, embed.footer[key]])) } : {}),
101
+ ...(embed.image ? { image: { url: embed.image.url } } : {}),
102
+ ...(embed.thumbnail ? { thumbnail: { url: embed.thumbnail.url } } : {}),
103
+ ...(embed.fields ? { fields: embed.fields.map(field => ({ name: field.name, value: field.value, inline: Boolean(field.inline) })) } : {}),
104
+ }));
105
+ fn = 'sendMessage';
106
+ emittedMessages.add(current);
107
+ } else if (block.type === 'wait') {
108
+ const seconds = Number(config.seconds);
109
+ if (!Number.isFinite(seconds) || seconds < 0 || seconds > 300) fail(`/${name}/${current} : délai attendu entre 0 et 300 secondes.`);
110
+ waitBudget += seconds;
111
+ if (waitBudget > 840) fail(`/${name} : les délais cumulés dépassent 14 minutes.`);
112
+ fn = 'wait'; args = { seconds };
113
+ } else if (block.type === 'deleteMessage') {
114
+ const deleteMode = config.deleteMode || 'block';
115
+ if (!['block', 'manual'].includes(deleteMode)) fail(`/${name}/${current} : mode de suppression invalide.`);
116
+ if (deleteMode === 'block' && !emittedMessages.has(config.targetBlockId)) fail(`/${name}/${current} : le message à supprimer doit déjà avoir été envoyé.`);
117
+ if (deleteMode === 'manual' && !ID.test(config.messageId || '') && !/^\{[a-z0-9_-]+_option(?:_id)?\}$/.test(config.messageId || '')) fail(`/${name}/${current} : ID de message invalide.`);
118
+ fn = 'deleteMessage'; args = deleteMode === 'block' ? { deleteMode, targetBlockId: config.targetBlockId } : { deleteMode, messageId: config.messageId };
119
+ }
120
+ if (fn) calls.push({ id: current, fn, args });
121
+ current = next.get(current);
122
+ }
123
+ return calls;
124
+ }
125
+ const calls = compilePath(roots[0].id);
126
+ const errorCalls = errorRoots.length ? compilePath(errorRoots[0].id) : undefined;
127
+ if (visited.size !== blocks.size) fail(`/${name} : blocs non reliés à une racine ou boucle dans une partie non accessible.`);
128
+ if (!calls.length || (errorCalls && !errorCalls.length)) fail(`/${name} : ajoutez au moins une action à chaque racine.`);
129
+ return { name, description, ...(options.length ? { options } : {}), calls, ...(errorCalls ? { errorCalls } : {}) };
130
+ });
131
+ return JSON.parse(JSON.stringify({ schemaVersion: 1, bot: { name: typeof input.bot?.name === 'string' ? input.bot.name.slice(0, 100) : 'Oftone Bot' }, commands }));
132
+ }
133
+
package/compiler.mjs ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile, mkdir, cp, access } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { compile } from './compile-plan.mjs';
6
+ export { compile } from './compile-plan.mjs';
7
+ function fail(message) { throw new Error(message); }
8
+
9
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
10
+ /** Emit a standalone Node.js project. Refuse to replace an existing directory. */
11
+ export async function writeBot(input, outputDirectory) {
12
+ const plan = compile(input);
13
+ const output = path.resolve(outputDirectory);
14
+ try { await access(output); fail(`Le dossier de sortie existe déjà : ${output}`); }
15
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
16
+ await mkdir(output, { recursive: false });
17
+ await cp(path.join(ROOT, 'runtime'), path.join(output, 'runtime'), { recursive: true });
18
+ await cp(path.join(ROOT, 'templates/bot.mjs'), path.join(output, 'bot.mjs'));
19
+ await writeFile(path.join(output, 'plan.json'), JSON.stringify(plan, null, 2) + '\n');
20
+ await writeFile(path.join(output, 'package.json'), JSON.stringify({ name: 'oftone-generated-bot', version: '1.0.0', private: true, type: 'module', engines: { node: '>=22' }, scripts: { start: 'node --env-file=.env bot.mjs', deploy: 'node --env-file=.env bot.mjs --deploy' }, dependencies: { 'discord.js': '^14.14.0' } }, null, 2) + '\n');
21
+ await writeFile(path.join(output, '.env.example'), '# Token saisi uniquement sur votre serveur.\nDISCORD_TOKEN=\n# Application ID Discord (pas l’identifiant du projet Builder).\nDISCORD_CLIENT_ID=\n# Optionnel : serveur de test. Vide = commandes globales.\nDISCORD_GUILD_ID=\n');
22
+ await writeFile(path.join(output, '.gitignore'), '.env\nnode_modules/\n');
23
+ return { output, plan };
24
+ }
25
+
26
+ async function main() {
27
+ const args = process.argv.slice(2);
28
+ if (!args.length || args.includes('--help')) {
29
+ console.log('Oftone Compiler\n node compiler.mjs <builder.json> --out <nouveau-dossier>\n node compiler.mjs --stdin --out <nouveau-dossier>\n node compiler.mjs <builder.json> --check\n\nAucun token requis. --stdin permet une invocation par un futur service.');
30
+ return;
31
+ }
32
+ const allowed = new Set(['--stdin', '--check', '--out']);
33
+ for (const arg of args) if (arg.startsWith('--') && !allowed.has(arg)) fail(`Option inconnue : ${arg}`);
34
+ let source;
35
+ if (args[0] === '--stdin') {
36
+ const chunks = [];
37
+ let size = 0;
38
+ for await (const chunk of process.stdin) {
39
+ size += chunk.length;
40
+ if (size > 5 * 1024 * 1024) fail('Le JSON dépasse 5 Mo.');
41
+ chunks.push(chunk);
42
+ }
43
+ source = Buffer.concat(chunks).toString('utf8');
44
+ } else source = await readFile(args[0], 'utf8');
45
+ if (Buffer.byteLength(source) > 5 * 1024 * 1024) fail('Le JSON dépasse 5 Mo.');
46
+ const input = JSON.parse(source);
47
+ if (args.includes('--check')) { const plan = compile(input); console.log(`JSON valide : ${plan.commands.length} commande(s).`); return; }
48
+ const outIndex = args.indexOf('--out');
49
+ if (outIndex < 0 || !args[outIndex + 1] || args[outIndex + 1].startsWith('--')) fail('Précisez --out <nouveau-dossier>.');
50
+ const { output, plan } = await writeBot(input, args[outIndex + 1]);
51
+ console.log(`Bot généré : ${output}\n${plan.commands.length} commande(s). Renseignez .env, puis npm install et npm run deploy avant npm start.`);
52
+ }
53
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
54
+ main().catch(error => { console.error(`Compilation refusée : ${error.message}`); process.exitCode = 1; });
55
+ }
package/instance.mjs ADDED
@@ -0,0 +1,61 @@
1
+ import path from 'node:path';
2
+ import { readFile, writeFile, rename, access, readdir } from 'node:fs/promises';
3
+
4
+ export async function rememberInstance(base, config) {
5
+ instanceDirectory(base, config.object, config.agentId);
6
+ const file = path.join(base, 'active-instance.json');
7
+ await writeFile(file + '.tmp', JSON.stringify({ object: config.object, agentId: config.agentId }), { mode: 0o600 });
8
+ await rename(file + '.tmp', file);
9
+ }
10
+
11
+ export async function listInstances(base) {
12
+ const result = [];
13
+ try { const config = JSON.parse(await readFile(path.join(base, 'agent.json'), 'utf8')); if (OBJECT.test(config.object) && AGENT.test(config.agentId)) result.push({ home: base, object: config.object, agentId: config.agentId }); } catch {}
14
+ let objects = []; try { objects = await readdir(path.join(base, 'instances'), { withFileTypes: true }); } catch {}
15
+ for (const object of objects.filter(entry => entry.isDirectory() && OBJECT.test(entry.name))) {
16
+ for (const agent of await readdir(path.join(base, 'instances', object.name), { withFileTypes: true })) {
17
+ if (!agent.isDirectory() || !AGENT.test(agent.name)) continue;
18
+ const home = instanceDirectory(base, object.name, agent.name);
19
+ try { const config = JSON.parse(await readFile(path.join(home, 'agent.json'), 'utf8')); if (config.object === object.name && config.agentId === agent.name) result.push({ home, object: object.name, agentId: agent.name }); } catch {}
20
+ }
21
+ }
22
+ return result;
23
+ }
24
+
25
+ const OBJECT = /^[a-f0-9]{64}$/;
26
+ const AGENT = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
27
+ export function instanceDirectory(base, object, agentId) {
28
+ if (!OBJECT.test(object || '') || !AGENT.test(agentId || '')) throw new Error('INSTANCE_INVALID');
29
+ return path.join(base, 'instances', object, agentId);
30
+ }
31
+ export async function resolveInstance(base, source, selector) {
32
+ if (selector && /^[a-f0-9]{8}$/.test(selector)) { const matches = (await listInstances(base)).filter(item => item.agentId.startsWith(selector)); if (matches.length !== 1) throw new Error('INSTANCE_NOT_FOUND'); return matches[0].home; }
33
+ if (selector) {
34
+ const [object, agentId, extra] = selector.split('.');
35
+ if (extra !== undefined) throw new Error('INSTANCE_INVALID');
36
+ const directory = instanceDirectory(base, object, agentId);
37
+ try { await access(path.join(directory, 'agent.json')); return directory; } catch {}
38
+ // Existing installations stay usable when upgraded with the scoped dashboard command.
39
+ try {
40
+ const legacy = JSON.parse(await readFile(path.join(base, 'agent.json'), 'utf8'));
41
+ if (legacy.object === object && legacy.agentId === agentId) return base;
42
+ } catch {}
43
+ throw new Error('INSTANCE_NOT_FOUND');
44
+ }
45
+ if (path.basename(source) === 'installation') {
46
+ const installedHome = path.dirname(source);
47
+ try { await access(path.join(installedHome, 'agent.json')); return installedHome; } catch {}
48
+ }
49
+ if (path.basename(path.dirname(source)) === 'versions') {
50
+ const installedHome = path.dirname(path.dirname(source));
51
+ try { await access(path.join(installedHome, 'agent.json')); return installedHome; } catch {}
52
+ }
53
+ const instances = await listInstances(base);
54
+ if (instances.length === 1) return instances[0].home;
55
+ try {
56
+ const active = JSON.parse(await readFile(path.join(base, 'active-instance.json'), 'utf8'));
57
+ const match = instances.find(item => item.object === active.object && item.agentId === active.agentId);
58
+ if (match) return match.home;
59
+ } catch {}
60
+ throw new Error('INSTANCE_REQUIRED');
61
+ }