@revoengine/cli 1.0.8 → 1.0.10
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 +181 -4
- package/dist/src/cli.js +9 -1
- package/dist/src/client.d.ts +10 -0
- package/dist/src/client.js +157 -0
- package/dist/src/commands/component.js +607 -298
- package/dist/src/commands/env.d.ts +2 -0
- package/dist/src/commands/env.js +360 -0
- package/dist/src/commands/index.d.ts +2 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +137 -0
- package/dist/src/commands/project.js +75 -1
- package/dist/src/component-lock.d.ts +28 -3
- package/dist/src/component-lock.js +92 -9
- package/dist/src/config.d.ts +12 -0
- package/dist/src/config.js +89 -4
- package/dist/src/env-sync.d.ts +86 -0
- package/dist/src/env-sync.js +331 -0
- package/dist/src/metadata-backfill.d.ts +46 -0
- package/dist/src/metadata-backfill.js +125 -0
- package/dist/src/project.d.ts +15 -9
- package/dist/src/project.js +89 -0
- package/dist/src/resource-metadata.d.ts +24 -0
- package/dist/src/resource-metadata.js +129 -0
- package/dist/src/tracked-resources.d.ts +7 -0
- package/dist/src/tracked-resources.js +37 -0
- package/dist/src/types.d.ts +3 -0
- package/dist/src/ui.js +12 -3
- package/package.json +2 -2
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { RevoClient } from "../client.js";
|
|
2
|
+
import { DEFAULT_BASE_URL, DEFAULT_ENV_NAME, getConfigDir, getEnvironmentProfile, listEnvironmentProfiles, removeEnvironmentProfile, resolveRuntimeConfig, saveEnvironmentProfile, } from "../config.js";
|
|
3
|
+
import { applyEnvSyncPlan, buildEnvSyncPlan, captureEnvSnapshot, } from "../env-sync.js";
|
|
4
|
+
import { isInteractiveTerminal, promptConfirm, promptText } from "../prompt.js";
|
|
5
|
+
import { readProjectStableKeyName, readProjectTrackedResources } from "../project.js";
|
|
6
|
+
import { normalizeStableKeyName } from "../resource-metadata.js";
|
|
7
|
+
import { normalizeTrackedResources, shouldTrackResource } from "../tracked-resources.js";
|
|
8
|
+
import { readBoolFlag, readFlag, readValues } from "../utils.js";
|
|
9
|
+
const ENV_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
10
|
+
const RESERVED_ENV_NAMES = new Set(['add', 'list', 'remove', 'diff', 'apply', DEFAULT_ENV_NAME]);
|
|
11
|
+
const ANSI = {
|
|
12
|
+
reset: '\u001b[0m',
|
|
13
|
+
bold: '\u001b[1m',
|
|
14
|
+
dim: '\u001b[2m',
|
|
15
|
+
green: '\u001b[32m',
|
|
16
|
+
yellow: '\u001b[33m',
|
|
17
|
+
red: '\u001b[31m',
|
|
18
|
+
magenta: '\u001b[35m',
|
|
19
|
+
cyan: '\u001b[36m',
|
|
20
|
+
};
|
|
21
|
+
function supportsColor() {
|
|
22
|
+
return Boolean(process.stdout.isTTY || process.env.FORCE_COLOR);
|
|
23
|
+
}
|
|
24
|
+
function paint(text, code) {
|
|
25
|
+
return supportsColor() ? `${code}${text}${ANSI.reset}` : text;
|
|
26
|
+
}
|
|
27
|
+
const ACTION_MARKS = {
|
|
28
|
+
create: { symbol: '+', code: ANSI.bold + ANSI.green },
|
|
29
|
+
update: { symbol: '~', code: ANSI.bold + ANSI.yellow },
|
|
30
|
+
delete: { symbol: '-', code: ANSI.bold + ANSI.red },
|
|
31
|
+
collision: { symbol: '!', code: ANSI.bold + ANSI.magenta },
|
|
32
|
+
orphan: { symbol: ' ', code: ANSI.dim },
|
|
33
|
+
noop: { symbol: ' ', code: ANSI.dim },
|
|
34
|
+
};
|
|
35
|
+
function formatUpdateDetail(item) {
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (item.changedFields && item.changedFields.length > 0) {
|
|
38
|
+
parts.push(`fields: ${item.changedFields.join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
const elementParts = [
|
|
41
|
+
...(item.changedElementKeys || []).map((key) => `~${key}`),
|
|
42
|
+
...(item.addedElementKeys || []).map((key) => `+${key}`),
|
|
43
|
+
...(item.removedElementKeys || []).map((key) => `-${key}`),
|
|
44
|
+
];
|
|
45
|
+
if (elementParts.length > 0) {
|
|
46
|
+
parts.push(`elements ${elementParts.join(' ')}`);
|
|
47
|
+
}
|
|
48
|
+
return parts.length > 0 ? ` (${parts.join('; ')})` : '';
|
|
49
|
+
}
|
|
50
|
+
function printPlanText(context, plan) {
|
|
51
|
+
const { println } = context;
|
|
52
|
+
println(`Comparing ${paint(plan.from.env, ANSI.bold + ANSI.cyan)} -> ${paint(plan.to.env, ANSI.bold + ANSI.cyan)}`);
|
|
53
|
+
for (const item of plan.items) {
|
|
54
|
+
if (item.action === 'noop') {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const mark = ACTION_MARKS[item.action];
|
|
58
|
+
const detail = item.action === 'update'
|
|
59
|
+
? formatUpdateDetail(item)
|
|
60
|
+
: item.reason
|
|
61
|
+
? ` (${item.reason})`
|
|
62
|
+
: '';
|
|
63
|
+
const line = `${mark.symbol} ${item.action.padEnd(9)} ${item.resourceType} ${item.stableKey}${detail}`;
|
|
64
|
+
println(paint(line, mark.code));
|
|
65
|
+
}
|
|
66
|
+
const { counts } = plan;
|
|
67
|
+
println(`Plan: ${counts.create} to create, ${counts.update} to update, ${counts.delete} to delete, `
|
|
68
|
+
+ `${counts.noop} unchanged, ${counts.orphan} orphaned, ${counts.collision} collisions.`);
|
|
69
|
+
}
|
|
70
|
+
function hasBlockingDrift(plan) {
|
|
71
|
+
return plan.items.some((item) => item.action !== 'noop');
|
|
72
|
+
}
|
|
73
|
+
function readScope(args) {
|
|
74
|
+
const keys = readValues(args, ['key']);
|
|
75
|
+
const categories = readValues(args, ['category']);
|
|
76
|
+
return keys.length > 0 || categories.length > 0 ? { keys, categories } : undefined;
|
|
77
|
+
}
|
|
78
|
+
function readStableKeyName(context) {
|
|
79
|
+
return normalizeStableKeyName(readFlag(context.args, ['stable-key-name', 'stableKeyName'])
|
|
80
|
+
|| readProjectStableKeyName(context.cwd));
|
|
81
|
+
}
|
|
82
|
+
function readTrackedResources(context) {
|
|
83
|
+
const values = readValues(context.args, ['resources', 'resource', 'tracked-resources', 'trackedResources']);
|
|
84
|
+
return values.length > 0
|
|
85
|
+
? normalizeTrackedResources(values)
|
|
86
|
+
: readProjectTrackedResources(context.cwd);
|
|
87
|
+
}
|
|
88
|
+
function requireEnvName(args, flag) {
|
|
89
|
+
const value = readFlag(args, [flag]) || '';
|
|
90
|
+
if (!value) {
|
|
91
|
+
throw new Error(`Missing --${flag} <env>. Use a saved environment name or "${DEFAULT_ENV_NAME}".`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function buildEnvClient(envName) {
|
|
96
|
+
return new RevoClient(resolveRuntimeConfig({ envName }));
|
|
97
|
+
}
|
|
98
|
+
function assertDifferentEnvironments(fromName, toName, from, to) {
|
|
99
|
+
if (from.baseUrl === to.baseUrl && from.token === to.token) {
|
|
100
|
+
throw new Error(`--from ${fromName} and --to ${toName} resolve to the same environment.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function captureSnapshots(fromName, toName, stableKeyName) {
|
|
104
|
+
const fromClient = buildEnvClient(fromName);
|
|
105
|
+
const toClient = buildEnvClient(toName);
|
|
106
|
+
assertDifferentEnvironments(fromName, toName, fromClient, toClient);
|
|
107
|
+
const [source, target] = await Promise.all([
|
|
108
|
+
captureEnvSnapshot(fromClient, fromName, { stableKeyName }),
|
|
109
|
+
captureEnvSnapshot(toClient, toName, { stableKeyName }),
|
|
110
|
+
]);
|
|
111
|
+
return { source, target, toClient };
|
|
112
|
+
}
|
|
113
|
+
function emptySnapshot(client, envName) {
|
|
114
|
+
return {
|
|
115
|
+
ref: {
|
|
116
|
+
env: envName,
|
|
117
|
+
baseUrl: client.baseUrl,
|
|
118
|
+
instance: client.instance,
|
|
119
|
+
},
|
|
120
|
+
components: new Map(),
|
|
121
|
+
collisions: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function buildEmptySnapshots(fromName, toName) {
|
|
125
|
+
const fromClient = buildEnvClient(fromName);
|
|
126
|
+
const toClient = buildEnvClient(toName);
|
|
127
|
+
assertDifferentEnvironments(fromName, toName, fromClient, toClient);
|
|
128
|
+
return {
|
|
129
|
+
source: emptySnapshot(fromClient, fromName),
|
|
130
|
+
target: emptySnapshot(toClient, toName),
|
|
131
|
+
toClient,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function validateProfileName(name) {
|
|
135
|
+
if (!name) {
|
|
136
|
+
throw new Error('Missing environment name. Usage: revo env add <name>');
|
|
137
|
+
}
|
|
138
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
139
|
+
throw new Error('Environment names may only contain letters, digits, dots, underscores, and dashes.');
|
|
140
|
+
}
|
|
141
|
+
if (RESERVED_ENV_NAMES.has(name)) {
|
|
142
|
+
throw new Error(`"${name}" is a reserved name and cannot be used for an environment.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function handleEnvAdd(context) {
|
|
146
|
+
const { args, println } = context;
|
|
147
|
+
const name = args._[2] || '';
|
|
148
|
+
validateProfileName(name);
|
|
149
|
+
const offline = readBoolFlag(args, ['offline']);
|
|
150
|
+
const baseUrl = readFlag(args, ['url', 'baseUrl'])
|
|
151
|
+
|| await promptText('RevoEngine API URL', DEFAULT_BASE_URL);
|
|
152
|
+
const token = (readFlag(args, ['token']) || await promptText('Paste API key (input is visible)')).trim();
|
|
153
|
+
if (!token) {
|
|
154
|
+
throw new Error('API key is required.');
|
|
155
|
+
}
|
|
156
|
+
let instance = '';
|
|
157
|
+
if (!offline) {
|
|
158
|
+
const probe = new RevoClient({ baseUrl, token });
|
|
159
|
+
await probe.validateSession({ force: true });
|
|
160
|
+
instance = probe.instance;
|
|
161
|
+
}
|
|
162
|
+
const existed = Boolean(getEnvironmentProfile(name));
|
|
163
|
+
saveEnvironmentProfile(name, {
|
|
164
|
+
baseUrl,
|
|
165
|
+
token,
|
|
166
|
+
...(instance ? { instance } : {}),
|
|
167
|
+
});
|
|
168
|
+
println(`${existed ? 'Updated' : 'Saved'} environment "${name}" in ${getConfigDir()}.`);
|
|
169
|
+
}
|
|
170
|
+
function handleEnvList(context) {
|
|
171
|
+
const { args, print, println } = context;
|
|
172
|
+
const profiles = listEnvironmentProfiles();
|
|
173
|
+
const names = Object.keys(profiles).sort();
|
|
174
|
+
if (readBoolFlag(args, ['json'])) {
|
|
175
|
+
print(Object.fromEntries(names.map((name) => [name, {
|
|
176
|
+
baseUrl: profiles[name].baseUrl,
|
|
177
|
+
instance: profiles[name].instance || null,
|
|
178
|
+
tokenConfigured: Boolean(profiles[name].token),
|
|
179
|
+
}])));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (names.length === 0) {
|
|
183
|
+
println('No environments saved. Run `revo env add <name>` to add one.');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const name of names) {
|
|
187
|
+
const profile = profiles[name];
|
|
188
|
+
const instanceSuffix = profile.instance ? ` (instance ${profile.instance})` : '';
|
|
189
|
+
println(`${paint(name.padEnd(16), ANSI.bold + ANSI.cyan)} ${profile.baseUrl}${instanceSuffix}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function handleEnvRemove(context) {
|
|
193
|
+
const { args, println } = context;
|
|
194
|
+
const name = args._[2] || '';
|
|
195
|
+
if (!name) {
|
|
196
|
+
throw new Error('Missing environment name. Usage: revo env remove <name>');
|
|
197
|
+
}
|
|
198
|
+
if (!removeEnvironmentProfile(name)) {
|
|
199
|
+
throw new Error(`Unknown environment "${name}". Run 'revo env list' to see saved environments.`);
|
|
200
|
+
}
|
|
201
|
+
println(`Removed environment "${name}".`);
|
|
202
|
+
}
|
|
203
|
+
async function handleEnvDiff(context) {
|
|
204
|
+
const { args, print } = context;
|
|
205
|
+
const fromName = requireEnvName(args, 'from');
|
|
206
|
+
const toName = requireEnvName(args, 'to');
|
|
207
|
+
const prune = readBoolFlag(args, ['prune']);
|
|
208
|
+
const stableKeyName = readStableKeyName(context);
|
|
209
|
+
const trackedResources = readTrackedResources(context);
|
|
210
|
+
const { source, target } = shouldTrackResource(trackedResources, 'component')
|
|
211
|
+
? await captureSnapshots(fromName, toName, stableKeyName)
|
|
212
|
+
: buildEmptySnapshots(fromName, toName);
|
|
213
|
+
const plan = buildEnvSyncPlan(source, target, { prune, scope: readScope(args) });
|
|
214
|
+
if (readBoolFlag(args, ['json'])) {
|
|
215
|
+
print(plan);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
printPlanText(context, plan);
|
|
219
|
+
}
|
|
220
|
+
if (readBoolFlag(args, ['strict']) && hasBlockingDrift(plan)) {
|
|
221
|
+
throw new Error('Strict mode: environments differ.');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function confirmApply(context, plan, toName) {
|
|
225
|
+
const { args, println } = context;
|
|
226
|
+
const yes = readBoolFlag(args, ['yes']);
|
|
227
|
+
const force = readBoolFlag(args, ['force']);
|
|
228
|
+
const changeCount = plan.counts.create + plan.counts.update + plan.counts.delete;
|
|
229
|
+
const deleteCount = plan.counts.delete;
|
|
230
|
+
if (!isInteractiveTerminal()) {
|
|
231
|
+
if (!yes) {
|
|
232
|
+
throw new Error('Apply requires confirmation. Re-run with --yes or use an interactive terminal.');
|
|
233
|
+
}
|
|
234
|
+
if (deleteCount > 0 && !force) {
|
|
235
|
+
throw new Error(`Apply would delete ${deleteCount} component(s) from "${toName}". Re-run with --force to confirm deletes.`);
|
|
236
|
+
}
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
if (!yes) {
|
|
240
|
+
const confirmed = await promptConfirm(`Apply ${changeCount} change(s) to "${toName}"?`, false);
|
|
241
|
+
if (!confirmed) {
|
|
242
|
+
println('Apply cancelled.');
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (deleteCount > 0 && !force) {
|
|
247
|
+
const confirmed = await promptConfirm(`Also delete ${deleteCount} component(s) from "${toName}"?`, false);
|
|
248
|
+
if (!confirmed) {
|
|
249
|
+
println('Apply cancelled: deletes were not confirmed. Re-run without --prune to apply without deletes.');
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
function formatDuration(durationMs) {
|
|
256
|
+
if (durationMs < 1_000) {
|
|
257
|
+
return `${durationMs}ms`;
|
|
258
|
+
}
|
|
259
|
+
const seconds = durationMs / 1_000;
|
|
260
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
261
|
+
}
|
|
262
|
+
function printApplyResult(context, result) {
|
|
263
|
+
const { println } = context;
|
|
264
|
+
const mark = ACTION_MARKS[result.action];
|
|
265
|
+
if (result.status === 'failed') {
|
|
266
|
+
println(`${paint('Failed', ANSI.bold + ANSI.red)} ${result.action.padEnd(9)} ${result.stableKey} (${result.error})`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const verifiedSuffix = result.verified === false ? paint(' (verification mismatch)', ANSI.bold + ANSI.red) : '';
|
|
270
|
+
println(`${paint('Applied', mark.code)} ${result.action.padEnd(9)} ${result.stableKey}${verifiedSuffix}`);
|
|
271
|
+
}
|
|
272
|
+
async function handleEnvApply(context) {
|
|
273
|
+
const { args, print, println } = context;
|
|
274
|
+
const fromName = requireEnvName(args, 'from');
|
|
275
|
+
const toName = requireEnvName(args, 'to');
|
|
276
|
+
const prune = readBoolFlag(args, ['prune']);
|
|
277
|
+
const dryRun = readBoolFlag(args, ['dry-run', 'dryRun']);
|
|
278
|
+
const json = readBoolFlag(args, ['json']);
|
|
279
|
+
const stableKeyName = readStableKeyName(context);
|
|
280
|
+
const trackedResources = readTrackedResources(context);
|
|
281
|
+
const { source, target, toClient } = shouldTrackResource(trackedResources, 'component')
|
|
282
|
+
? await captureSnapshots(fromName, toName, stableKeyName)
|
|
283
|
+
: buildEmptySnapshots(fromName, toName);
|
|
284
|
+
const plan = buildEnvSyncPlan(source, target, { prune, scope: readScope(args) });
|
|
285
|
+
if (!json) {
|
|
286
|
+
printPlanText(context, plan);
|
|
287
|
+
}
|
|
288
|
+
const changeCount = plan.counts.create + plan.counts.update + plan.counts.delete;
|
|
289
|
+
if (plan.counts.collision > 0) {
|
|
290
|
+
throw new Error(`Cannot apply: ${plan.counts.collision} stable-key collision(s) in scope. Resolve duplicates or narrow the scope with --key/--category.`);
|
|
291
|
+
}
|
|
292
|
+
if (changeCount === 0) {
|
|
293
|
+
if (json) {
|
|
294
|
+
print({ plan, results: [] });
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
println('No changes to apply.');
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (dryRun) {
|
|
302
|
+
if (json) {
|
|
303
|
+
print({ plan, results: [] });
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
println('Dry run: no changes applied.');
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!await confirmApply(context, plan, toName)) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const startedAt = Date.now();
|
|
314
|
+
const results = await applyEnvSyncPlan({
|
|
315
|
+
targetClient: toClient,
|
|
316
|
+
plan,
|
|
317
|
+
source,
|
|
318
|
+
target,
|
|
319
|
+
stableKeyName,
|
|
320
|
+
onItem: json ? undefined : (result) => printApplyResult(context, result),
|
|
321
|
+
});
|
|
322
|
+
const failed = results.filter((result) => result.status === 'failed');
|
|
323
|
+
const unverified = results.filter((result) => result.verified === false);
|
|
324
|
+
if (json) {
|
|
325
|
+
print({ plan, results });
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
println(`Applied ${results.length - failed.length}/${results.length} in ${formatDuration(Date.now() - startedAt)}.`);
|
|
329
|
+
if (unverified.length > 0) {
|
|
330
|
+
println(paint(`Warning: ${unverified.length} component(s) did not verify against the source after apply.`, ANSI.bold + ANSI.yellow));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (failed.length > 0) {
|
|
334
|
+
throw new Error(`Apply finished with ${failed.length} failure(s).`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export async function handleEnvCommand(context) {
|
|
338
|
+
const subcommand = context.args._[1] || 'list';
|
|
339
|
+
if (subcommand === 'add') {
|
|
340
|
+
await handleEnvAdd(context);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (subcommand === 'list') {
|
|
344
|
+
handleEnvList(context);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (subcommand === 'remove') {
|
|
348
|
+
handleEnvRemove(context);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (subcommand === 'diff') {
|
|
352
|
+
await handleEnvDiff(context);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (subcommand === 'apply') {
|
|
356
|
+
await handleEnvApply(context);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
throw new Error(`Unknown env command: ${subcommand}. Available: add, list, remove, diff, apply.`);
|
|
360
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from './auth.ts';
|
|
2
2
|
export * from './component.ts';
|
|
3
3
|
export * from './endpoints.ts';
|
|
4
|
+
export * from './env.ts';
|
|
4
5
|
export * from './info.ts';
|
|
6
|
+
export * from './metadata.ts';
|
|
5
7
|
export * from './project.ts';
|
|
6
8
|
export * from './request.ts';
|
|
7
9
|
export * from './search.ts';
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from "./auth.js";
|
|
2
2
|
export * from "./component.js";
|
|
3
3
|
export * from "./endpoints.js";
|
|
4
|
+
export * from "./env.js";
|
|
4
5
|
export * from "./info.js";
|
|
6
|
+
export * from "./metadata.js";
|
|
5
7
|
export * from "./project.js";
|
|
6
8
|
export * from "./request.js";
|
|
7
9
|
export * from "./search.js";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { RevoClient } from "../client.js";
|
|
2
|
+
import { DEFAULT_ENV_NAME, resolveRuntimeConfig } from "../config.js";
|
|
3
|
+
import { applyMetadataBackfillPlan, buildMetadataBackfillPlan, } from "../metadata-backfill.js";
|
|
4
|
+
import { isInteractiveTerminal, promptConfirm } from "../prompt.js";
|
|
5
|
+
import { readProjectStableKeyName, readProjectTrackedResources } from "../project.js";
|
|
6
|
+
import { normalizeStableKeyName } from "../resource-metadata.js";
|
|
7
|
+
import { normalizeTrackedResources } from "../tracked-resources.js";
|
|
8
|
+
import { readBoolFlag, readFlag, readValues } from "../utils.js";
|
|
9
|
+
const ANSI = {
|
|
10
|
+
reset: '\u001b[0m',
|
|
11
|
+
bold: '\u001b[1m',
|
|
12
|
+
dim: '\u001b[2m',
|
|
13
|
+
green: '\u001b[32m',
|
|
14
|
+
yellow: '\u001b[33m',
|
|
15
|
+
red: '\u001b[31m',
|
|
16
|
+
cyan: '\u001b[36m',
|
|
17
|
+
};
|
|
18
|
+
function supportsColor() {
|
|
19
|
+
return Boolean(process.stdout.isTTY || process.env.FORCE_COLOR);
|
|
20
|
+
}
|
|
21
|
+
function paint(text, code) {
|
|
22
|
+
return supportsColor() ? `${code}${text}${ANSI.reset}` : text;
|
|
23
|
+
}
|
|
24
|
+
function readEnvName(context) {
|
|
25
|
+
return readFlag(context.args, ['env']) || DEFAULT_ENV_NAME;
|
|
26
|
+
}
|
|
27
|
+
function readStableKeyName(context) {
|
|
28
|
+
return normalizeStableKeyName(readFlag(context.args, ['stable-key-name', 'stableKeyName'])
|
|
29
|
+
|| readProjectStableKeyName(context.cwd));
|
|
30
|
+
}
|
|
31
|
+
function readTrackedResources(context) {
|
|
32
|
+
const values = readValues(context.args, ['resources', 'resource', 'tracked-resources', 'trackedResources']);
|
|
33
|
+
return values.length > 0
|
|
34
|
+
? normalizeTrackedResources(values)
|
|
35
|
+
: readProjectTrackedResources(context.cwd);
|
|
36
|
+
}
|
|
37
|
+
function buildEnvClient(envName) {
|
|
38
|
+
return new RevoClient(resolveRuntimeConfig({ envName }));
|
|
39
|
+
}
|
|
40
|
+
async function buildPlan(context) {
|
|
41
|
+
const envName = readEnvName(context);
|
|
42
|
+
const client = buildEnvClient(envName);
|
|
43
|
+
return buildMetadataBackfillPlan({
|
|
44
|
+
client,
|
|
45
|
+
envName,
|
|
46
|
+
stableKeyName: readStableKeyName(context),
|
|
47
|
+
trackedResources: readTrackedResources(context),
|
|
48
|
+
force: readBoolFlag(context.args, ['force']),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function printPlanText(context, plan) {
|
|
52
|
+
const { println } = context;
|
|
53
|
+
println(`Metadata backfill ${paint(plan.env, ANSI.bold + ANSI.cyan)} (${plan.stableKeyName})`);
|
|
54
|
+
for (const item of plan.items) {
|
|
55
|
+
if (item.action === 'skip') {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
println(`${paint('+ update', ANSI.bold + ANSI.green)} ${item.resourceType} ${item.name} -> ${item.metadataField}.${item.stableKeyName}=${item.stableKey}`);
|
|
59
|
+
}
|
|
60
|
+
println(`Plan: ${plan.counts.update} to update, ${plan.counts.skip} already populated.`);
|
|
61
|
+
}
|
|
62
|
+
function printResult(context, result) {
|
|
63
|
+
const { println } = context;
|
|
64
|
+
if (result.action === 'failed') {
|
|
65
|
+
println(`${paint('Failed', ANSI.bold + ANSI.red)} ${result.resourceType} ${result.resourceId} (${result.error})`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (result.action === 'skip') {
|
|
69
|
+
println(`${paint('Skipped', ANSI.dim + ANSI.yellow)} ${result.resourceType} ${result.resourceId}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
println(`${paint('Updated', ANSI.bold + ANSI.green)} ${result.resourceType} ${result.resourceId} ${result.stableKey}`);
|
|
73
|
+
}
|
|
74
|
+
async function confirmApply(context, plan) {
|
|
75
|
+
const yes = readBoolFlag(context.args, ['yes']);
|
|
76
|
+
if (plan.counts.update === 0) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (yes) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (!isInteractiveTerminal()) {
|
|
83
|
+
throw new Error('Metadata apply requires confirmation. Re-run with --yes or use an interactive terminal.');
|
|
84
|
+
}
|
|
85
|
+
return promptConfirm(`Populate metadata on ${plan.counts.update} resource(s) in "${plan.env}"?`, false);
|
|
86
|
+
}
|
|
87
|
+
async function handleMetadataPlan(context) {
|
|
88
|
+
const plan = await buildPlan(context);
|
|
89
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
90
|
+
context.print(plan);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
printPlanText(context, plan);
|
|
94
|
+
}
|
|
95
|
+
async function handleMetadataApply(context) {
|
|
96
|
+
const plan = await buildPlan(context);
|
|
97
|
+
if (!readBoolFlag(context.args, ['json'])) {
|
|
98
|
+
printPlanText(context, plan);
|
|
99
|
+
}
|
|
100
|
+
if (!await confirmApply(context, plan)) {
|
|
101
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
102
|
+
context.print({ plan, results: [] });
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
context.println('No metadata changes to apply.');
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const client = buildEnvClient(plan.env);
|
|
110
|
+
const results = await applyMetadataBackfillPlan({ client, plan });
|
|
111
|
+
if (readBoolFlag(context.args, ['json'])) {
|
|
112
|
+
context.print({ plan, results });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
for (const result of results) {
|
|
116
|
+
printResult(context, result);
|
|
117
|
+
}
|
|
118
|
+
const failed = results.filter((result) => result.action === 'failed').length;
|
|
119
|
+
context.println(`Applied ${results.length - failed}/${results.length}.`);
|
|
120
|
+
}
|
|
121
|
+
const failed = results.filter((result) => result.action === 'failed').length;
|
|
122
|
+
if (failed > 0) {
|
|
123
|
+
throw new Error(`Metadata apply finished with ${failed} failure(s).`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function handleMetadataCommand(context) {
|
|
127
|
+
const subcommand = context.args._[1] || 'plan';
|
|
128
|
+
if (subcommand === 'plan') {
|
|
129
|
+
await handleMetadataPlan(context);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (subcommand === 'apply') {
|
|
133
|
+
await handleMetadataApply(context);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw new Error('Unknown metadata command. Usage: revo metadata <plan|apply> --env <name>');
|
|
137
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { readComponentLock } from "../component-lock.js";
|
|
3
|
+
import { applyMetadataBackfillPlan, buildMetadataBackfillPlan, } from "../metadata-backfill.js";
|
|
4
|
+
import { buildProjectSyncState, buildEditorTypesUrl, extractSandboxEndpoint, extractEditorTypesBundle, readProjectComponentIdentity, readProjectMetadata, readProjectStableKeyName, readProjectTrackedResources, resolveProjectInvocation, resolveProjectTarget, syncProjectFiles, } from "../project.js";
|
|
5
|
+
import { promptText } from "../prompt.js";
|
|
6
|
+
import { DEFAULT_COMPONENT_IDENTITY, DEFAULT_STABLE_KEY_NAME, normalizeComponentIdentityConfig, normalizeComponentIdentityMode, normalizeStableKeyName, } from "../resource-metadata.js";
|
|
7
|
+
import { formatTrackedResources, normalizeTrackedResources, } from "../tracked-resources.js";
|
|
3
8
|
function printSyncSummary(println, projectRoot, workspaceRoot, result, prefix) {
|
|
4
9
|
const workspaceDirectory = path.relative(projectRoot, workspaceRoot) || '.';
|
|
5
10
|
println(`${prefix} ${projectRoot}`);
|
|
@@ -31,6 +36,68 @@ async function resolveProjectSyncInput(context) {
|
|
|
31
36
|
bundle,
|
|
32
37
|
});
|
|
33
38
|
}
|
|
39
|
+
async function resolveTrackedResources(invocation, projectRoot) {
|
|
40
|
+
if (invocation.trackedResources) {
|
|
41
|
+
return invocation.trackedResources;
|
|
42
|
+
}
|
|
43
|
+
if (invocation.action === 'update') {
|
|
44
|
+
return readProjectTrackedResources(projectRoot);
|
|
45
|
+
}
|
|
46
|
+
const existing = readProjectTrackedResources(projectRoot);
|
|
47
|
+
const answer = await promptText('Resources to track with CLI (all, component; comma-separated)', formatTrackedResources(existing));
|
|
48
|
+
return normalizeTrackedResources(answer);
|
|
49
|
+
}
|
|
50
|
+
async function resolveComponentIdentity(invocation, projectRoot) {
|
|
51
|
+
if (invocation.identityMode === 'componentId' && invocation.stableKeyName) {
|
|
52
|
+
throw new Error('--stable-key-name cannot be used with --identity-mode componentId.');
|
|
53
|
+
}
|
|
54
|
+
const existingMetadata = readProjectMetadata(projectRoot);
|
|
55
|
+
const existingLock = readComponentLock(projectRoot);
|
|
56
|
+
const hasTrackedIdentity = Object.keys(existingLock.components).length > 0
|
|
57
|
+
|| existingLock.componentIdentity.mode === 'stableKey';
|
|
58
|
+
const existing = existingMetadata
|
|
59
|
+
? readProjectComponentIdentity(projectRoot)
|
|
60
|
+
: hasTrackedIdentity
|
|
61
|
+
? existingLock.componentIdentity
|
|
62
|
+
: DEFAULT_COMPONENT_IDENTITY;
|
|
63
|
+
const defaultIdentity = existing;
|
|
64
|
+
const mode = invocation.identityMode || (invocation.action === 'update'
|
|
65
|
+
? existing.mode
|
|
66
|
+
: normalizeComponentIdentityMode(await promptText('Component identity mode (stableKey or componentId)', defaultIdentity.mode)));
|
|
67
|
+
if (mode === 'componentId') {
|
|
68
|
+
return { mode };
|
|
69
|
+
}
|
|
70
|
+
const metadataProperty = invocation.stableKeyName || (invocation.action === 'update'
|
|
71
|
+
? readProjectStableKeyName(projectRoot)
|
|
72
|
+
: normalizeStableKeyName(await promptText('Stable key metadata property', defaultIdentity.mode === 'stableKey'
|
|
73
|
+
? defaultIdentity.metadataProperty || DEFAULT_STABLE_KEY_NAME
|
|
74
|
+
: DEFAULT_STABLE_KEY_NAME)));
|
|
75
|
+
return normalizeComponentIdentityConfig({ mode, metadataProperty }, DEFAULT_COMPONENT_IDENTITY);
|
|
76
|
+
}
|
|
77
|
+
async function backfillStableKeyMetadata(context, stableKeyName, trackedResources) {
|
|
78
|
+
const plan = await buildMetadataBackfillPlan({
|
|
79
|
+
client: context.client,
|
|
80
|
+
envName: 'default',
|
|
81
|
+
stableKeyName,
|
|
82
|
+
trackedResources,
|
|
83
|
+
});
|
|
84
|
+
if (plan.counts.update === 0) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const results = await applyMetadataBackfillPlan({
|
|
88
|
+
client: context.client,
|
|
89
|
+
plan,
|
|
90
|
+
});
|
|
91
|
+
const failed = results.filter((result) => result.action === 'failed');
|
|
92
|
+
if (failed.length > 0) {
|
|
93
|
+
const details = failed
|
|
94
|
+
.map((result) => `${result.resourceId}: ${result.error || 'unknown error'}`)
|
|
95
|
+
.join('; ');
|
|
96
|
+
throw new Error(`Project initialized, but stable-key metadata backfill failed for ${failed.length} component(s): ${details}`);
|
|
97
|
+
}
|
|
98
|
+
const updated = results.filter((result) => result.action === 'update').length;
|
|
99
|
+
context.println(`Backfilled stable-key metadata on ${updated} component(s).`);
|
|
100
|
+
}
|
|
34
101
|
export async function handleProjectCommand(context) {
|
|
35
102
|
const { args, client, cwd, println } = context;
|
|
36
103
|
const invocation = resolveProjectInvocation(args);
|
|
@@ -39,6 +106,13 @@ export async function handleProjectCommand(context) {
|
|
|
39
106
|
}
|
|
40
107
|
const layout = resolveProjectTarget(cwd, invocation.targetArg);
|
|
41
108
|
const syncInput = await resolveProjectSyncInput(context);
|
|
109
|
+
syncInput.componentIdentity = await resolveComponentIdentity(invocation, layout.projectRoot);
|
|
110
|
+
syncInput.trackedResources = await resolveTrackedResources(invocation, layout.projectRoot);
|
|
42
111
|
const result = syncProjectFiles(layout, syncInput);
|
|
43
112
|
printSyncSummary(println, layout.projectRoot, layout.workspaceRoot, result, invocation.action === 'update' ? 'Updated Revo project in' : 'Initialized Revo project in');
|
|
113
|
+
if (invocation.action === 'init'
|
|
114
|
+
&& syncInput.componentIdentity.mode === 'stableKey'
|
|
115
|
+
&& context.args['metadata-backfill'] !== false) {
|
|
116
|
+
await backfillStableKeyMetadata(context, syncInput.componentIdentity.metadataProperty || DEFAULT_STABLE_KEY_NAME, syncInput.trackedResources);
|
|
117
|
+
}
|
|
44
118
|
}
|
|
@@ -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;
|
|
@@ -10,8 +15,10 @@ export type ComponentLockEntry = {
|
|
|
10
15
|
sourceHash: string;
|
|
11
16
|
pulledAt: string;
|
|
12
17
|
};
|
|
18
|
+
export type ComponentLockEntryInput = Omit<ComponentLockEntry, 'identityMode' | 'remoteComponentId'> & Partial<Pick<ComponentLockEntry, 'identityMode' | 'remoteComponentId'>>;
|
|
13
19
|
export type RevoLockFile = {
|
|
14
|
-
schemaVersion:
|
|
20
|
+
schemaVersion: 2;
|
|
21
|
+
componentIdentity: ComponentIdentityConfig;
|
|
15
22
|
components: Record<string, ComponentLockEntry>;
|
|
16
23
|
};
|
|
17
24
|
export declare function hashStable(value: unknown): string;
|
|
@@ -23,7 +30,6 @@ export declare function normalizeComponentSource(component: ComponentRecord): {
|
|
|
23
30
|
desc: string | null;
|
|
24
31
|
active: boolean;
|
|
25
32
|
type: string;
|
|
26
|
-
compiler: string;
|
|
27
33
|
async: boolean;
|
|
28
34
|
elements: {
|
|
29
35
|
key: string;
|
|
@@ -34,7 +40,26 @@ export declare function normalizeComponentSource(component: ComponentRecord): {
|
|
|
34
40
|
}[];
|
|
35
41
|
};
|
|
36
42
|
export declare function hashComponentSource(component: ComponentRecord): string;
|
|
43
|
+
export declare function normalizePortableComponentSource(component: ComponentRecord): {
|
|
44
|
+
name: string;
|
|
45
|
+
category: string | null;
|
|
46
|
+
desc: string | null;
|
|
47
|
+
active: boolean;
|
|
48
|
+
type: string;
|
|
49
|
+
async: boolean;
|
|
50
|
+
elements: {
|
|
51
|
+
key: string;
|
|
52
|
+
desc: string | null;
|
|
53
|
+
hidden: boolean;
|
|
54
|
+
order: number;
|
|
55
|
+
details: string;
|
|
56
|
+
}[];
|
|
57
|
+
};
|
|
58
|
+
export declare function hashPortableComponentSource(component: ComponentRecord): string;
|
|
37
59
|
export declare function getLockPath(projectRoot: string): string;
|
|
38
60
|
export declare function readComponentLock(projectRoot: string): RevoLockFile;
|
|
39
61
|
export declare function writeComponentLock(projectRoot: string, lock: RevoLockFile): void;
|
|
40
|
-
export declare function
|
|
62
|
+
export declare function componentLockKey(identity: ComponentIdentityConfig, component: ComponentRecord): string | null;
|
|
63
|
+
export declare function componentLockMatchesIdentity(lock: RevoLockFile, identity: ComponentIdentityConfig): boolean;
|
|
64
|
+
export declare function getComponentLockEntry(lock: RevoLockFile, identity: ComponentIdentityConfig, component: ComponentRecord): ComponentLockEntry | null;
|
|
65
|
+
export declare function upsertComponentLockEntry(projectRoot: string, entry: ComponentLockEntryInput, identity?: ComponentIdentityConfig): void;
|