@revoengine/cli 1.0.10 → 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.
- package/README.md +312 -33
- package/dist/src/cli.js +58 -7
- package/dist/src/client.d.ts +356 -5
- package/dist/src/client.js +803 -19
- package/dist/src/commands/auth.js +4 -2
- package/dist/src/commands/component.js +260 -141
- package/dist/src/commands/database-schemas.d.ts +2 -0
- package/dist/src/commands/database-schemas.js +188 -0
- package/dist/src/commands/database-views.d.ts +2 -0
- package/dist/src/commands/database-views.js +123 -0
- package/dist/src/commands/endpoints.js +114 -0
- package/dist/src/commands/env.js +44 -24
- package/dist/src/commands/events.d.ts +2 -0
- package/dist/src/commands/events.js +146 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +169 -0
- package/dist/src/commands/index.d.ts +8 -0
- package/dist/src/commands/index.js +8 -0
- package/dist/src/commands/job-templates.d.ts +2 -0
- package/dist/src/commands/job-templates.js +101 -0
- package/dist/src/commands/metadata.js +29 -7
- package/dist/src/commands/project.js +10 -3
- package/dist/src/commands/role-groups.d.ts +2 -0
- package/dist/src/commands/role-groups.js +152 -0
- package/dist/src/commands/schedules.d.ts +2 -0
- package/dist/src/commands/schedules.js +141 -0
- package/dist/src/commands/terminal-service.d.ts +38 -0
- package/dist/src/commands/terminal-service.js +210 -0
- package/dist/src/commands/terminal.d.ts +22 -0
- package/dist/src/commands/terminal.js +511 -0
- package/dist/src/component-lock.d.ts +100 -2
- package/dist/src/component-lock.js +304 -15
- package/dist/src/config.d.ts +10 -2
- package/dist/src/config.js +49 -14
- package/dist/src/database-schema-artifacts.d.ts +7 -0
- package/dist/src/database-schema-artifacts.js +8 -0
- package/dist/src/env-sync.d.ts +0 -3
- package/dist/src/env-sync.js +3 -19
- package/dist/src/metadata-backfill.d.ts +16 -6
- package/dist/src/metadata-backfill.js +1069 -18
- package/dist/src/project.d.ts +2 -0
- package/dist/src/project.js +4 -17
- package/dist/src/prompt.js +10 -18
- package/dist/src/resource-metadata.d.ts +1 -0
- package/dist/src/resource-metadata.js +3 -0
- package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
- package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
- package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
- package/dist/src/resource-syncs/database-view-sync.js +1317 -0
- package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
- package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
- package/dist/src/resource-syncs/event-sync.d.ts +99 -0
- package/dist/src/resource-syncs/event-sync.js +949 -0
- package/dist/src/resource-syncs/group-sync.d.ts +86 -0
- package/dist/src/resource-syncs/group-sync.js +882 -0
- package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
- package/dist/src/resource-syncs/job-template-sync.js +782 -0
- package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
- package/dist/src/resource-syncs/role-group-sync.js +597 -0
- package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
- package/dist/src/resource-syncs/schedule-sync.js +1302 -0
- package/dist/src/resource-syncs/util.d.ts +19 -0
- package/dist/src/resource-syncs/util.js +116 -0
- package/dist/src/runtime-view.d.ts +1 -0
- package/dist/src/runtime-view.js +6 -1
- package/dist/src/sync-output.d.ts +38 -0
- package/dist/src/sync-output.js +131 -0
- package/dist/src/tracked-resources.d.ts +1 -1
- package/dist/src/tracked-resources.js +26 -2
- package/dist/src/types.d.ts +224 -0
- package/dist/src/ui.d.ts +3 -0
- package/dist/src/ui.js +67 -18
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +64 -0
- package/dist/src/workspace-component.d.ts +2 -0
- package/dist/src/workspace-component.js +34 -0
- package/dist/src/workspace-resource.d.ts +2 -0
- package/dist/src/workspace-resource.js +52 -0
- package/package.json +8 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { RevoClient } from "../client.js";
|
|
4
|
+
import { resolveProjectRoot, readProjectStableKeyName } from "../project.js";
|
|
5
|
+
import { buildRoleGroupsPlan, pullRoleGroupsToWorkspace, pushRoleGroups } from "../resource-syncs/role-group-sync.js";
|
|
6
|
+
import { isInteractiveTerminal, promptConfirm } from "../prompt.js";
|
|
7
|
+
import { printSyncPlan, printSyncStatus, printSyncSummary } from "../sync-output.js";
|
|
8
|
+
import { readBoolFlag, readFlag } from "../utils.js";
|
|
9
|
+
function requireEnvName(context) {
|
|
10
|
+
const envName = readFlag(context.args, ['env']);
|
|
11
|
+
if (!envName)
|
|
12
|
+
throw new Error('Missing --project <name>.');
|
|
13
|
+
return envName;
|
|
14
|
+
}
|
|
15
|
+
function environmentClient(envName) {
|
|
16
|
+
return new RevoClient({ envName });
|
|
17
|
+
}
|
|
18
|
+
function warnAboutExperimentalState(context) {
|
|
19
|
+
const projectRoot = resolveProjectRoot(context.cwd) || path.resolve(context.cwd);
|
|
20
|
+
const legacyPaths = [
|
|
21
|
+
path.join(projectRoot, '.revoengine', 'resources'),
|
|
22
|
+
path.join(projectRoot, '.revoengine', 'resources.lock.json'),
|
|
23
|
+
path.join(projectRoot, '.revoengine', 'plans'),
|
|
24
|
+
].filter((candidate) => fs.existsSync(candidate));
|
|
25
|
+
if (legacyPaths.length > 0) {
|
|
26
|
+
const warning = `Ignoring experimental ${legacyPaths.map((candidate) => path.relative(projectRoot, candidate)).join(', ')}; role-groups use workspace/RoleGroups and .revoengine/revo.lock.json.`;
|
|
27
|
+
if (readBoolFlag(context.args, ['json']))
|
|
28
|
+
(context.warn || context.error)(warning);
|
|
29
|
+
else
|
|
30
|
+
context.println(warning);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function handlePull(context) {
|
|
34
|
+
if (!readBoolFlag(context.args, ['all', 'a'])) {
|
|
35
|
+
throw new Error('Role-group pull currently requires --all.');
|
|
36
|
+
}
|
|
37
|
+
const envName = requireEnvName(context);
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
const result = await pullRoleGroupsToWorkspace({
|
|
40
|
+
client: environmentClient(envName),
|
|
41
|
+
cwd: context.cwd,
|
|
42
|
+
envName,
|
|
43
|
+
stableKeyName: readProjectStableKeyName(context.cwd),
|
|
44
|
+
force: readBoolFlag(context.args, ['force', 'f']),
|
|
45
|
+
});
|
|
46
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
47
|
+
context.print(result);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const outputResults = [];
|
|
51
|
+
for (const pulled of result.pulled) {
|
|
52
|
+
printSyncStatus(context.println, { status: 'Pulled', direction: 'pull', targetPath: pulled.targetPath });
|
|
53
|
+
outputResults.push({ status: 'pulled', targetPath: pulled.targetPath });
|
|
54
|
+
}
|
|
55
|
+
for (const skipped of result.skipped) {
|
|
56
|
+
printSyncStatus(context.println, { status: 'Skipped', direction: 'pull', targetPath: skipped.targetPath, reason: skipped.reason });
|
|
57
|
+
outputResults.push({ status: 'skipped', targetPath: skipped.targetPath, reason: skipped.reason });
|
|
58
|
+
}
|
|
59
|
+
printSyncSummary(context.println, 'Pulled', outputResults, Date.now() - startedAt);
|
|
60
|
+
}
|
|
61
|
+
async function handlePlan(context) {
|
|
62
|
+
if (!readBoolFlag(context.args, ['all', 'a']))
|
|
63
|
+
throw new Error('Role-group plan currently requires --all.');
|
|
64
|
+
if (readBoolFlag(context.args, ['prune'])) {
|
|
65
|
+
throw new Error('Role-group prune is disabled until the backend soft-delete contract is confirmed.');
|
|
66
|
+
}
|
|
67
|
+
const envName = requireEnvName(context);
|
|
68
|
+
const plan = await buildRoleGroupsPlan({
|
|
69
|
+
client: environmentClient(envName),
|
|
70
|
+
cwd: context.cwd,
|
|
71
|
+
envName,
|
|
72
|
+
stableKeyName: readProjectStableKeyName(context.cwd),
|
|
73
|
+
});
|
|
74
|
+
if (readBoolFlag(context.args, ['json']))
|
|
75
|
+
context.print(plan);
|
|
76
|
+
else
|
|
77
|
+
printSyncPlan(context.println, {
|
|
78
|
+
title: 'Role-group',
|
|
79
|
+
environment: plan.env,
|
|
80
|
+
counts: plan.counts,
|
|
81
|
+
items: plan.items.map((item) => ({
|
|
82
|
+
...item,
|
|
83
|
+
details: [
|
|
84
|
+
...(item.rolesToAdd?.length ? [{ label: 'roles add', values: item.rolesToAdd }] : []),
|
|
85
|
+
...(item.rolesToRemove?.length ? [{ label: 'roles remove', values: item.rolesToRemove }] : []),
|
|
86
|
+
],
|
|
87
|
+
})),
|
|
88
|
+
blockers: plan.blockers,
|
|
89
|
+
});
|
|
90
|
+
if (readBoolFlag(context.args, ['strict'])) {
|
|
91
|
+
const drift = plan.items.filter((item) => !['clean', 'orphan', 'skipped'].includes(item.status));
|
|
92
|
+
if (plan.blockers.length > 0 || drift.length > 0) {
|
|
93
|
+
throw new Error(`Role-group plan is not clean: ${drift.length} drift item(s), ${plan.blockers.length} blocker(s).`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function handlePush(context) {
|
|
98
|
+
if (!readBoolFlag(context.args, ['all', 'a']))
|
|
99
|
+
throw new Error('Role-group push currently requires --all.');
|
|
100
|
+
if (readBoolFlag(context.args, ['prune'])) {
|
|
101
|
+
throw new Error('Role-group prune is disabled until the backend soft-delete contract is confirmed.');
|
|
102
|
+
}
|
|
103
|
+
const envName = requireEnvName(context);
|
|
104
|
+
if (!readBoolFlag(context.args, ['yes', 'y'])) {
|
|
105
|
+
if (!isInteractiveTerminal() || !await promptConfirm(`Push role-groups to "${envName}"?`, false)) {
|
|
106
|
+
throw new Error('Role-group push requires confirmation. Re-run with --yes or use an interactive terminal.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const startedAt = Date.now();
|
|
110
|
+
const result = await pushRoleGroups({
|
|
111
|
+
client: environmentClient(envName),
|
|
112
|
+
cwd: context.cwd,
|
|
113
|
+
envName,
|
|
114
|
+
stableKeyName: readProjectStableKeyName(context.cwd),
|
|
115
|
+
force: readBoolFlag(context.args, ['force', 'f']),
|
|
116
|
+
});
|
|
117
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
118
|
+
context.print(result);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
for (const item of result.results) {
|
|
122
|
+
const label = item.status === 'deployed' ? 'Deployed' : item.status === 'failed' ? 'Failed' : 'Skipped';
|
|
123
|
+
printSyncStatus(context.println, {
|
|
124
|
+
status: label,
|
|
125
|
+
direction: 'push',
|
|
126
|
+
targetPath: item.targetPath,
|
|
127
|
+
reason: item.error || item.reason,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
printSyncSummary(context.println, 'Deployed', result.results, Date.now() - startedAt);
|
|
131
|
+
}
|
|
132
|
+
const failed = result.results.filter((item) => item.status === 'failed');
|
|
133
|
+
if (failed.length > 0)
|
|
134
|
+
throw new Error(`Role-group push finished with ${failed.length} failure(s).`);
|
|
135
|
+
}
|
|
136
|
+
export async function handleRoleGroupsCommand(context) {
|
|
137
|
+
warnAboutExperimentalState(context);
|
|
138
|
+
const subcommand = context.args._[1] || '';
|
|
139
|
+
if (subcommand === 'pull') {
|
|
140
|
+
await handlePull(context);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (subcommand === 'plan') {
|
|
144
|
+
await handlePlan(context);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (subcommand === 'push') {
|
|
148
|
+
await handlePush(context);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw new Error('Unknown role-groups command. Usage: revo role-groups <pull|plan|push> --all --project <name>');
|
|
152
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { RevoClient } from "../client.js";
|
|
2
|
+
import { DEFAULT_ENV_NAME } from "../config.js";
|
|
3
|
+
import { isInteractiveTerminal, promptConfirm } from "../prompt.js";
|
|
4
|
+
import { readProjectStableKeyName } from "../project.js";
|
|
5
|
+
import { activateVerifiedSchedules, buildSchedulesPlan, pullSchedulesToWorkspace, pushSchedules } from "../resource-syncs/schedule-sync.js";
|
|
6
|
+
import { printSyncNotice, printSyncPlan, printSyncStatus, printSyncSummary } from "../sync-output.js";
|
|
7
|
+
import { readBoolFlag, readFlag } from "../utils.js";
|
|
8
|
+
export async function handleSchedulesCommand(context) {
|
|
9
|
+
const subcommand = context.args._[1] || '';
|
|
10
|
+
if (!['pull', 'plan', 'push'].includes(subcommand)) {
|
|
11
|
+
throw new Error('Unknown schedules command. Usage: revo schedules <pull|plan|push> --all --project <name>');
|
|
12
|
+
}
|
|
13
|
+
if (!readBoolFlag(context.args, ['all', 'a'])) {
|
|
14
|
+
throw new Error('Schedule sync currently requires --all.');
|
|
15
|
+
}
|
|
16
|
+
const envName = readFlag(context.args, ['env']);
|
|
17
|
+
if (!envName) {
|
|
18
|
+
throw new Error('Missing --project <name>.');
|
|
19
|
+
}
|
|
20
|
+
if (envName === DEFAULT_ENV_NAME) {
|
|
21
|
+
throw new Error('Schedule sync requires a named environment profile; "default" is not supported.');
|
|
22
|
+
}
|
|
23
|
+
const activate = readBoolFlag(context.args, ['activate']);
|
|
24
|
+
if (activate && subcommand !== 'push') {
|
|
25
|
+
throw new Error('--activate is available only with schedules push.');
|
|
26
|
+
}
|
|
27
|
+
if (subcommand === 'push') {
|
|
28
|
+
const yes = readBoolFlag(context.args, ['yes', 'y']);
|
|
29
|
+
if (activate && !yes && !isInteractiveTerminal()) {
|
|
30
|
+
throw new Error('Schedule activation in non-interactive mode requires --yes --activate.');
|
|
31
|
+
}
|
|
32
|
+
if (!yes) {
|
|
33
|
+
if (!isInteractiveTerminal() || !await promptConfirm(`Push Schedules to "${envName}"?`, false)) {
|
|
34
|
+
throw new Error('Schedule push requires confirmation. Re-run with --yes or use an interactive terminal.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const startedAt = Date.now();
|
|
38
|
+
const client = new RevoClient({ envName });
|
|
39
|
+
const pushed = await pushSchedules({
|
|
40
|
+
client,
|
|
41
|
+
cwd: context.cwd,
|
|
42
|
+
envName,
|
|
43
|
+
stableKeyName: readProjectStableKeyName(context.cwd),
|
|
44
|
+
force: readBoolFlag(context.args, ['force', 'f']),
|
|
45
|
+
allowMaterialized: readBoolFlag(context.args, ['allow-materialized']),
|
|
46
|
+
});
|
|
47
|
+
const results = [...pushed.results];
|
|
48
|
+
let activation;
|
|
49
|
+
if (activate) {
|
|
50
|
+
const candidateCount = pushed.verifiedActivationCandidates.length;
|
|
51
|
+
const confirmed = !isInteractiveTerminal() || await promptConfirm(`Activate ${candidateCount} verified Schedule(s) on "${envName}"?`, false);
|
|
52
|
+
if (confirmed) {
|
|
53
|
+
const activationResults = await activateVerifiedSchedules({
|
|
54
|
+
client,
|
|
55
|
+
candidates: pushed.verifiedActivationCandidates,
|
|
56
|
+
});
|
|
57
|
+
results.push(...activationResults);
|
|
58
|
+
activation = { candidateCount, results: activationResults };
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
activation = { candidateCount, results: [], skipped: 'activation not confirmed' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
65
|
+
context.print({
|
|
66
|
+
plan: pushed.plan,
|
|
67
|
+
results,
|
|
68
|
+
...(activation ? { activation } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (const item of results) {
|
|
73
|
+
const label = item.status === 'deployed' ? 'Deployed' : item.status === 'failed' ? 'Failed' : 'Skipped';
|
|
74
|
+
printSyncStatus(context.println, {
|
|
75
|
+
status: label,
|
|
76
|
+
direction: 'push',
|
|
77
|
+
targetPath: item.targetPath,
|
|
78
|
+
reason: item.error || item.reason,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (activation?.skipped) {
|
|
82
|
+
printSyncNotice(context.println, `${activation.candidateCount} verified Schedule activation candidate(s): ${activation.skipped}`);
|
|
83
|
+
}
|
|
84
|
+
printSyncSummary(context.println, 'Deployed', results, Date.now() - startedAt);
|
|
85
|
+
}
|
|
86
|
+
const failed = results.filter((item) => item.status === 'failed');
|
|
87
|
+
if (failed.length > 0) {
|
|
88
|
+
throw new Error(`Schedule push finished with ${failed.length} failure(s).`);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (subcommand === 'plan') {
|
|
93
|
+
const plan = await buildSchedulesPlan({
|
|
94
|
+
client: new RevoClient({ envName }),
|
|
95
|
+
cwd: context.cwd,
|
|
96
|
+
envName,
|
|
97
|
+
stableKeyName: readProjectStableKeyName(context.cwd),
|
|
98
|
+
});
|
|
99
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
100
|
+
context.print(plan);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
printSyncPlan(context.println, {
|
|
104
|
+
title: 'Schedule',
|
|
105
|
+
environment: plan.env,
|
|
106
|
+
counts: plan.counts,
|
|
107
|
+
items: plan.items,
|
|
108
|
+
warnings: [...plan.warnings, ...plan.exclusions],
|
|
109
|
+
blockers: plan.blockers,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (readBoolFlag(context.args, ['strict'])) {
|
|
113
|
+
const drift = plan.items.filter((item) => !['clean', 'orphan', 'skipped'].includes(item.status));
|
|
114
|
+
if (plan.blockers.length > 0 || plan.exclusions.length > 0 || drift.length > 0) {
|
|
115
|
+
throw new Error(`Schedule strict plan is not converged: ${drift.length} drift item(s), ${plan.blockers.length} blocker(s), ${plan.exclusions.length} exclusion(s).`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const startedAt = Date.now();
|
|
121
|
+
const result = await pullSchedulesToWorkspace({
|
|
122
|
+
client: new RevoClient({ envName }),
|
|
123
|
+
cwd: context.cwd,
|
|
124
|
+
envName,
|
|
125
|
+
stableKeyName: readProjectStableKeyName(context.cwd),
|
|
126
|
+
force: readBoolFlag(context.args, ['force', 'f']),
|
|
127
|
+
});
|
|
128
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
129
|
+
context.print(result);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const output = [];
|
|
133
|
+
for (const pulled of result.pulled) {
|
|
134
|
+
printSyncStatus(context.println, { status: 'Pulled', direction: 'pull', targetPath: pulled.targetPath });
|
|
135
|
+
output.push({ status: 'pulled', targetPath: pulled.targetPath });
|
|
136
|
+
}
|
|
137
|
+
for (const warning of result.warnings) {
|
|
138
|
+
printSyncNotice(context.println, `${warning.stableKey}: ${warning.reason}`);
|
|
139
|
+
}
|
|
140
|
+
printSyncSummary(context.println, 'Pulled', output, Date.now() - startedAt);
|
|
141
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type Launchctl = (args: string[]) => void;
|
|
2
|
+
type ServiceOptions = {
|
|
3
|
+
workspace: string;
|
|
4
|
+
envName?: string;
|
|
5
|
+
home?: string;
|
|
6
|
+
entrypoint?: string;
|
|
7
|
+
nodePath?: string;
|
|
8
|
+
pathValue?: string;
|
|
9
|
+
configHome?: string;
|
|
10
|
+
uid?: number;
|
|
11
|
+
platform?: NodeJS.Platform;
|
|
12
|
+
launchctl?: Launchctl;
|
|
13
|
+
systemctl?: (args: string[]) => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function terminalServicePaths(workspace: string, envName?: string, home?: string): {
|
|
16
|
+
label: string;
|
|
17
|
+
plist: string;
|
|
18
|
+
logDirectory: string;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function terminalSystemdPaths(workspace: string, envName?: string, home?: string, configHome?: string): {
|
|
23
|
+
label: string;
|
|
24
|
+
unit: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function renderTerminalSystemdUnit(options: ServiceOptions): string;
|
|
27
|
+
export declare function renderTerminalServicePlist(options: ServiceOptions): string;
|
|
28
|
+
export declare function terminalServiceInstalled(workspace: string, envName?: string): boolean;
|
|
29
|
+
export declare function installTerminalService(options: ServiceOptions): {
|
|
30
|
+
label: string;
|
|
31
|
+
serviceFile: string;
|
|
32
|
+
installed: boolean;
|
|
33
|
+
};
|
|
34
|
+
export declare function uninstallTerminalService(options: ServiceOptions): {
|
|
35
|
+
label: string;
|
|
36
|
+
removed: boolean;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
const AUTH_ENV_KEYS = ['REVO_TOKEN', 'REVO_API_KEY', 'REVOENGINE_TOKEN', 'REVOENGINE_API_KEY',
|
|
7
|
+
'REVO_URL', 'REVO_BASE_URL', 'REVOENGINE_URL', 'REVOENGINE_BASE_URL',
|
|
8
|
+
'REVO_INSTANCE', 'REVOENGINE_INSTANCE'];
|
|
9
|
+
function xml(value) {
|
|
10
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(value))
|
|
11
|
+
throw new Error('LaunchAgent value contains an invalid control character.');
|
|
12
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
14
|
+
}
|
|
15
|
+
export function terminalServicePaths(workspace, envName = 'default', home = os.homedir()) {
|
|
16
|
+
const hash = createHash('sha256').update(`${workspace}\0${envName}`).digest('hex').slice(0, 16);
|
|
17
|
+
const label = `com.revoengine.terminal.${hash}`;
|
|
18
|
+
return {
|
|
19
|
+
label,
|
|
20
|
+
plist: path.join(home, 'Library', 'LaunchAgents', `${label}.plist`),
|
|
21
|
+
logDirectory: path.join(home, 'Library', 'Logs', 'RevoEngine'),
|
|
22
|
+
stdout: path.join(home, 'Library', 'Logs', 'RevoEngine', `${label}.out.log`),
|
|
23
|
+
stderr: path.join(home, 'Library', 'Logs', 'RevoEngine', `${label}.err.log`),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function terminalSystemdPaths(workspace, envName = 'default', home = os.homedir(), configHome = path.join(home, '.config')) {
|
|
27
|
+
if (!path.isAbsolute(configHome))
|
|
28
|
+
throw new Error('XDG_CONFIG_HOME must be an absolute path.');
|
|
29
|
+
const hash = createHash('sha256').update(`${workspace}\0${envName}`).digest('hex').slice(0, 16);
|
|
30
|
+
const label = `revo-terminal-${hash}.service`;
|
|
31
|
+
return { label, unit: path.join(configHome, 'systemd', 'user', label) };
|
|
32
|
+
}
|
|
33
|
+
function systemdQuote(value, execArgument = false) {
|
|
34
|
+
if (/[\u0000-\u001f\u007f]/u.test(value))
|
|
35
|
+
throw new Error('Systemd unit value contains an invalid control character.');
|
|
36
|
+
let escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
37
|
+
if (execArgument)
|
|
38
|
+
escaped = escaped.replace(/\$/g, '$$$$');
|
|
39
|
+
return `"${escaped}"`;
|
|
40
|
+
}
|
|
41
|
+
export function renderTerminalSystemdUnit(options) {
|
|
42
|
+
const node = options.nodePath || process.execPath;
|
|
43
|
+
const entrypoint = options.entrypoint || (process.argv[1] && fs.realpathSync(process.argv[1]));
|
|
44
|
+
if (!entrypoint || !path.isAbsolute(node) || !path.isAbsolute(entrypoint))
|
|
45
|
+
throw new Error('Cannot resolve absolute Node.js and revo executable paths for systemd.');
|
|
46
|
+
const args = [node, entrypoint, 'terminal', 'connect', '--workspace', options.workspace,
|
|
47
|
+
...(options.envName && options.envName !== 'default' ? ['--env', options.envName] : [])];
|
|
48
|
+
const env = [
|
|
49
|
+
...(options.pathValue ? [`Environment=${systemdQuote(`PATH=${options.pathValue}`)}`] : []),
|
|
50
|
+
...(options.configHome ? [`Environment=${systemdQuote(`XDG_CONFIG_HOME=${options.configHome}`)}`] : []),
|
|
51
|
+
];
|
|
52
|
+
return `[Unit]\nDescription=RevoEngine Agent Terminal\n\n[Service]\nType=simple\nExecStart=${args.map(arg => systemdQuote(arg, true)).join(' ')}\nRestart=always\nRestartSec=30s\n${env.join('\n')}${env.length ? '\n' : ''}UnsetEnvironment=${AUTH_ENV_KEYS.join(' ')}\n\n[Install]\nWantedBy=default.target\n`;
|
|
53
|
+
}
|
|
54
|
+
export function renderTerminalServicePlist(options) {
|
|
55
|
+
const paths = terminalServicePaths(options.workspace, options.envName, options.home);
|
|
56
|
+
const node = options.nodePath || process.execPath;
|
|
57
|
+
const entrypoint = options.entrypoint || (process.argv[1] && fs.realpathSync(process.argv[1]));
|
|
58
|
+
if (!entrypoint || !path.isAbsolute(node) || !path.isAbsolute(entrypoint))
|
|
59
|
+
throw new Error('Cannot resolve absolute Node.js and revo executable paths for LaunchAgent.');
|
|
60
|
+
const args = [node, entrypoint, 'terminal', 'connect', '--workspace', options.workspace,
|
|
61
|
+
...(options.envName && options.envName !== 'default' ? ['--env', options.envName] : [])];
|
|
62
|
+
const lines = args.map(arg => ` <string>${xml(arg)}</string>`).join('\n');
|
|
63
|
+
const env = [];
|
|
64
|
+
if (options.pathValue)
|
|
65
|
+
env.push(` <key>PATH</key><string>${xml(options.pathValue)}</string>`);
|
|
66
|
+
if (options.configHome)
|
|
67
|
+
env.push(` <key>XDG_CONFIG_HOME</key><string>${xml(options.configHome)}</string>`);
|
|
68
|
+
for (const key of AUTH_ENV_KEYS)
|
|
69
|
+
env.push(` <key>${key}</key><string></string>`);
|
|
70
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(paths.label)}</string>\n <key>ProgramArguments</key>\n <array>\n${lines}\n </array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><true/>\n <key>ThrottleInterval</key><integer>30</integer>\n <key>StandardOutPath</key><string>${xml(paths.stdout)}</string>\n <key>StandardErrorPath</key><string>${xml(paths.stderr)}</string>\n <key>EnvironmentVariables</key>\n <dict>\n${env.join('\n')}\n </dict>\n</dict>\n</plist>\n`;
|
|
71
|
+
}
|
|
72
|
+
function ensureMac(options) {
|
|
73
|
+
if ((options.platform || process.platform) !== 'darwin')
|
|
74
|
+
throw new Error('Terminal background service currently supports macOS only.');
|
|
75
|
+
const uid = options.uid ?? process.getuid?.();
|
|
76
|
+
if (uid === undefined)
|
|
77
|
+
throw new Error('Cannot resolve the macOS user ID for LaunchAgent.');
|
|
78
|
+
return uid;
|
|
79
|
+
}
|
|
80
|
+
function ensureSupported(options) {
|
|
81
|
+
const platform = options.platform || process.platform;
|
|
82
|
+
if (platform !== 'darwin' && platform !== 'linux')
|
|
83
|
+
throw new Error('Terminal background service supports macOS and Linux only.');
|
|
84
|
+
return platform;
|
|
85
|
+
}
|
|
86
|
+
function systemctl(options, args) {
|
|
87
|
+
if (options.systemctl)
|
|
88
|
+
options.systemctl(args);
|
|
89
|
+
else
|
|
90
|
+
execFileSync('systemctl', ['--user', ...args], { stdio: 'pipe' });
|
|
91
|
+
}
|
|
92
|
+
function launchctl(options, args) {
|
|
93
|
+
if (options.launchctl)
|
|
94
|
+
options.launchctl(args);
|
|
95
|
+
else
|
|
96
|
+
execFileSync('/bin/launchctl', args, { stdio: 'pipe' });
|
|
97
|
+
}
|
|
98
|
+
function isLoaded(options, uid, label) {
|
|
99
|
+
try {
|
|
100
|
+
launchctl(options, ['print', `gui/${uid}/${label}`]);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function terminalServiceInstalled(workspace, envName = 'default') {
|
|
108
|
+
if (process.platform === 'linux')
|
|
109
|
+
return fs.existsSync(terminalSystemdPaths(workspace, envName, os.homedir(), process.env.XDG_CONFIG_HOME).unit);
|
|
110
|
+
return fs.existsSync(terminalServicePaths(workspace, envName).plist);
|
|
111
|
+
}
|
|
112
|
+
function installLinuxTerminalService(options) {
|
|
113
|
+
const paths = terminalSystemdPaths(options.workspace, options.envName, options.home, options.configHome || path.join(options.home || os.homedir(), '.config'));
|
|
114
|
+
const content = renderTerminalSystemdUnit(options);
|
|
115
|
+
fs.mkdirSync(path.dirname(paths.unit), { recursive: true, mode: 0o700 });
|
|
116
|
+
if (fs.existsSync(paths.unit)) {
|
|
117
|
+
if (fs.readFileSync(paths.unit, 'utf8') !== content)
|
|
118
|
+
throw new Error('Terminal service configuration differs. Uninstall it before installing again.');
|
|
119
|
+
systemctl(options, ['enable', '--now', paths.label]);
|
|
120
|
+
return { label: paths.label, serviceFile: paths.unit, installed: false };
|
|
121
|
+
}
|
|
122
|
+
const temporary = `${paths.unit}.${randomUUID()}.tmp`;
|
|
123
|
+
try {
|
|
124
|
+
fs.writeFileSync(temporary, content, { mode: 0o600, flag: 'wx' });
|
|
125
|
+
fs.renameSync(temporary, paths.unit);
|
|
126
|
+
try {
|
|
127
|
+
systemctl(options, ['daemon-reload']);
|
|
128
|
+
systemctl(options, ['enable', '--now', paths.label]);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
try {
|
|
132
|
+
systemctl(options, ['disable', '--now', paths.label]);
|
|
133
|
+
}
|
|
134
|
+
catch { /* best effort rollback */ }
|
|
135
|
+
fs.unlinkSync(paths.unit);
|
|
136
|
+
try {
|
|
137
|
+
systemctl(options, ['daemon-reload']);
|
|
138
|
+
}
|
|
139
|
+
catch { /* original failure is primary */ }
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
try {
|
|
145
|
+
fs.unlinkSync(temporary);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code !== 'ENOENT')
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { label: paths.label, serviceFile: paths.unit, installed: true };
|
|
153
|
+
}
|
|
154
|
+
export function installTerminalService(options) {
|
|
155
|
+
if (ensureSupported(options) === 'linux')
|
|
156
|
+
return installLinuxTerminalService(options);
|
|
157
|
+
const uid = ensureMac(options);
|
|
158
|
+
const paths = terminalServicePaths(options.workspace, options.envName, options.home);
|
|
159
|
+
const content = renderTerminalServicePlist(options);
|
|
160
|
+
fs.mkdirSync(path.dirname(paths.plist), { recursive: true, mode: 0o700 });
|
|
161
|
+
fs.mkdirSync(paths.logDirectory, { recursive: true, mode: 0o700 });
|
|
162
|
+
if (fs.existsSync(paths.plist)) {
|
|
163
|
+
if (fs.readFileSync(paths.plist, 'utf8') !== content)
|
|
164
|
+
throw new Error('Terminal service configuration differs. Uninstall it before installing again.');
|
|
165
|
+
if (!isLoaded(options, uid, paths.label))
|
|
166
|
+
launchctl(options, ['bootstrap', `gui/${uid}`, paths.plist]);
|
|
167
|
+
return { label: paths.label, serviceFile: paths.plist, installed: false };
|
|
168
|
+
}
|
|
169
|
+
const temporary = `${paths.plist}.${randomUUID()}.tmp`;
|
|
170
|
+
try {
|
|
171
|
+
fs.writeFileSync(temporary, content, { mode: 0o600, flag: 'wx' });
|
|
172
|
+
fs.renameSync(temporary, paths.plist);
|
|
173
|
+
try {
|
|
174
|
+
launchctl(options, ['bootstrap', `gui/${uid}`, paths.plist]);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
fs.unlinkSync(paths.plist);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
try {
|
|
183
|
+
fs.unlinkSync(temporary);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error.code !== 'ENOENT')
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { label: paths.label, serviceFile: paths.plist, installed: true };
|
|
191
|
+
}
|
|
192
|
+
export function uninstallTerminalService(options) {
|
|
193
|
+
if (ensureSupported(options) === 'linux') {
|
|
194
|
+
const paths = terminalSystemdPaths(options.workspace, options.envName, options.home, options.configHome || path.join(options.home || os.homedir(), '.config'));
|
|
195
|
+
if (!fs.existsSync(paths.unit))
|
|
196
|
+
return { label: paths.label, removed: false };
|
|
197
|
+
systemctl(options, ['disable', '--now', paths.label]);
|
|
198
|
+
fs.unlinkSync(paths.unit);
|
|
199
|
+
systemctl(options, ['daemon-reload']);
|
|
200
|
+
return { label: paths.label, removed: true };
|
|
201
|
+
}
|
|
202
|
+
const uid = ensureMac(options);
|
|
203
|
+
const paths = terminalServicePaths(options.workspace, options.envName, options.home);
|
|
204
|
+
const exists = fs.existsSync(paths.plist);
|
|
205
|
+
if (isLoaded(options, uid, paths.label))
|
|
206
|
+
launchctl(options, ['bootout', `gui/${uid}/${paths.label}`]);
|
|
207
|
+
if (exists)
|
|
208
|
+
fs.unlinkSync(paths.plist);
|
|
209
|
+
return { label: paths.label, removed: exists };
|
|
210
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
export declare function childEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
3
|
+
export declare function resolveWorkspaceDirectory(root: string, requested: string): string;
|
|
4
|
+
export declare function boundResultFrame(frame: Record<string, unknown>): Record<string, unknown>;
|
|
5
|
+
export declare function runCommand(input: {
|
|
6
|
+
cmd: string;
|
|
7
|
+
workdir: string;
|
|
8
|
+
workspace: string;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
executionId?: string;
|
|
11
|
+
logDir?: string;
|
|
12
|
+
}, onDone: (result: {
|
|
13
|
+
executed: boolean;
|
|
14
|
+
exitCode: number | null;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
timedOut: boolean;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
logTruncated: boolean;
|
|
20
|
+
logPath: string;
|
|
21
|
+
}) => void, onChunk?: (stream: 'stdout' | 'stderr', text: string) => void): Promise<ChildProcess>;
|
|
22
|
+
export declare function handleTerminalCommand(argv: string[]): Promise<void>;
|