@revoengine/cli 1.0.9 → 1.0.11

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.
Files changed (81) hide show
  1. package/README.md +464 -8
  2. package/dist/src/cli.js +65 -6
  3. package/dist/src/client.d.ts +366 -5
  4. package/dist/src/client.js +954 -13
  5. package/dist/src/commands/auth.js +4 -2
  6. package/dist/src/commands/component.js +839 -412
  7. package/dist/src/commands/database-schemas.d.ts +2 -0
  8. package/dist/src/commands/database-schemas.js +188 -0
  9. package/dist/src/commands/database-views.d.ts +2 -0
  10. package/dist/src/commands/database-views.js +123 -0
  11. package/dist/src/commands/endpoints.js +114 -0
  12. package/dist/src/commands/env.d.ts +2 -0
  13. package/dist/src/commands/env.js +380 -0
  14. package/dist/src/commands/events.d.ts +2 -0
  15. package/dist/src/commands/events.js +146 -0
  16. package/dist/src/commands/groups.d.ts +2 -0
  17. package/dist/src/commands/groups.js +169 -0
  18. package/dist/src/commands/index.d.ts +10 -0
  19. package/dist/src/commands/index.js +10 -0
  20. package/dist/src/commands/job-templates.d.ts +2 -0
  21. package/dist/src/commands/job-templates.js +101 -0
  22. package/dist/src/commands/metadata.d.ts +2 -0
  23. package/dist/src/commands/metadata.js +159 -0
  24. package/dist/src/commands/project.js +82 -1
  25. package/dist/src/commands/role-groups.d.ts +2 -0
  26. package/dist/src/commands/role-groups.js +152 -0
  27. package/dist/src/commands/schedules.d.ts +2 -0
  28. package/dist/src/commands/schedules.js +141 -0
  29. package/dist/src/commands/terminal-service.d.ts +38 -0
  30. package/dist/src/commands/terminal-service.js +210 -0
  31. package/dist/src/commands/terminal.d.ts +22 -0
  32. package/dist/src/commands/terminal.js +511 -0
  33. package/dist/src/component-lock.d.ts +126 -2
  34. package/dist/src/component-lock.js +378 -15
  35. package/dist/src/config.d.ts +20 -0
  36. package/dist/src/config.js +121 -6
  37. package/dist/src/database-schema-artifacts.d.ts +7 -0
  38. package/dist/src/database-schema-artifacts.js +8 -0
  39. package/dist/src/env-sync.d.ts +83 -0
  40. package/dist/src/env-sync.js +315 -0
  41. package/dist/src/metadata-backfill.d.ts +56 -0
  42. package/dist/src/metadata-backfill.js +1176 -0
  43. package/dist/src/project.d.ts +17 -9
  44. package/dist/src/project.js +87 -11
  45. package/dist/src/prompt.js +10 -18
  46. package/dist/src/resource-metadata.d.ts +25 -0
  47. package/dist/src/resource-metadata.js +132 -0
  48. package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
  49. package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
  50. package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
  51. package/dist/src/resource-syncs/database-view-sync.js +1317 -0
  52. package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
  53. package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
  54. package/dist/src/resource-syncs/event-sync.d.ts +99 -0
  55. package/dist/src/resource-syncs/event-sync.js +949 -0
  56. package/dist/src/resource-syncs/group-sync.d.ts +86 -0
  57. package/dist/src/resource-syncs/group-sync.js +882 -0
  58. package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
  59. package/dist/src/resource-syncs/job-template-sync.js +782 -0
  60. package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
  61. package/dist/src/resource-syncs/role-group-sync.js +597 -0
  62. package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
  63. package/dist/src/resource-syncs/schedule-sync.js +1302 -0
  64. package/dist/src/resource-syncs/util.d.ts +19 -0
  65. package/dist/src/resource-syncs/util.js +116 -0
  66. package/dist/src/runtime-view.d.ts +1 -0
  67. package/dist/src/runtime-view.js +6 -1
  68. package/dist/src/sync-output.d.ts +38 -0
  69. package/dist/src/sync-output.js +131 -0
  70. package/dist/src/tracked-resources.d.ts +7 -0
  71. package/dist/src/tracked-resources.js +61 -0
  72. package/dist/src/types.d.ts +227 -0
  73. package/dist/src/ui.d.ts +3 -0
  74. package/dist/src/ui.js +68 -10
  75. package/dist/src/utils.d.ts +2 -0
  76. package/dist/src/utils.js +64 -0
  77. package/dist/src/workspace-component.d.ts +2 -0
  78. package/dist/src/workspace-component.js +34 -0
  79. package/dist/src/workspace-resource.d.ts +2 -0
  80. package/dist/src/workspace-resource.js +52 -0
  81. package/package.json +8 -3
@@ -0,0 +1,511 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import WebSocket from 'ws';
7
+ import { getActiveEnvironmentName, getConfigDir, getEnvironmentProfile, loadStoredConfig, resolveRuntimeConfig } from "../config.js";
8
+ import { promptConfirm } from "../prompt.js";
9
+ import { installTerminalService, terminalServiceInstalled, uninstallTerminalService } from "./terminal-service.js";
10
+ const configDir = getConfigDir;
11
+ const runnerConfig = () => path.join(configDir(), 'runner.json');
12
+ const MAX_MODEL_OUTPUT_BYTES = 100_000;
13
+ const MAX_LOCAL_LOG_BYTES = 10_000_000;
14
+ const OUTPUT_FRAME_CHARS = 1_000;
15
+ const LOG_RETENTION_MS = 24 * 60 * 60 * 1_000;
16
+ const CHILD_ENV_KEYS = ['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM'];
17
+ export function childEnvironment(source) {
18
+ const safe = {};
19
+ for (const key of CHILD_ENV_KEYS)
20
+ if (source[key] !== undefined)
21
+ safe[key] = source[key];
22
+ return safe;
23
+ }
24
+ function logDirectory() { return path.join(configDir(), 'runner-logs'); }
25
+ function pruneLogs(directory) {
26
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
27
+ for (const name of fs.readdirSync(directory)) {
28
+ const file = path.join(directory, name);
29
+ try {
30
+ if (name.endsWith('.log') && Date.now() - fs.statSync(file).mtimeMs > LOG_RETENTION_MS)
31
+ fs.unlinkSync(file);
32
+ }
33
+ catch (error) {
34
+ if (error.code !== 'ENOENT')
35
+ throw error;
36
+ }
37
+ }
38
+ }
39
+ function readJson(file) {
40
+ try {
41
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ return {};
46
+ throw error;
47
+ }
48
+ }
49
+ function readStore() {
50
+ const value = readJson(runnerConfig());
51
+ return { devices: Array.isArray(value.devices) ? value.devices.map((device) => ({
52
+ ...device, agentTerminalId: device.agentTerminalId || device.deviceId, deviceId: undefined,
53
+ })) : [] };
54
+ }
55
+ function saveStore(store) {
56
+ fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
57
+ const file = runnerConfig();
58
+ const temporary = `${file}.${randomUUID()}.tmp`;
59
+ try {
60
+ fs.writeFileSync(temporary, JSON.stringify(store, null, 2), { mode: 0o600 });
61
+ fs.renameSync(temporary, file);
62
+ fs.chmodSync(file, 0o600);
63
+ }
64
+ finally {
65
+ try {
66
+ fs.unlinkSync(temporary);
67
+ }
68
+ catch (error) {
69
+ if (error.code !== 'ENOENT')
70
+ throw error;
71
+ }
72
+ }
73
+ }
74
+ export function resolveWorkspaceDirectory(root, requested) {
75
+ const workspace = fs.realpathSync(root);
76
+ const workdir = fs.realpathSync(requested);
77
+ const relative = path.relative(workspace, workdir);
78
+ if (relative.startsWith('..') || path.isAbsolute(relative))
79
+ throw new Error('Working directory is outside the registered workspace.');
80
+ return workdir;
81
+ }
82
+ function option(argv, name) {
83
+ const index = argv.indexOf(`--${name}`);
84
+ if (index >= 0)
85
+ return argv[index + 1];
86
+ return argv.find(value => value.startsWith(`--${name}=`))?.slice(name.length + 3);
87
+ }
88
+ function resolveProfile(argv) {
89
+ const urlOverride = option(argv, 'url');
90
+ const tokenOverride = option(argv, 'token');
91
+ const configured = resolveRuntimeConfig({ baseUrl: urlOverride,
92
+ token: tokenOverride, instance: option(argv, 'instance'), envName: option(argv, 'env') });
93
+ const token = configured.token;
94
+ if (!token || !token.startsWith('sk-'))
95
+ throw new Error('A personal Revo API key is required. Run revo auth login or select a CLI profile.');
96
+ return { baseUrl: configured.baseUrl.replace(/\/$/, ''), token,
97
+ instance: option(argv, 'instance') || (urlOverride || tokenOverride ? undefined : configured.instance),
98
+ realtimeUrl: option(argv, 'realtime-url'), production: configured.production };
99
+ }
100
+ function apiUrl(profile, route) {
101
+ const base = profile.baseUrl.replace(/\/(?:api\/)?v1\/?$/, '');
102
+ const url = new URL(base);
103
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost'))
104
+ throw new Error('Remote Revo API requires HTTPS.');
105
+ return `${base}/api/v1${route}`;
106
+ }
107
+ async function api(profile, method, route, body) {
108
+ if (profile.production && !profile.productionWriteConfirmed && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
109
+ const confirmed = await promptConfirm(`PRODUCTION: send ${method} ${apiUrl(profile, route)}?`, false);
110
+ if (!confirmed)
111
+ throw new Error('Production write cancelled. Confirm the operation in an interactive terminal.');
112
+ profile.productionWriteConfirmed = true;
113
+ }
114
+ const response = await fetch(apiUrl(profile, route), {
115
+ method,
116
+ redirect: 'manual',
117
+ headers: { Authorization: `Bearer ${profile.token}`,
118
+ ...(profile.instance ? { instance: profile.instance } : {}),
119
+ ...(body ? { 'Content-Type': 'application/json' } : {}) },
120
+ ...(body ? { body: JSON.stringify(body) } : {}),
121
+ });
122
+ if (response.status >= 300 && response.status < 400)
123
+ throw new Error(`Revo API ${method} ${route} redirected; refusing to forward credentials.`);
124
+ const value = await response.json().catch(() => ({}));
125
+ if (!response.ok)
126
+ throw new Error(`Revo API ${method} ${route} returned ${response.status}: ${String(value?.message || 'request failed')}`);
127
+ return (value?.data ?? value);
128
+ }
129
+ function realtimeUrl(value) {
130
+ const url = new URL(value);
131
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol))
132
+ throw new Error('Invalid Realtime URL.');
133
+ url.protocol = url.protocol === 'https:' || url.protocol === 'wss:' ? 'wss:' : 'ws:';
134
+ if (url.protocol === 'ws:' && url.hostname !== 'localhost')
135
+ throw new Error('Remote Realtime requires TLS.');
136
+ url.pathname = '/runner/v1/ws';
137
+ url.search = '';
138
+ return url.toString();
139
+ }
140
+ function send(socket, frame) {
141
+ if (socket.readyState === WebSocket.OPEN)
142
+ socket.send(JSON.stringify(frame));
143
+ }
144
+ export function boundResultFrame(frame) {
145
+ const bounded = { ...frame, stdout: String(frame.stdout || ''), stderr: String(frame.stderr || '') };
146
+ while (Buffer.byteLength(JSON.stringify(bounded)) > 90_000) {
147
+ if (bounded.stdout.length >= bounded.stderr.length)
148
+ bounded.stdout = bounded.stdout.slice(0, Math.floor(bounded.stdout.length * 0.75));
149
+ else
150
+ bounded.stderr = bounded.stderr.slice(0, Math.floor(bounded.stderr.length * 0.75));
151
+ }
152
+ return bounded;
153
+ }
154
+ function sendResult(socket, frame) {
155
+ send(socket, boundResultFrame(frame));
156
+ }
157
+ export async function runCommand(input, onDone, onChunk) {
158
+ const workdir = resolveWorkspaceDirectory(input.workspace, input.workdir);
159
+ const directory = input.logDir || logDirectory();
160
+ const logPath = path.join(directory, `${input.executionId || randomUUID()}.log`);
161
+ let logFd;
162
+ try {
163
+ pruneLogs(directory);
164
+ logFd = fs.openSync(logPath, 'wx', 0o600);
165
+ }
166
+ catch (error) {
167
+ console.error(`Runner log unavailable: ${error.message}`);
168
+ }
169
+ const child = spawn(process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh', ['-lc', input.cmd], {
170
+ cwd: workdir, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: childEnvironment(process.env),
171
+ });
172
+ let stdout = '';
173
+ let stderr = '';
174
+ let modelBytes = 0;
175
+ let logBytes = 0;
176
+ let truncated = false;
177
+ let logTruncated = logFd === undefined;
178
+ let timedOut = false;
179
+ let finished = false;
180
+ const terminate = () => { if (child.pid) {
181
+ try {
182
+ process.kill(-child.pid, 'SIGKILL');
183
+ }
184
+ catch { /* exited */ }
185
+ } };
186
+ const timer = setTimeout(() => { timedOut = true; terminate(); }, Math.min(Math.max(input.timeoutMs, 1_000), 600_000));
187
+ const append = (chunk, kind) => {
188
+ const logPart = chunk.subarray(0, Math.max(0, MAX_LOCAL_LOG_BYTES - logBytes));
189
+ if (logPart.length && logFd !== undefined) {
190
+ const header = Buffer.from(`[${kind}] `);
191
+ if (logBytes + header.length + logPart.length <= MAX_LOCAL_LOG_BYTES) {
192
+ try {
193
+ fs.writeSync(logFd, header);
194
+ fs.writeSync(logFd, logPart);
195
+ logBytes += header.length + logPart.length;
196
+ }
197
+ catch (error) {
198
+ console.error(`Runner log write failed: ${error.message}`);
199
+ logTruncated = true;
200
+ try {
201
+ fs.closeSync(logFd);
202
+ }
203
+ catch { /* log already unavailable */ }
204
+ logFd = undefined;
205
+ }
206
+ }
207
+ else
208
+ logTruncated = true;
209
+ }
210
+ if (logPart.length < chunk.length)
211
+ logTruncated = true;
212
+ const available = Math.max(0, MAX_MODEL_OUTPUT_BYTES - modelBytes);
213
+ const modelPart = chunk.subarray(0, available);
214
+ modelBytes += modelPart.length;
215
+ if (modelPart.length < chunk.length)
216
+ truncated = true;
217
+ const text = modelPart.toString('utf8');
218
+ if (kind === 'stdout')
219
+ stdout += text;
220
+ else
221
+ stderr += text;
222
+ if (onChunk && text) {
223
+ for (let offset = 0; offset < text.length; offset += OUTPUT_FRAME_CHARS)
224
+ onChunk(kind, text.slice(offset, offset + OUTPUT_FRAME_CHARS));
225
+ }
226
+ };
227
+ child.stdout?.on('data', (chunk) => append(chunk, 'stdout'));
228
+ child.stderr?.on('data', (chunk) => append(chunk, 'stderr'));
229
+ const finish = (exitCode, error) => {
230
+ if (finished)
231
+ return;
232
+ finished = true;
233
+ clearTimeout(timer);
234
+ if (logFd !== undefined) {
235
+ try {
236
+ fs.closeSync(logFd);
237
+ }
238
+ catch {
239
+ logTruncated = true;
240
+ }
241
+ }
242
+ onDone({ executed: !error, exitCode, stdout, stderr: error ? `${stderr}\n${error.message}` : stderr,
243
+ timedOut, truncated, logTruncated, logPath: logFd === undefined ? '' : logPath });
244
+ };
245
+ child.on('close', code => finish(code));
246
+ child.on('error', error => finish(null, error));
247
+ return child;
248
+ }
249
+ async function connect(profile, device) {
250
+ let backoffMs = 1_000;
251
+ let stopping = false;
252
+ let activeSocket;
253
+ const activeChildren = new Set();
254
+ const stop = () => {
255
+ stopping = true;
256
+ if (activeSocket?.readyState === WebSocket.CONNECTING)
257
+ activeSocket.terminate();
258
+ else
259
+ activeSocket?.close();
260
+ for (const child of activeChildren)
261
+ if (child.pid) {
262
+ try {
263
+ process.kill(-child.pid, 'SIGKILL');
264
+ }
265
+ catch { /* exited */ }
266
+ }
267
+ };
268
+ process.on('SIGINT', stop);
269
+ process.on('SIGTERM', stop);
270
+ try {
271
+ while (!stopping) {
272
+ const children = new Map();
273
+ const cancelledExecutions = new Set();
274
+ try {
275
+ await new Promise((resolve, reject) => {
276
+ const socket = new WebSocket(device.realtimeUrl, {
277
+ headers: { Authorization: `Bearer ${profile.token}`,
278
+ ...(profile.instance ? { instance: profile.instance } : {}) },
279
+ perMessageDeflate: false,
280
+ });
281
+ activeSocket = socket;
282
+ let connectionId = '';
283
+ let workspace = device.workspace;
284
+ let heartbeat;
285
+ let lastHeartbeat = Date.now();
286
+ let incoming = Promise.resolve();
287
+ socket.on('open', () => send(socket, { type: 'hello', protocol: 1, agentTerminalId: device.agentTerminalId, secret: device.secret }));
288
+ socket.on('message', raw => {
289
+ incoming = incoming.then(async () => {
290
+ if (stopping || socket.readyState !== WebSocket.OPEN)
291
+ return;
292
+ const frame = JSON.parse(raw.toString());
293
+ if (frame.type === 'ready' && frame.connectionId) {
294
+ connectionId = frame.connectionId;
295
+ workspace = frame.workspace || device.workspace;
296
+ backoffMs = 1_000;
297
+ console.log(`Connected ${device.name} (${device.agentTerminalId}).`);
298
+ heartbeat = setInterval(() => {
299
+ if (Date.now() - lastHeartbeat > 45_000) {
300
+ socket.terminate();
301
+ return;
302
+ }
303
+ send(socket, { type: 'heartbeat', connectionId });
304
+ }, 15_000);
305
+ return;
306
+ }
307
+ if (frame.type === 'heartbeat') {
308
+ lastHeartbeat = Date.now();
309
+ return;
310
+ }
311
+ if (frame.connectionId !== connectionId || !frame.executionId)
312
+ return;
313
+ if (frame.type === 'cancel') {
314
+ const child = children.get(frame.executionId);
315
+ if (child?.pid) {
316
+ cancelledExecutions.add(frame.executionId);
317
+ try {
318
+ process.kill(-child.pid, 'SIGKILL');
319
+ }
320
+ catch { /* exited */ }
321
+ }
322
+ return;
323
+ }
324
+ if (frame.type !== 'execute')
325
+ return;
326
+ if (typeof frame.cmd !== 'string' || !frame.cmd.trim() || frame.cmd.length > 8_192 || children.size >= 4) {
327
+ sendResult(socket, { type: 'result', connectionId, executionId: frame.executionId,
328
+ executed: false, exitCode: null, stdout: '', stderr: children.size >= 4 ? 'runner_busy' : 'invalid_command', timedOut: false });
329
+ return;
330
+ }
331
+ try {
332
+ const child = await runCommand({ cmd: frame.cmd, workdir: frame.workdir || workspace,
333
+ workspace, timeoutMs: frame.timeoutMs || 60_000, executionId: frame.executionId }, result => {
334
+ children.delete(frame.executionId);
335
+ activeChildren.delete(child);
336
+ const { stdout, stderr, ...completion } = result;
337
+ send(socket, { type: 'result', connectionId, executionId: frame.executionId,
338
+ ...completion, cancelled: cancelledExecutions.delete(frame.executionId), stdout: '', stderr: '' });
339
+ }, (stream, text) => send(socket, { type: 'output', connectionId,
340
+ executionId: frame.executionId, stream, text }));
341
+ children.set(frame.executionId, child);
342
+ activeChildren.add(child);
343
+ send(socket, { type: 'ack', connectionId, executionId: frame.executionId });
344
+ }
345
+ catch (error) {
346
+ sendResult(socket, { type: 'result', connectionId, executionId: frame.executionId,
347
+ executed: false, exitCode: null, stdout: '', stderr: error instanceof Error ? error.message : 'Command rejected.', timedOut: false });
348
+ }
349
+ }).catch(() => socket.terminate());
350
+ });
351
+ socket.on('close', (code, reason) => {
352
+ if (heartbeat)
353
+ clearInterval(heartbeat);
354
+ for (const child of children.values())
355
+ if (child.pid) {
356
+ try {
357
+ process.kill(-child.pid, 'SIGKILL');
358
+ }
359
+ catch { /* exited */ }
360
+ }
361
+ for (const child of children.values())
362
+ activeChildren.delete(child);
363
+ children.clear();
364
+ cancelledExecutions.clear();
365
+ if (activeSocket === socket)
366
+ activeSocket = undefined;
367
+ if (code === 1008 && reason.toString() !== 'heartbeat_timeout')
368
+ reject(new Error(`Runner rejected by Realtime: ${reason.toString() || 'policy violation'}.`));
369
+ else
370
+ resolve();
371
+ });
372
+ socket.on('error', error => { console.error(`Realtime connection: ${error.message}`); });
373
+ });
374
+ }
375
+ catch (error) {
376
+ if (error instanceof Error && error.message.startsWith('Runner rejected by Realtime:'))
377
+ throw error;
378
+ console.error(error);
379
+ }
380
+ if (stopping)
381
+ break;
382
+ await new Promise(resolve => setTimeout(resolve, backoffMs));
383
+ backoffMs = Math.min(backoffMs * 2, 30_000);
384
+ }
385
+ }
386
+ finally {
387
+ stop();
388
+ process.off('SIGINT', stop);
389
+ process.off('SIGTERM', stop);
390
+ }
391
+ }
392
+ export async function handleTerminalCommand(argv) {
393
+ const command = argv[0] || 'help';
394
+ if (command === 'help' || argv.includes('--help')) {
395
+ console.log('revo terminal connect|status|disconnect [--project NAME] [--workspace PATH] [--name NAME] [--url URL] [--realtime-url URL]\nrevo terminal service install|uninstall --workspace PATH [--project NAME]');
396
+ return;
397
+ }
398
+ if (command === 'service') {
399
+ const action = argv[1];
400
+ if (!action || action === 'help') {
401
+ console.log('revo terminal service install|uninstall --workspace PATH [--project NAME]');
402
+ return;
403
+ }
404
+ if (action !== 'install' && action !== 'uninstall')
405
+ throw new Error('Use revo terminal service install|uninstall.');
406
+ const requestedWorkspace = option(argv, 'workspace');
407
+ if (!requestedWorkspace)
408
+ throw new Error('Terminal service requires --workspace PATH.');
409
+ const workspace = fs.existsSync(requestedWorkspace) ? fs.realpathSync(requestedWorkspace) : path.resolve(requestedWorkspace);
410
+ const envName = option(argv, 'env') || getActiveEnvironmentName();
411
+ if (action === 'uninstall') {
412
+ const result = uninstallTerminalService({ workspace, envName, configHome: process.env.XDG_CONFIG_HOME });
413
+ console.log(result.removed ? `Terminal service stopped and removed (${result.label}). Grant remains active.`
414
+ : `No terminal service installed for this workspace (${result.label}).`);
415
+ return;
416
+ }
417
+ if (!fs.existsSync(workspace))
418
+ throw new Error('Workspace does not exist.');
419
+ for (const unsupported of ['token', 'url', 'instance', 'realtime-url'])
420
+ if (option(argv, unsupported))
421
+ throw new Error(`Service install cannot use --${unsupported}; use a saved CLI login/profile.`);
422
+ const saved = envName === 'default' ? loadStoredConfig({ allowLegacy: false }) : getEnvironmentProfile(envName);
423
+ if (!saved?.token?.startsWith('sk-'))
424
+ throw new Error('A saved personal Revo API key is required. Run revo auth login or select a saved --project profile.');
425
+ const baseUrl = saved.baseUrl.replace(/\/$/, '');
426
+ const devices = readStore().devices.filter(item => item.workspace === workspace && item.baseUrl === baseUrl
427
+ && (!saved.instance || item.instance === saved.instance));
428
+ if (devices.length !== 1)
429
+ throw new Error('Expected one registered terminal for this workspace and saved profile. Run revo terminal connect first.');
430
+ const device = devices[0];
431
+ const profile = { baseUrl, token: saved.token, instance: device.instance };
432
+ const listing = await api(profile, 'GET', '/agents/terminals');
433
+ if (!listing.devices.some(item => item.agentTerminalId === device.agentTerminalId && item.status === 'approved'))
434
+ throw new Error('Terminal grant is not approved. Approve it in AI Agents → Terminals before installing the service.');
435
+ const result = installTerminalService({ workspace, envName, pathValue: process.env.PATH,
436
+ configHome: process.env.XDG_CONFIG_HOME });
437
+ console.log(result.installed ? `Terminal service installed and started (${result.label}).`
438
+ : `Terminal service already installed (${result.label}).`);
439
+ return;
440
+ }
441
+ const profile = resolveProfile(argv);
442
+ let me;
443
+ if (!profile.instance) {
444
+ const currentUser = await api(profile, 'GET', '/me');
445
+ me = currentUser;
446
+ profile.instance = currentUser.instance?.id;
447
+ if (!profile.instance)
448
+ throw new Error('The API key instance could not be resolved. Pass --instance.');
449
+ }
450
+ const workspace = fs.realpathSync(option(argv, 'workspace') || process.cwd());
451
+ const store = readStore();
452
+ let device = store.devices.find(item => item.workspace === workspace && item.baseUrl === profile.baseUrl && item.instance === profile.instance);
453
+ if (command === 'disconnect') {
454
+ if (!device) {
455
+ console.log('No local terminal registration for this workspace.');
456
+ return;
457
+ }
458
+ if (terminalServiceInstalled(workspace, option(argv, 'env') || getActiveEnvironmentName()))
459
+ throw new Error('Uninstall the terminal service before revoking this terminal grant.');
460
+ const selectedDevice = device;
461
+ const listing = await api(profile, 'GET', '/agents/terminals');
462
+ if (listing.devices.some(item => item.agentTerminalId === selectedDevice.agentTerminalId))
463
+ await api(profile, 'POST', `/agents/terminals/${selectedDevice.agentTerminalId}/revoke`);
464
+ store.devices = store.devices.filter(item => item !== selectedDevice);
465
+ saveStore(store);
466
+ console.log('Terminal registration removed.');
467
+ return;
468
+ }
469
+ if (command === 'status') {
470
+ const listing = await api(profile, 'GET', '/agents/terminals');
471
+ console.log(JSON.stringify({ devices: listing.devices.map(item => ({ agentTerminalId: item.agentTerminalId, status: item.status, mode: item.mode })) }, null, 2));
472
+ return;
473
+ }
474
+ if (command !== 'connect')
475
+ throw new Error(`Unknown command: ${command}`);
476
+ if (process.platform !== 'darwin' && process.platform !== 'linux')
477
+ throw new Error('Runner supports macOS and Linux.');
478
+ if (device) {
479
+ const listing = await api(profile, 'GET', '/agents/terminals');
480
+ if (!listing.devices.some(item => item.agentTerminalId === device.agentTerminalId && item.status !== 'revoked')) {
481
+ store.devices = store.devices.filter(item => item !== device);
482
+ saveStore(store);
483
+ device = undefined;
484
+ }
485
+ }
486
+ if (!device) {
487
+ const currentUser = me || await api(profile, 'GET', '/me');
488
+ const rt = option(argv, 'realtime-url') || profile.realtimeUrl || currentUser.instance?.endpoints?.realtime;
489
+ if (!rt)
490
+ throw new Error('Realtime endpoint unavailable. Pass --realtime-url.');
491
+ const secret = randomBytes(32).toString('hex');
492
+ device = { agentTerminalId: randomUUID(), secret, workspace, name: option(argv, 'name') || os.hostname(),
493
+ baseUrl: profile.baseUrl, instance: profile.instance, realtimeUrl: realtimeUrl(rt) };
494
+ await api(profile, 'POST', '/agents/terminals/pair', { agentTerminalId: device.agentTerminalId,
495
+ secretHash: createHash('sha256').update(Buffer.from(secret, 'hex')).digest('hex'),
496
+ name: device.name, workspace });
497
+ store.devices.push(device);
498
+ saveStore(store);
499
+ console.log(`Pending approval in AI Agents → Terminals: ${device.name} (${device.agentTerminalId}).`);
500
+ }
501
+ for (;;) {
502
+ const listing = await api(profile, 'GET', '/agents/terminals');
503
+ const grant = listing.devices.find(item => item.agentTerminalId === device.agentTerminalId);
504
+ if (grant?.status === 'approved')
505
+ break;
506
+ if (!grant || grant.status === 'revoked')
507
+ throw new Error('Terminal grant revoked or missing.');
508
+ await new Promise(resolve => setTimeout(resolve, 2_000));
509
+ }
510
+ await connect(profile, device);
511
+ }
@@ -1,7 +1,12 @@
1
+ import { type ComponentIdentityConfig, type ComponentIdentityMode } from './resource-metadata.ts';
1
2
  import type { ComponentRecord } from './types.ts';
2
3
  export declare const REVO_LOCK_FILE: string;
3
4
  export type ComponentLockEntry = {
5
+ identityMode: ComponentIdentityMode;
6
+ metadataProperty?: string;
7
+ stableKey?: string;
4
8
  componentId: string;
9
+ remoteComponentId: string;
5
10
  componentName: string;
6
11
  category: string | null;
7
12
  path: string;
@@ -9,10 +14,101 @@ export type ComponentLockEntry = {
9
14
  remoteHash: string;
10
15
  sourceHash: string;
11
16
  pulledAt: string;
17
+ environments?: Record<string, ComponentEnvironmentBaseline>;
18
+ };
19
+ export type ComponentEnvironmentBaseline = {
20
+ remoteComponentId: string;
21
+ remoteVersion: number | null;
22
+ remoteHash: string;
23
+ syncedAt: string;
24
+ };
25
+ export type ComponentLockEntryInput = Omit<ComponentLockEntry, 'identityMode' | 'remoteComponentId'> & Partial<Pick<ComponentLockEntry, 'identityMode' | 'remoteComponentId'>>;
26
+ export type EnvironmentBaseline = {
27
+ remoteHash: string;
28
+ syncedAt: string;
29
+ };
30
+ export type RoleGroupLockEntry = {
31
+ stableKey: string;
32
+ path: string;
33
+ sourceHash: string;
34
+ environments: Record<string, EnvironmentBaseline>;
35
+ };
36
+ export type GroupLockEntry = {
37
+ stableKey: string;
38
+ path: string;
39
+ sourceHash: string;
40
+ owner?: GroupOwnerPolicy;
41
+ environments: Record<string, EnvironmentBaseline>;
42
+ };
43
+ export type GroupOwnerPolicy = {
44
+ type: 'group';
45
+ stableKey: string;
46
+ } | {
47
+ type: 'manual-user';
48
+ } | {
49
+ type: 'unresolved';
50
+ reason: string;
51
+ };
52
+ export type JobTemplateLockEntry = {
53
+ stableKey: string;
54
+ path: string;
55
+ sourceHash: string;
56
+ environments: Record<string, EnvironmentBaseline>;
57
+ migration?: {
58
+ componentBinding?: 'pinned-unsupported';
59
+ executionPrincipal?: 'excluded';
60
+ };
61
+ };
62
+ export type ScheduleLockEntry = {
63
+ stableKey: string;
64
+ path: string;
65
+ sourceHash: string;
66
+ environments: Record<string, EnvironmentBaseline>;
67
+ };
68
+ export type EventLockEntry = {
69
+ stableKey: string;
70
+ path: string;
71
+ sourceHash: string;
72
+ environments: Record<string, EnvironmentBaseline>;
73
+ };
74
+ export type EndpointLockEntry = {
75
+ stableKey: string;
76
+ path: string;
77
+ sourceHash: string;
78
+ environments: Record<string, EnvironmentBaseline>;
79
+ };
80
+ export type DatabaseSchemaLockEntry = {
81
+ stableKey: string;
82
+ path: string;
83
+ sourceHash: string;
84
+ sourceRestricted?: boolean;
85
+ parentStableKey?: string;
86
+ environments: Record<string, {
87
+ remoteHash: string;
88
+ tombstone?: {
89
+ deletedAt: string;
90
+ parentStableKey: string;
91
+ };
92
+ }>;
93
+ };
94
+ export type DatabaseViewLockEntry = {
95
+ stableKey: string;
96
+ path: string;
97
+ sourceHash: string;
98
+ environments: Record<string, EnvironmentBaseline>;
12
99
  };
13
100
  export type RevoLockFile = {
14
- schemaVersion: 1;
101
+ schemaVersion: 2;
102
+ componentIdentity: ComponentIdentityConfig;
15
103
  components: Record<string, ComponentLockEntry>;
104
+ groups: Record<string, GroupLockEntry>;
105
+ roleGroups: Record<string, RoleGroupLockEntry>;
106
+ jobTemplates: Record<string, JobTemplateLockEntry>;
107
+ schedules: Record<string, ScheduleLockEntry>;
108
+ events: Record<string, EventLockEntry>;
109
+ endpoints: Record<string, EndpointLockEntry>;
110
+ databaseSchemas: Record<string, DatabaseSchemaLockEntry>;
111
+ databaseViews: Record<string, DatabaseViewLockEntry>;
16
112
  };
17
113
  export declare function hashStable(value: unknown): string;
18
114
  export declare function sanitizeComponentSource<T extends ComponentRecord>(component: T): T;
@@ -33,7 +129,35 @@ export declare function normalizeComponentSource(component: ComponentRecord): {
33
129
  }[];
34
130
  };
35
131
  export declare function hashComponentSource(component: ComponentRecord): string;
132
+ export declare function normalizePortableComponentSource(component: ComponentRecord): {
133
+ name: string;
134
+ category: string | null;
135
+ desc: string | null;
136
+ active: boolean;
137
+ type: string;
138
+ async: boolean;
139
+ elements: {
140
+ key: string;
141
+ desc: string | null;
142
+ hidden: boolean;
143
+ order: number;
144
+ details: string;
145
+ }[];
146
+ };
147
+ export declare function hashPortableComponentSource(component: ComponentRecord): string;
148
+ export declare const PORTABLE_COMPONENT_CONFIG_FIELDS: readonly ["name", "category", "desc", "active", "type", "async"];
149
+ export type PortableComponentConfigField = (typeof PORTABLE_COMPONENT_CONFIG_FIELDS)[number];
150
+ export declare function diffPortableComponentSource(source: ComponentRecord, target: ComponentRecord): {
151
+ configurationFields: ("desc" | "name" | "category" | "type" | "active" | "async")[];
152
+ elementsChanged: boolean;
153
+ changedFields: string[];
154
+ };
155
+ export declare function buildPortableComponentUpdatePayload(source: ComponentRecord, target: ComponentRecord, changedFields: ReadonlyArray<string>): Record<string, unknown>;
36
156
  export declare function getLockPath(projectRoot: string): string;
37
157
  export declare function readComponentLock(projectRoot: string): RevoLockFile;
38
158
  export declare function writeComponentLock(projectRoot: string, lock: RevoLockFile): void;
39
- export declare function upsertComponentLockEntry(projectRoot: string, entry: ComponentLockEntry): void;
159
+ export declare function componentLockKey(identity: ComponentIdentityConfig, component: ComponentRecord): string | null;
160
+ export declare function componentLockMatchesIdentity(lock: RevoLockFile, identity: ComponentIdentityConfig): boolean;
161
+ export declare function getComponentLockEntry(lock: RevoLockFile, identity: ComponentIdentityConfig, component: ComponentRecord, environmentName?: string): ComponentLockEntry | null;
162
+ export declare function getComponentLockEntries(lock: RevoLockFile, identity: ComponentIdentityConfig, environmentName?: string): Record<string, ComponentLockEntry>;
163
+ export declare function upsertComponentLockEntry(projectRoot: string, entry: ComponentLockEntryInput, identity?: ComponentIdentityConfig, environmentName?: string): void;