@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.
@@ -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([
@@ -60,14 +61,13 @@ export function normalizeComponentSource(component) {
60
61
  desc: normalizeValue(source.desc),
61
62
  active: source.active ?? true,
62
63
  type: source.type || '',
63
- compiler: source.compiler || '',
64
64
  async: Boolean(source.async),
65
65
  elements: [...(source.elements || [])]
66
66
  .map((element) => ({
67
- key: element.key,
67
+ key: typeof element.key === 'string' ? element.key : '',
68
68
  desc: normalizeValue(element.desc),
69
69
  hidden: Boolean(element.hidden),
70
- order: element.order,
70
+ order: Number.isFinite(Number(element.order)) ? Number(element.order) : 0,
71
71
  details: element.details ?? '',
72
72
  }))
73
73
  .sort((left, right) => left.order - right.order || left.key.localeCompare(right.key)),
@@ -76,6 +76,16 @@ export function normalizeComponentSource(component) {
76
76
  export function hashComponentSource(component) {
77
77
  return hashStable(normalizeComponentSource(component));
78
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
+ }
79
89
  export function getLockPath(projectRoot) {
80
90
  return path.join(projectRoot, REVO_LOCK_FILE);
81
91
  }
@@ -83,25 +93,98 @@ export function readComponentLock(projectRoot) {
83
93
  const lockPath = getLockPath(projectRoot);
84
94
  if (!fs.existsSync(lockPath)) {
85
95
  return {
86
- schemaVersion: 1,
96
+ schemaVersion: 2,
97
+ componentIdentity: COMPONENT_IDENTITY_BY_ID,
87
98
  components: {},
88
99
  };
89
100
  }
90
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
+ }));
91
120
  return {
92
- schemaVersion: 1,
93
- components: raw.components && typeof raw.components === 'object' ? raw.components : {},
121
+ schemaVersion: 2,
122
+ componentIdentity,
123
+ components,
94
124
  };
95
125
  }
96
126
  export function writeComponentLock(projectRoot, lock) {
97
127
  const sortedComponents = Object.fromEntries(Object.entries(lock.components).sort(([left], [right]) => left.localeCompare(right)));
98
128
  writeJsonFile(getLockPath(projectRoot), {
99
- schemaVersion: 1,
129
+ schemaVersion: 2,
130
+ componentIdentity: normalizeComponentIdentityConfig(lock.componentIdentity),
100
131
  components: sortedComponents,
101
132
  });
102
133
  }
103
- 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);
104
149
  const lock = readComponentLock(projectRoot);
105
- lock.components[entry.componentId] = entry;
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];
173
+ if (existing
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) {
185
+ return;
186
+ }
187
+ lock.componentIdentity = normalizedIdentity;
188
+ lock.components[key] = normalizedEntry;
106
189
  writeComponentLock(projectRoot, lock);
107
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({
@@ -316,8 +381,13 @@ function readAuthValidationStateStore() {
316
381
  if (!fs.existsSync(authStateFile)) {
317
382
  return emptyAuthValidationStateStore();
318
383
  }
319
- const raw = readJsonFile(authStateFile);
320
- return parseAuthValidationStateStore(raw);
384
+ try {
385
+ const raw = readJsonFile(authStateFile);
386
+ return parseAuthValidationStateStore(raw);
387
+ }
388
+ catch {
389
+ return emptyAuthValidationStateStore();
390
+ }
321
391
  }
322
392
  export function readAuthValidationState(key) {
323
393
  const store = readAuthValidationStateStore();
@@ -352,6 +422,7 @@ export function clearAuthValidationState(key) {
352
422
  }
353
423
  }
354
424
  }
425
+ export const DEFAULT_ENV_NAME = 'default';
355
426
  export function resolveRuntimeConfig(options = {}) {
356
427
  const env = {
357
428
  baseUrl: resolveEnvValue(['REVO_URL', 'REVO_BASE_URL', 'REVOENGINE_URL', 'REVOENGINE_BASE_URL']),
@@ -359,6 +430,20 @@ export function resolveRuntimeConfig(options = {}) {
359
430
  token: resolveEnvValue(['REVO_TOKEN', 'REVO_API_KEY', 'REVOENGINE_TOKEN', 'REVOENGINE_API_KEY']),
360
431
  };
361
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
+ }
362
447
  return {
363
448
  baseUrl: options.baseUrl || env.baseUrl || stored.baseUrl || DEFAULT_BASE_URL,
364
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 {};