@revoengine/cli 1.0.9 → 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.
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { REVO_PROJECT_DIR } from "./project.js";
5
+ import { COMPONENT_IDENTITY_BY_ID, normalizeComponentIdentityConfig, readComponentIdentityValue, sameComponentIdentityConfig, } from "./resource-metadata.js";
5
6
  import { readJsonFile, writeJsonFile } from "./utils.js";
6
7
  export const REVO_LOCK_FILE = path.join(REVO_PROJECT_DIR, 'revo.lock.json');
7
8
  const VOLATILE_COMPONENT_FIELDS = new Set([
@@ -63,10 +64,10 @@ export function normalizeComponentSource(component) {
63
64
  async: Boolean(source.async),
64
65
  elements: [...(source.elements || [])]
65
66
  .map((element) => ({
66
- key: element.key,
67
+ key: typeof element.key === 'string' ? element.key : '',
67
68
  desc: normalizeValue(element.desc),
68
69
  hidden: Boolean(element.hidden),
69
- order: element.order,
70
+ order: Number.isFinite(Number(element.order)) ? Number(element.order) : 0,
70
71
  details: element.details ?? '',
71
72
  }))
72
73
  .sort((left, right) => left.order - right.order || left.key.localeCompare(right.key)),
@@ -75,6 +76,16 @@ export function normalizeComponentSource(component) {
75
76
  export function hashComponentSource(component) {
76
77
  return hashStable(normalizeComponentSource(component));
77
78
  }
79
+ // componentId is instance-local, so comparing components across two RevoEngine
80
+ // environments must project it away; element componentElementIds are already
81
+ // excluded by normalizeComponentSource.
82
+ export function normalizePortableComponentSource(component) {
83
+ const { componentId, ...portable } = normalizeComponentSource(component);
84
+ return portable;
85
+ }
86
+ export function hashPortableComponentSource(component) {
87
+ return hashStable(normalizePortableComponentSource(component));
88
+ }
78
89
  export function getLockPath(projectRoot) {
79
90
  return path.join(projectRoot, REVO_LOCK_FILE);
80
91
  }
@@ -82,35 +93,98 @@ export function readComponentLock(projectRoot) {
82
93
  const lockPath = getLockPath(projectRoot);
83
94
  if (!fs.existsSync(lockPath)) {
84
95
  return {
85
- schemaVersion: 1,
96
+ schemaVersion: 2,
97
+ componentIdentity: COMPONENT_IDENTITY_BY_ID,
86
98
  components: {},
87
99
  };
88
100
  }
89
101
  const raw = readJsonFile(lockPath);
102
+ const componentIdentity = normalizeComponentIdentityConfig(raw.componentIdentity, COMPONENT_IDENTITY_BY_ID);
103
+ const components = Object.fromEntries(Object.entries(raw.components && typeof raw.components === 'object' ? raw.components : {})
104
+ .map(([key, entry]) => {
105
+ const remoteComponentId = String(entry.remoteComponentId || entry.componentId || key);
106
+ const identityMode = entry.identityMode || componentIdentity.mode;
107
+ return [key, {
108
+ ...entry,
109
+ identityMode,
110
+ ...(identityMode === 'stableKey'
111
+ ? {
112
+ metadataProperty: entry.metadataProperty || componentIdentity.metadataProperty,
113
+ stableKey: entry.stableKey || key,
114
+ }
115
+ : {}),
116
+ componentId: String(entry.componentId || remoteComponentId),
117
+ remoteComponentId,
118
+ }];
119
+ }));
90
120
  return {
91
- schemaVersion: 1,
92
- components: raw.components && typeof raw.components === 'object' ? raw.components : {},
121
+ schemaVersion: 2,
122
+ componentIdentity,
123
+ components,
93
124
  };
94
125
  }
95
126
  export function writeComponentLock(projectRoot, lock) {
96
127
  const sortedComponents = Object.fromEntries(Object.entries(lock.components).sort(([left], [right]) => left.localeCompare(right)));
97
128
  writeJsonFile(getLockPath(projectRoot), {
98
- schemaVersion: 1,
129
+ schemaVersion: 2,
130
+ componentIdentity: normalizeComponentIdentityConfig(lock.componentIdentity),
99
131
  components: sortedComponents,
100
132
  });
101
133
  }
102
- export function upsertComponentLockEntry(projectRoot, entry) {
134
+ export function componentLockKey(identity, component) {
135
+ return readComponentIdentityValue(component, identity);
136
+ }
137
+ export function componentLockMatchesIdentity(lock, identity) {
138
+ return sameComponentIdentityConfig(lock.componentIdentity, identity);
139
+ }
140
+ export function getComponentLockEntry(lock, identity, component) {
141
+ if (!componentLockMatchesIdentity(lock, identity)) {
142
+ return null;
143
+ }
144
+ const key = componentLockKey(identity, component);
145
+ return key ? lock.components[key] || null : null;
146
+ }
147
+ export function upsertComponentLockEntry(projectRoot, entry, identity = COMPONENT_IDENTITY_BY_ID) {
148
+ const normalizedIdentity = normalizeComponentIdentityConfig(identity);
103
149
  const lock = readComponentLock(projectRoot);
104
- const existing = lock.components[entry.componentId];
150
+ if (!componentLockMatchesIdentity(lock, normalizedIdentity)) {
151
+ lock.componentIdentity = normalizedIdentity;
152
+ lock.components = {};
153
+ }
154
+ const remoteComponentId = entry.remoteComponentId || entry.componentId;
155
+ const key = normalizedIdentity.mode === 'stableKey'
156
+ ? entry.stableKey
157
+ : remoteComponentId;
158
+ if (!key) {
159
+ throw new Error(`Cannot write component lock entry without ${normalizedIdentity.mode === 'stableKey' ? normalizedIdentity.metadataProperty : 'componentId'}.`);
160
+ }
161
+ const normalizedEntry = {
162
+ ...entry,
163
+ identityMode: normalizedIdentity.mode,
164
+ remoteComponentId,
165
+ ...(normalizedIdentity.mode === 'stableKey'
166
+ ? {
167
+ metadataProperty: normalizedIdentity.metadataProperty,
168
+ stableKey: key,
169
+ }
170
+ : {}),
171
+ };
172
+ const existing = lock.components[key];
105
173
  if (existing
106
- && existing.componentName === entry.componentName
107
- && existing.category === entry.category
108
- && existing.path === entry.path
109
- && existing.remoteVersion === entry.remoteVersion
110
- && existing.remoteHash === entry.remoteHash
111
- && existing.sourceHash === entry.sourceHash) {
174
+ && existing.identityMode === normalizedEntry.identityMode
175
+ && existing.metadataProperty === normalizedEntry.metadataProperty
176
+ && existing.stableKey === normalizedEntry.stableKey
177
+ && existing.componentId === normalizedEntry.componentId
178
+ && existing.remoteComponentId === normalizedEntry.remoteComponentId
179
+ && existing.componentName === normalizedEntry.componentName
180
+ && existing.category === normalizedEntry.category
181
+ && existing.path === normalizedEntry.path
182
+ && existing.remoteVersion === normalizedEntry.remoteVersion
183
+ && existing.remoteHash === normalizedEntry.remoteHash
184
+ && existing.sourceHash === normalizedEntry.sourceHash) {
112
185
  return;
113
186
  }
114
- lock.components[entry.componentId] = entry;
187
+ lock.componentIdentity = normalizedIdentity;
188
+ lock.components[key] = normalizedEntry;
115
189
  writeComponentLock(projectRoot, lock);
116
190
  }
@@ -9,9 +9,16 @@ export type RuntimeConfigOptions = {
9
9
  baseUrl?: string;
10
10
  instance?: string;
11
11
  token?: string;
12
+ envName?: string;
13
+ };
14
+ export type StoredEnvironmentProfile = {
15
+ baseUrl: string;
16
+ token: string;
17
+ instance?: string;
12
18
  };
13
19
  type StoredConfig = Omit<RuntimeConfig, 'instance'> & {
14
20
  instance?: string;
21
+ environments?: Record<string, StoredEnvironmentProfile>;
15
22
  };
16
23
  export type AuthValidationState = {
17
24
  key: string;
@@ -45,10 +52,15 @@ export declare function loadStoredConfigIndex(): {
45
52
  export declare function loadStoredConfig(options?: {
46
53
  allowLegacy?: boolean;
47
54
  }): StoredConfig;
55
+ export declare function listEnvironmentProfiles(): Record<string, StoredEnvironmentProfile>;
56
+ export declare function getEnvironmentProfile(name: string): StoredEnvironmentProfile | null;
57
+ export declare function saveEnvironmentProfile(name: string, profile: StoredEnvironmentProfile): void;
58
+ export declare function removeEnvironmentProfile(name: string): boolean;
48
59
  export declare function buildAuthValidationKey(runtime: RuntimeConfigOptions): string;
49
60
  export declare function readAuthValidationState(key?: string): AuthValidationState;
50
61
  export declare function saveAuthValidationState(state: AuthValidationState): void;
51
62
  export declare function clearAuthValidationState(key?: string): void;
63
+ export declare const DEFAULT_ENV_NAME = "default";
52
64
  export declare function resolveRuntimeConfig(options?: RuntimeConfigOptions): {
53
65
  baseUrl: string;
54
66
  instance: string;
@@ -74,16 +74,40 @@ export function saveStoredConfig(nextConfig) {
74
74
  ensureDirectory(dir);
75
75
  const current = readStoredConfigFile();
76
76
  const instance = nextConfig.instance || current.instance || '';
77
+ const environments = nextConfig.environments ?? current.environments;
77
78
  const config = {
78
79
  baseUrl: nextConfig.baseUrl || current.baseUrl || DEFAULT_BASE_URL,
79
80
  token: nextConfig.token || current.token || '',
80
81
  ...(instance ? { instance } : {}),
82
+ ...(environments && Object.keys(environments).length > 0 ? { environments } : {}),
81
83
  };
82
84
  writeJsonFile(configFile, config);
83
85
  removeFileIfExists(credentialsFile);
84
86
  }
85
87
  export function clearStoredConfig() {
86
- const { configFile, credentialsFile, authStateFile } = getConfigPaths();
88
+ const { dir, configFile, credentialsFile, authStateFile } = getConfigPaths();
89
+ // Logout clears the default login but must not destroy named environment
90
+ // profiles stored alongside it.
91
+ const environments = readStoredConfigFile().environments;
92
+ if (environments && Object.keys(environments).length > 0) {
93
+ ensureDirectory(dir);
94
+ writeJsonFile(configFile, {
95
+ baseUrl: DEFAULT_BASE_URL,
96
+ token: '',
97
+ environments,
98
+ });
99
+ for (const filePath of [credentialsFile, authStateFile]) {
100
+ try {
101
+ fs.unlinkSync(filePath);
102
+ }
103
+ catch (error) {
104
+ if (!error || error.code !== 'ENOENT') {
105
+ throw error;
106
+ }
107
+ }
108
+ }
109
+ return;
110
+ }
87
111
  for (const filePath of [configFile, credentialsFile, authStateFile]) {
88
112
  try {
89
113
  fs.unlinkSync(filePath);
@@ -144,6 +168,23 @@ function normalizeInstanceConfig(value) {
144
168
  token: typeof value.token === 'string' ? value.token : '',
145
169
  };
146
170
  }
171
+ function parseEnvironmentProfiles(value) {
172
+ if (!isRecord(value)) {
173
+ return null;
174
+ }
175
+ const environments = {};
176
+ for (const [name, entry] of Object.entries(value)) {
177
+ if (!isRecord(entry) || typeof entry.baseUrl !== 'string' || typeof entry.token !== 'string') {
178
+ continue;
179
+ }
180
+ environments[name] = {
181
+ baseUrl: entry.baseUrl,
182
+ token: entry.token,
183
+ ...(typeof entry.instance === 'string' && isUuid(entry.instance) ? { instance: entry.instance } : {}),
184
+ };
185
+ }
186
+ return Object.keys(environments).length > 0 ? environments : null;
187
+ }
147
188
  function parseFlatConfig(raw, credentials) {
148
189
  if (!isRecord(raw) || isRecord(raw.instances)) {
149
190
  return null;
@@ -153,6 +194,7 @@ function parseFlatConfig(raw, credentials) {
153
194
  : isRecord(credentials) && typeof credentials.token === 'string'
154
195
  ? credentials.token
155
196
  : '';
197
+ const environments = parseEnvironmentProfiles(raw.environments);
156
198
  return {
157
199
  baseUrl: typeof raw.baseUrl === 'string'
158
200
  ? raw.baseUrl
@@ -160,6 +202,7 @@ function parseFlatConfig(raw, credentials) {
160
202
  ? raw.url
161
203
  : DEFAULT_BASE_URL,
162
204
  ...(typeof raw.instance === 'string' && isUuid(raw.instance) ? { instance: raw.instance } : {}),
205
+ ...(environments ? { environments } : {}),
163
206
  token,
164
207
  };
165
208
  }
@@ -181,14 +224,16 @@ function parseMappedConfig(raw) {
181
224
  const defaultInstance = isUuid(configuredDefault) && instances[configuredDefault]
182
225
  ? configuredDefault
183
226
  : Object.keys(instances)[0] || '';
227
+ const environments = parseEnvironmentProfiles(raw.environments);
184
228
  const selected = defaultInstance ? instances[defaultInstance] : undefined;
185
- return selected
229
+ const base = selected
186
230
  ? {
187
231
  baseUrl: selected.baseUrl,
188
232
  instance: defaultInstance,
189
233
  token: selected.token,
190
234
  }
191
235
  : emptyStoredConfigFile();
236
+ return environments ? { ...base, environments } : base;
192
237
  }
193
238
  function parseLegacyConfig(config, credentials) {
194
239
  const baseUrl = isRecord(config) && typeof config.baseUrl === 'string'
@@ -256,6 +301,7 @@ function mergeWithLegacy(stored, options = {}) {
256
301
  return stored;
257
302
  }
258
303
  const merged = {
304
+ ...stored,
259
305
  baseUrl: stored.baseUrl || legacy.baseUrl || DEFAULT_BASE_URL,
260
306
  token: stored.token || legacy.token || '',
261
307
  };
@@ -267,6 +313,25 @@ function mergeWithLegacy(stored, options = {}) {
267
313
  export function loadStoredConfig(options = {}) {
268
314
  return mergeWithLegacy(readStoredConfig(), options);
269
315
  }
316
+ export function listEnvironmentProfiles() {
317
+ return readStoredConfigFile().environments || {};
318
+ }
319
+ export function getEnvironmentProfile(name) {
320
+ return listEnvironmentProfiles()[name] || null;
321
+ }
322
+ export function saveEnvironmentProfile(name, profile) {
323
+ const environments = { ...listEnvironmentProfiles(), [name]: profile };
324
+ saveStoredConfig({ environments });
325
+ }
326
+ export function removeEnvironmentProfile(name) {
327
+ const environments = listEnvironmentProfiles();
328
+ if (!(name in environments)) {
329
+ return false;
330
+ }
331
+ delete environments[name];
332
+ saveStoredConfig({ environments });
333
+ return true;
334
+ }
270
335
  export function buildAuthValidationKey(runtime) {
271
336
  return createHash('sha256')
272
337
  .update(JSON.stringify({
@@ -357,6 +422,7 @@ export function clearAuthValidationState(key) {
357
422
  }
358
423
  }
359
424
  }
425
+ export const DEFAULT_ENV_NAME = 'default';
360
426
  export function resolveRuntimeConfig(options = {}) {
361
427
  const env = {
362
428
  baseUrl: resolveEnvValue(['REVO_URL', 'REVO_BASE_URL', 'REVOENGINE_URL', 'REVOENGINE_BASE_URL']),
@@ -364,6 +430,20 @@ export function resolveRuntimeConfig(options = {}) {
364
430
  token: resolveEnvValue(['REVO_TOKEN', 'REVO_API_KEY', 'REVOENGINE_TOKEN', 'REVOENGINE_API_KEY']),
365
431
  };
366
432
  const stored = loadStoredConfig();
433
+ // A named profile is an explicit selection and resolves exclusively: no
434
+ // fallback to env vars or the stored default, or --from/--to could silently
435
+ // mix one environment's URL with another environment's token or instance.
436
+ if (options.envName && options.envName !== DEFAULT_ENV_NAME) {
437
+ const profile = getEnvironmentProfile(options.envName);
438
+ if (!profile) {
439
+ throw new Error(`Unknown environment "${options.envName}". Run 'revo env list' to see saved environments.`);
440
+ }
441
+ return {
442
+ baseUrl: options.baseUrl || profile.baseUrl || DEFAULT_BASE_URL,
443
+ instance: options.instance || profile.instance || '',
444
+ token: options.token || profile.token || '',
445
+ };
446
+ }
367
447
  return {
368
448
  baseUrl: options.baseUrl || env.baseUrl || stored.baseUrl || DEFAULT_BASE_URL,
369
449
  instance: options.instance || env.instance || stored.instance || '',
@@ -0,0 +1,86 @@
1
+ import type { RevoClient } from './client.ts';
2
+ import { normalizePortableComponentSource } from './component-lock.ts';
3
+ import type { ComponentRecord } from './types.ts';
4
+ export type EnvRef = {
5
+ env: string;
6
+ baseUrl: string;
7
+ instance: string;
8
+ };
9
+ export type PortableComponentSource = ReturnType<typeof normalizePortableComponentSource>;
10
+ export type EnvComponentEntry = {
11
+ stableKey: string;
12
+ componentId: string;
13
+ component: ComponentRecord;
14
+ portable: PortableComponentSource;
15
+ portableHash: string;
16
+ };
17
+ export type EnvCollision = {
18
+ stableKey: string;
19
+ components: Array<{
20
+ componentId: string;
21
+ name: string;
22
+ }>;
23
+ };
24
+ export type EnvSnapshot = {
25
+ ref: EnvRef;
26
+ components: Map<string, EnvComponentEntry>;
27
+ collisions: EnvCollision[];
28
+ };
29
+ export type EnvSyncAction = 'create' | 'update' | 'delete' | 'orphan' | 'noop' | 'collision';
30
+ export type EnvSyncPlanItem = {
31
+ resourceType: 'component';
32
+ stableKey: string;
33
+ action: EnvSyncAction;
34
+ name: string;
35
+ category: string | null;
36
+ sourceComponentId: string | null;
37
+ targetComponentId: string | null;
38
+ sourceHash: string | null;
39
+ targetHash: string | null;
40
+ changedFields?: string[];
41
+ addedElementKeys?: string[];
42
+ removedElementKeys?: string[];
43
+ changedElementKeys?: string[];
44
+ reason: string;
45
+ };
46
+ export type EnvSyncScope = {
47
+ keys?: string[];
48
+ categories?: string[];
49
+ };
50
+ export type EnvSyncPlan = {
51
+ schemaVersion: 1;
52
+ generatedAt: string;
53
+ from: EnvRef;
54
+ to: EnvRef;
55
+ prune: boolean;
56
+ scope?: EnvSyncScope;
57
+ counts: Record<EnvSyncAction, number>;
58
+ items: EnvSyncPlanItem[];
59
+ };
60
+ export type EnvApplyItemResult = {
61
+ stableKey: string;
62
+ action: EnvSyncAction;
63
+ status: 'applied' | 'failed';
64
+ verified?: boolean;
65
+ error?: string;
66
+ };
67
+ export declare function normalizeKeyPart(value: string | null | undefined): string;
68
+ export declare function buildStableKey(component: ComponentRecord, stableKeyName?: string): string;
69
+ export declare function captureEnvSnapshot(client: RevoClient, envName: string, options?: {
70
+ stableKeyName?: string;
71
+ }): Promise<EnvSnapshot>;
72
+ declare const PORTABLE_METADATA_FIELDS: readonly ["name", "category", "desc", "active", "type", "async"];
73
+ export type PortableMetadataField = (typeof PORTABLE_METADATA_FIELDS)[number];
74
+ export declare function buildEnvSyncPlan(source: EnvSnapshot, target: EnvSnapshot, options: {
75
+ prune: boolean;
76
+ scope?: EnvSyncScope;
77
+ }): EnvSyncPlan;
78
+ export declare function applyEnvSyncPlan(input: {
79
+ targetClient: RevoClient;
80
+ plan: EnvSyncPlan;
81
+ source: EnvSnapshot;
82
+ target: EnvSnapshot;
83
+ stableKeyName?: string;
84
+ onItem?: (result: EnvApplyItemResult) => void;
85
+ }): Promise<EnvApplyItemResult[]>;
86
+ export {};