@revoengine/cli 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +464 -8
  2. package/dist/src/cli.js +65 -6
  3. package/dist/src/client.d.ts +366 -5
  4. package/dist/src/client.js +954 -13
  5. package/dist/src/commands/auth.js +4 -2
  6. package/dist/src/commands/component.js +839 -412
  7. package/dist/src/commands/database-schemas.d.ts +2 -0
  8. package/dist/src/commands/database-schemas.js +188 -0
  9. package/dist/src/commands/database-views.d.ts +2 -0
  10. package/dist/src/commands/database-views.js +123 -0
  11. package/dist/src/commands/endpoints.js +114 -0
  12. package/dist/src/commands/env.d.ts +2 -0
  13. package/dist/src/commands/env.js +380 -0
  14. package/dist/src/commands/events.d.ts +2 -0
  15. package/dist/src/commands/events.js +146 -0
  16. package/dist/src/commands/groups.d.ts +2 -0
  17. package/dist/src/commands/groups.js +169 -0
  18. package/dist/src/commands/index.d.ts +10 -0
  19. package/dist/src/commands/index.js +10 -0
  20. package/dist/src/commands/job-templates.d.ts +2 -0
  21. package/dist/src/commands/job-templates.js +101 -0
  22. package/dist/src/commands/metadata.d.ts +2 -0
  23. package/dist/src/commands/metadata.js +159 -0
  24. package/dist/src/commands/project.js +82 -1
  25. package/dist/src/commands/role-groups.d.ts +2 -0
  26. package/dist/src/commands/role-groups.js +152 -0
  27. package/dist/src/commands/schedules.d.ts +2 -0
  28. package/dist/src/commands/schedules.js +141 -0
  29. package/dist/src/commands/terminal-service.d.ts +38 -0
  30. package/dist/src/commands/terminal-service.js +210 -0
  31. package/dist/src/commands/terminal.d.ts +22 -0
  32. package/dist/src/commands/terminal.js +511 -0
  33. package/dist/src/component-lock.d.ts +126 -2
  34. package/dist/src/component-lock.js +378 -15
  35. package/dist/src/config.d.ts +20 -0
  36. package/dist/src/config.js +121 -6
  37. package/dist/src/database-schema-artifacts.d.ts +7 -0
  38. package/dist/src/database-schema-artifacts.js +8 -0
  39. package/dist/src/env-sync.d.ts +83 -0
  40. package/dist/src/env-sync.js +315 -0
  41. package/dist/src/metadata-backfill.d.ts +56 -0
  42. package/dist/src/metadata-backfill.js +1176 -0
  43. package/dist/src/project.d.ts +17 -9
  44. package/dist/src/project.js +87 -11
  45. package/dist/src/prompt.js +10 -18
  46. package/dist/src/resource-metadata.d.ts +25 -0
  47. package/dist/src/resource-metadata.js +132 -0
  48. package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
  49. package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
  50. package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
  51. package/dist/src/resource-syncs/database-view-sync.js +1317 -0
  52. package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
  53. package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
  54. package/dist/src/resource-syncs/event-sync.d.ts +99 -0
  55. package/dist/src/resource-syncs/event-sync.js +949 -0
  56. package/dist/src/resource-syncs/group-sync.d.ts +86 -0
  57. package/dist/src/resource-syncs/group-sync.js +882 -0
  58. package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
  59. package/dist/src/resource-syncs/job-template-sync.js +782 -0
  60. package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
  61. package/dist/src/resource-syncs/role-group-sync.js +597 -0
  62. package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
  63. package/dist/src/resource-syncs/schedule-sync.js +1302 -0
  64. package/dist/src/resource-syncs/util.d.ts +19 -0
  65. package/dist/src/resource-syncs/util.js +116 -0
  66. package/dist/src/runtime-view.d.ts +1 -0
  67. package/dist/src/runtime-view.js +6 -1
  68. package/dist/src/sync-output.d.ts +38 -0
  69. package/dist/src/sync-output.js +131 -0
  70. package/dist/src/tracked-resources.d.ts +7 -0
  71. package/dist/src/tracked-resources.js +61 -0
  72. package/dist/src/types.d.ts +227 -0
  73. package/dist/src/ui.d.ts +3 -0
  74. package/dist/src/ui.js +68 -10
  75. package/dist/src/utils.d.ts +2 -0
  76. package/dist/src/utils.js +64 -0
  77. package/dist/src/workspace-component.d.ts +2 -0
  78. package/dist/src/workspace-component.js +34 -0
  79. package/dist/src/workspace-resource.d.ts +2 -0
  80. package/dist/src/workspace-resource.js +52 -0
  81. package/package.json +8 -3
@@ -1,54 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { ApiError, PermissionDeniedError } from "../client.js";
5
- import { hashComponentSource, hashStable, readComponentLock, sanitizeComponentSource, upsertComponentLockEntry, } from "../component-lock.js";
6
- import { buildSandboxDebugStreamUrl, buildSandboxDebugUrl, extractSandboxEndpoint, resolveProjectRoot, resolveProjectWorkspace, } from "../project.js";
4
+ import { randomUUID } from 'node:crypto';
5
+ import { ApiError, COMPONENT_LIST_PAGE_SIZE, PermissionDeniedError } from "../client.js";
6
+ import { getConfigDir } from "../config.js";
7
+ import { REVO_LOCK_FILE, buildPortableComponentUpdatePayload, componentLockMatchesIdentity, diffPortableComponentSource, getComponentLockEntry, getComponentLockEntries, hashComponentSource, hashPortableComponentSource, hashStable, readComponentLock, sanitizeComponentSource, upsertComponentLockEntry, writeComponentLock, } from "../component-lock.js";
8
+ import { buildSandboxDebugStreamUrl, buildSandboxDebugUrl, extractSandboxEndpoint, readProjectComponentIdentity, readProjectMetadata, resolveProjectRoot, resolveProjectWorkspace, } from "../project.js";
7
9
  import { isInteractiveTerminal, promptConfirm } from "../prompt.js";
10
+ import { readComponentIdentityValue, sanitizeUserMetadata, } from "../resource-metadata.js";
11
+ import { printSyncPlan, printSyncNotice, printSyncStatus, printSyncSummary, } from "../sync-output.js";
8
12
  import { deepClone, readBoolFlag, readFlag, readValues, sanitizeSegment, writeJsonFile } from "../utils.js";
13
+ import { hydrateWorkspaceComponentElementDetails } from "../workspace-component.js";
14
+ import { replaceWorkspaceResourceDirectory } from "../workspace-resource.js";
9
15
  const NULL_CATEGORY_FOLDER = '__no_category__';
10
- const COMPONENT_LIST_PAGE_SIZE = 200;
11
- const ACTIVE_COMPONENT_LIST_FILTER = {
12
- 'filter[and][0][field]': 'deletedAt',
13
- 'filter[and][0][op]': 'isNull',
14
- };
15
- const ANSI = {
16
- reset: '\u001b[0m',
17
- bold: '\u001b[1m',
18
- dim: '\u001b[2m',
19
- green: '\u001b[32m',
20
- yellow: '\u001b[33m',
21
- cyan: '\u001b[36m',
22
- blue: '\u001b[34m',
23
- magenta: '\u001b[35m',
24
- };
25
- function supportsColor() {
26
- return Boolean(process.stdout.isTTY || process.env.FORCE_COLOR);
27
- }
28
- function paint(text, code) {
29
- return supportsColor() ? `${code}${text}${ANSI.reset}` : text;
30
- }
31
- function paintStatus(status) {
32
- if (status === 'Skipped') {
33
- return paint(status.padEnd(9), ANSI.bold + ANSI.yellow);
34
- }
35
- if (status === 'Notice') {
36
- return paint(status.padEnd(9), ANSI.bold + ANSI.magenta);
37
- }
38
- return paint(status.padEnd(9), ANSI.bold + ANSI.green);
39
- }
40
- function paintPath(targetPath) {
41
- return paint(targetPath, ANSI.cyan);
42
- }
43
- function paintEngine() {
44
- return paint('RevoEngine', ANSI.bold + ANSI.blue);
45
- }
46
- function paintArrow(direction) {
47
- return paint(direction === 'pull' ? '->' : '<-', ANSI.bold + ANSI.magenta);
48
- }
49
- function paintReason(reason) {
50
- return paint(`(${reason})`, ANSI.dim + ANSI.yellow);
51
- }
52
16
  function rethrowComponentAccessError(error, action) {
53
17
  if (error instanceof PermissionDeniedError) {
54
18
  throw new Error(`Access denied (403). You are authenticated, but you do not have permission to ${action} components.`);
@@ -58,32 +22,6 @@ function rethrowComponentAccessError(error, action) {
58
22
  function componentWorkspacePath(cwd, manifestPath) {
59
23
  return path.relative(cwd, path.dirname(manifestPath)) || '.';
60
24
  }
61
- function printSyncStatus(println, input) {
62
- const suffix = input.reason ? ` ${paintReason(input.reason)}` : '';
63
- println(`${paintStatus(input.status)} ${paintEngine()} ${paintArrow(input.direction)} ${paintPath(input.targetPath)}${suffix}`);
64
- }
65
- function printNotice(println, message) {
66
- println(`${paintStatus('Notice')} ${message}`);
67
- }
68
- function formatDuration(durationMs) {
69
- if (durationMs < 1_000) {
70
- return `${durationMs}ms`;
71
- }
72
- const seconds = durationMs / 1_000;
73
- if (seconds < 10) {
74
- return `${seconds.toFixed(1)}s`;
75
- }
76
- return `${Math.round(seconds)}s`;
77
- }
78
- function printSummary(println, label, results, durationMs) {
79
- const primary = results.filter((result) => result.status === label.toLowerCase()).length;
80
- const skipped = results.filter((result) => result.status === 'skipped').length;
81
- const total = results.length;
82
- const summary = skipped > 0
83
- ? `${label} ${primary}/${total}, Skipped ${skipped}/${total} in ${formatDuration(durationMs)}`
84
- : `${label} ${primary}/${total} in ${formatDuration(durationMs)}`;
85
- println(summary);
86
- }
87
25
  function formatBulkCount(direction, count) {
88
26
  if (typeof count === 'number') {
89
27
  return direction === 'pull'
@@ -103,7 +41,7 @@ async function confirmBulkAction(context, direction, count, force) {
103
41
  if (!isInteractiveTerminal()) {
104
42
  throw new Error(`Bulk ${action} requires confirmation. Re-run with --force or use an interactive terminal.`);
105
43
  }
106
- printNotice(context.println, `About to ${action} ${subject}.`);
44
+ printSyncNotice(context.println, `About to ${action} ${subject}.`);
107
45
  const confirmed = await promptConfirm('Continue?', false);
108
46
  if (!confirmed) {
109
47
  throw new Error('Cancelled.');
@@ -120,27 +58,6 @@ function readWorkspaceComponentSafe(manifestPath) {
120
58
  return null;
121
59
  }
122
60
  }
123
- function findLocalManifestInfo(cwd, componentId) {
124
- const manifestPath = findComponentManifestsById(cwd, componentId)[0];
125
- if (!manifestPath) {
126
- return null;
127
- }
128
- try {
129
- const manifest = readWorkspaceManifest(manifestPath);
130
- return {
131
- manifestPath,
132
- targetPath: componentWorkspacePath(cwd, manifestPath),
133
- version: typeof manifest.version === 'number' ? manifest.version : null,
134
- };
135
- }
136
- catch {
137
- return {
138
- manifestPath,
139
- targetPath: componentWorkspacePath(cwd, manifestPath),
140
- version: null,
141
- };
142
- }
143
- }
144
61
  function isNotModifiedError(error) {
145
62
  if (!(error instanceof ApiError) || error.status !== 400) {
146
63
  return false;
@@ -198,7 +115,28 @@ function getWorkspaceRoot(cwd) {
198
115
  return path.join(projectWorkspace || cwd, 'Components');
199
116
  }
200
117
  function getProjectRoot(cwd) {
201
- return resolveProjectRoot(cwd) || cwd;
118
+ const configuredRoot = resolveProjectRoot(cwd);
119
+ if (configuredRoot) {
120
+ return configuredRoot;
121
+ }
122
+ let currentDir = path.resolve(cwd);
123
+ const { root } = path.parse(currentDir);
124
+ while (true) {
125
+ if (fs.existsSync(path.join(currentDir, REVO_LOCK_FILE))) {
126
+ return currentDir;
127
+ }
128
+ if (currentDir === root) {
129
+ return cwd;
130
+ }
131
+ currentDir = path.dirname(currentDir);
132
+ }
133
+ }
134
+ function resolveComponentIdentity(cwd) {
135
+ const metadata = readProjectMetadata(cwd);
136
+ if (metadata) {
137
+ return readProjectComponentIdentity(cwd);
138
+ }
139
+ return readComponentLock(getProjectRoot(cwd)).componentIdentity;
202
140
  }
203
141
  function getCategoryFolder(component) {
204
142
  if (component.category == null || component.category === '') {
@@ -247,36 +185,9 @@ function getComponentManifestPaths(root) {
247
185
  return walkFiles(root).filter((filePath) => filePath.endsWith('component.json'));
248
186
  }
249
187
  function readWorkspaceComponent(manifestPath) {
250
- const componentDir = path.dirname(manifestPath);
251
188
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
252
- const detailDirs = [
253
- path.join(componentDir, 'elements'),
254
- path.join(componentDir, 'details'),
255
- ].filter((dir, index, list) => list.indexOf(dir) === index);
256
189
  const extension = componentTypeToExtension(normalizeComponentType(manifest));
257
- manifest.elements = (manifest.elements || []).map((element) => {
258
- const fileName = `${element.order}_${element.key}.${extension}`;
259
- let details = '';
260
- for (const detailDir of detailDirs) {
261
- const preferredPath = path.join(detailDir, fileName);
262
- if (fs.existsSync(preferredPath)) {
263
- details = fs.readFileSync(preferredPath, 'utf8');
264
- break;
265
- }
266
- if (fs.existsSync(detailDir)) {
267
- const match = fs.readdirSync(detailDir).find((entry) => entry.startsWith(`${element.order}_${element.key}.`));
268
- if (match) {
269
- details = fs.readFileSync(path.join(detailDir, match), 'utf8');
270
- break;
271
- }
272
- }
273
- }
274
- return {
275
- ...element,
276
- details,
277
- };
278
- });
279
- return manifest;
190
+ return hydrateWorkspaceComponentElementDetails(manifestPath, manifest, extension);
280
191
  }
281
192
  function getComponentIdFromPath(componentPath) {
282
193
  const basename = path.basename(componentPath);
@@ -298,6 +209,18 @@ function findComponentManifestsById(cwd, componentId) {
298
209
  return getComponentIdFromPath(path.dirname(manifestPath)).includes(componentId);
299
210
  });
300
211
  }
212
+ function findComponentManifestsByTarget(cwd, target) {
213
+ const identity = resolveComponentIdentity(cwd);
214
+ const matches = findComponentManifestsById(cwd, target);
215
+ if (identity.mode !== 'stableKey') {
216
+ return matches;
217
+ }
218
+ const stableMatches = getComponentManifestPaths(getWorkspaceRoot(cwd)).filter((manifestPath) => {
219
+ const component = readWorkspaceComponentSafe(manifestPath);
220
+ return component && readComponentIdentityValue(component, identity) === target;
221
+ });
222
+ return [...new Set([...stableMatches, ...matches])];
223
+ }
301
224
  function isRelativeInside(parent, child) {
302
225
  const relative = path.relative(parent, child);
303
226
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
@@ -392,79 +315,6 @@ function unwrapList(value) {
392
315
  function isRecord(value) {
393
316
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
394
317
  }
395
- function readNumber(value) {
396
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
397
- }
398
- function resolveNextComponentListRequest(value) {
399
- if (typeof value === 'string' && value) {
400
- return {
401
- path: value,
402
- };
403
- }
404
- if (!isRecord(value)) {
405
- return null;
406
- }
407
- for (const key of ['path', 'url', 'href']) {
408
- if (typeof value[key] === 'string' && value[key]) {
409
- return {
410
- path: value[key],
411
- };
412
- }
413
- }
414
- const query = {};
415
- for (const key of ['cursor', 'page', 'skip', 'take', 'limit', 'offset']) {
416
- const candidate = value[key];
417
- if (typeof candidate === 'string' || typeof candidate === 'number') {
418
- query[key] = candidate;
419
- }
420
- }
421
- return Object.keys(query).length > 0 ? { query } : null;
422
- }
423
- function buildActiveComponentListQuery(skip, take = COMPONENT_LIST_PAGE_SIZE) {
424
- return {
425
- take,
426
- skip,
427
- count: true,
428
- ...ACTIVE_COMPONENT_LIST_FILTER,
429
- };
430
- }
431
- function withActiveComponentListFilter(request) {
432
- if (!request.query) {
433
- return request;
434
- }
435
- return {
436
- ...request,
437
- query: {
438
- ...request.query,
439
- count: request.query.count ?? true,
440
- ...ACTIVE_COMPONENT_LIST_FILTER,
441
- },
442
- };
443
- }
444
- function unwrapComponentListPage(value) {
445
- const items = unwrapList(value);
446
- if (!isRecord(value)) {
447
- return {
448
- items,
449
- total: null,
450
- nextRequest: null,
451
- };
452
- }
453
- const meta = isRecord(value.meta) ? value.meta : null;
454
- const pagination = isRecord(value.pagination) ? value.pagination : null;
455
- const total = readNumber(value.total)
456
- ?? readNumber(value.count)
457
- ?? (meta ? readNumber(meta.total) ?? readNumber(meta.count) : null)
458
- ?? (pagination ? readNumber(pagination.total) ?? readNumber(pagination.count) : null);
459
- const nextRequest = resolveNextComponentListRequest(value.next)
460
- ?? (meta ? resolveNextComponentListRequest(meta.next) : null)
461
- ?? (pagination ? resolveNextComponentListRequest(pagination.next) : null);
462
- return {
463
- items,
464
- total,
465
- nextRequest,
466
- };
467
- }
468
318
  function unwrapComponent(value) {
469
319
  if (value && typeof value === 'object' && !Array.isArray(value)) {
470
320
  const candidate = value;
@@ -478,11 +328,12 @@ function normalizeValue(value, fallback = null) {
478
328
  return value ?? fallback;
479
329
  }
480
330
  function normalizeElementContract(element) {
331
+ const order = Number.isFinite(Number(element.order)) ? Number(element.order) : 0;
481
332
  return {
482
- key: element.key,
333
+ key: typeof element.key === 'string' ? element.key : '',
483
334
  desc: normalizeValue(element.desc),
484
335
  hidden: Boolean(element.hidden),
485
- order: element.order,
336
+ order,
486
337
  details: element.details ?? '',
487
338
  };
488
339
  }
@@ -508,113 +359,430 @@ function componentIdOf(component) {
508
359
  function componentTargetPath(cwd, component) {
509
360
  return path.relative(cwd, path.join(getWorkspaceRoot(cwd), getComponentFolder(component)));
510
361
  }
511
- function upsertLockFromRemote(cwd, remote, targetPath) {
512
- const componentId = componentIdOf(remote);
513
- if (!componentId) {
362
+ function componentSourceHash(component, identity) {
363
+ return identity.mode === 'stableKey'
364
+ ? hashPortableComponentSource(component)
365
+ : hashComponentSource(component);
366
+ }
367
+ function writeComponentPushVerificationLog(input) {
368
+ const outputDir = path.join(input.cwd, '.revoengine', 'logs', 'component-push');
369
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
370
+ const identityKey = readComponentIdentityValue(input.desired, input.identity) || componentIdOf(input.desired);
371
+ const outputFile = path.join(outputDir, `${timestamp}-${sanitizeSegment(identityKey)}.json`);
372
+ const desiredHash = componentSourceHash(input.desired, input.identity);
373
+ const actualHash = componentSourceHash(input.actual, input.identity);
374
+ const portableDiff = diffPortableComponentSource(input.desired, input.actual);
375
+ writeJsonFile(outputFile, {
376
+ schemaVersion: 1,
377
+ kind: 'component-push-verification-failure',
378
+ generatedAt: new Date().toISOString(),
379
+ targetPath: input.targetPath,
380
+ ...(input.environment ? { environment: input.environment } : {}),
381
+ identity: input.identity,
382
+ hashes: { desired: desiredHash, actual: actualHash },
383
+ difference: portableDiff,
384
+ ...(input.operations ? { operations: input.operations } : {}),
385
+ ...(input.errorOperationId ? { errorOperationId: input.errorOperationId } : {}),
386
+ desired: input.desired,
387
+ actual: input.actual,
388
+ });
389
+ return path.relative(input.cwd, outputFile);
390
+ }
391
+ function describeComponentPushOperationError(error) {
392
+ if (error instanceof ApiError) {
393
+ return {
394
+ outcome: 'rejected',
395
+ name: error.name,
396
+ message: error.message,
397
+ status: error.status,
398
+ response: error.data,
399
+ };
400
+ }
401
+ return {
402
+ outcome: 'failed',
403
+ name: error instanceof Error ? error.name : 'Error',
404
+ message: error instanceof Error ? error.message : String(error),
405
+ };
406
+ }
407
+ function readRevoOperationId(headers) {
408
+ const value = headers?.get('x-revo-oid')?.trim();
409
+ return value || undefined;
410
+ }
411
+ function identityDescription(identity) {
412
+ return identity.mode === 'stableKey'
413
+ ? `metadata.${identity.metadataProperty}`
414
+ : 'componentId';
415
+ }
416
+ function addCatalogEntry(catalog, entry, source) {
417
+ const componentId = componentIdOf(entry.component);
418
+ if (componentId) {
419
+ const duplicateId = catalog.byComponentId.get(componentId);
420
+ if (duplicateId && duplicateId.manifestPath !== entry.manifestPath) {
421
+ throw new Error(`Duplicate ${source} componentId "${componentId}".`);
422
+ }
423
+ catalog.byComponentId.set(componentId, entry);
424
+ }
425
+ const identityKey = readComponentIdentityValue(entry.component, catalog.identity);
426
+ if (!identityKey) {
427
+ catalog.unmanaged.push(entry);
514
428
  return;
515
429
  }
516
- const remoteHash = hashComponentSource(remote);
430
+ const duplicate = catalog.byIdentity.get(identityKey);
431
+ if (duplicate && duplicate.component !== entry.component) {
432
+ const duplicateIds = [componentIdOf(duplicate.component), componentId]
433
+ .filter(Boolean)
434
+ .join(', ');
435
+ throw new Error(`Duplicate ${source} ${identityDescription(catalog.identity)} "${identityKey}"${duplicateIds ? ` (${duplicateIds})` : ''}.`);
436
+ }
437
+ catalog.byIdentity.set(identityKey, entry);
438
+ }
439
+ function createComponentCatalog(identity) {
440
+ return {
441
+ identity,
442
+ byIdentity: new Map(),
443
+ byComponentId: new Map(),
444
+ unmanaged: [],
445
+ };
446
+ }
447
+ function buildLocalComponentCatalog(cwd, identity) {
448
+ const catalog = createComponentCatalog(identity);
449
+ for (const manifestPath of getComponentManifestPaths(getWorkspaceRoot(cwd))) {
450
+ const component = readWorkspaceComponentSafe(manifestPath);
451
+ if (!component) {
452
+ continue;
453
+ }
454
+ addCatalogEntry(catalog, {
455
+ component,
456
+ hash: componentSourceHash(component, identity),
457
+ manifestPath,
458
+ }, 'local');
459
+ }
460
+ return catalog;
461
+ }
462
+ async function buildRemoteComponentCatalog(context, identity, summaries) {
463
+ const catalog = createComponentCatalog(identity);
464
+ const source = summaries || (await fetchAllComponentSummaries(context)).components;
465
+ for (const summary of source) {
466
+ const componentId = String(summary.componentId || summary.id || '');
467
+ if (!componentId) {
468
+ continue;
469
+ }
470
+ const component = unwrapComponent(await context.client.getComponent(componentId));
471
+ addCatalogEntry(catalog, {
472
+ component,
473
+ hash: componentSourceHash(component, identity),
474
+ }, 'remote');
475
+ }
476
+ return catalog;
477
+ }
478
+ function migrateComponentLockIdentity(cwd, identity, localCatalog, remoteCatalog) {
479
+ const projectRoot = getProjectRoot(cwd);
480
+ const lock = readComponentLock(projectRoot);
481
+ if (componentLockMatchesIdentity(lock, identity)) {
482
+ return lock;
483
+ }
484
+ const migratedComponents = {};
485
+ if (identity.mode === 'stableKey' && lock.componentIdentity.mode === 'componentId') {
486
+ const legacyEntries = Object.values(lock.components);
487
+ for (const [stableKey, remote] of remoteCatalog.byIdentity) {
488
+ const local = findLocalEntryForRemote(remote.component, localCatalog);
489
+ const localComponentId = local ? componentIdOf(local.component) : '';
490
+ const remoteComponentId = componentIdOf(remote.component);
491
+ const legacy = legacyEntries.find((entry) => (entry.componentId === localComponentId
492
+ || entry.remoteComponentId === localComponentId
493
+ || entry.componentId === remoteComponentId
494
+ || entry.remoteComponentId === remoteComponentId));
495
+ if (!legacy || !remoteComponentId) {
496
+ continue;
497
+ }
498
+ const remoteLegacyHash = hashComponentSource(remote.component);
499
+ const localLegacyHash = local ? hashComponentSource(local.component) : null;
500
+ let portableBaselineHash = null;
501
+ if (legacy.remoteHash === remoteLegacyHash) {
502
+ portableBaselineHash = remote.hash;
503
+ }
504
+ else if (local && legacy.remoteHash === localLegacyHash) {
505
+ portableBaselineHash = local.hash;
506
+ }
507
+ else if (local && local.hash === remote.hash) {
508
+ portableBaselineHash = remote.hash;
509
+ }
510
+ if (!portableBaselineHash) {
511
+ continue;
512
+ }
513
+ migratedComponents[stableKey] = {
514
+ identityMode: 'stableKey',
515
+ metadataProperty: identity.metadataProperty,
516
+ stableKey,
517
+ componentId: localComponentId || legacy.componentId || remoteComponentId,
518
+ remoteComponentId,
519
+ componentName: remote.component.name || local?.component.name || legacy.componentName,
520
+ category: normalizeValue(remote.component.category, normalizeValue(local?.component.category, legacy.category)),
521
+ path: local?.manifestPath
522
+ ? componentWorkspacePath(cwd, local.manifestPath)
523
+ : legacy.path || componentTargetPath(cwd, remote.component),
524
+ remoteVersion: legacy.remoteVersion,
525
+ remoteHash: portableBaselineHash,
526
+ sourceHash: local?.hash || portableBaselineHash,
527
+ pulledAt: legacy.pulledAt,
528
+ };
529
+ }
530
+ }
531
+ const migrated = {
532
+ schemaVersion: 2,
533
+ componentIdentity: identity,
534
+ components: migratedComponents,
535
+ groups: lock.groups,
536
+ roleGroups: lock.roleGroups,
537
+ jobTemplates: lock.jobTemplates,
538
+ schedules: lock.schedules,
539
+ events: lock.events,
540
+ endpoints: lock.endpoints,
541
+ databaseSchemas: lock.databaseSchemas,
542
+ databaseViews: lock.databaseViews,
543
+ };
544
+ writeComponentLock(projectRoot, migrated);
545
+ return migrated;
546
+ }
547
+ function findLocalEntryForRemote(remote, localCatalog) {
548
+ const identityKey = readComponentIdentityValue(remote, localCatalog.identity);
549
+ if (!identityKey) {
550
+ return null;
551
+ }
552
+ const identityMatch = localCatalog.byIdentity.get(identityKey);
553
+ if (identityMatch) {
554
+ return identityMatch;
555
+ }
556
+ if (localCatalog.identity.mode !== 'stableKey') {
557
+ return null;
558
+ }
559
+ const remoteComponentId = componentIdOf(remote);
560
+ const idMatch = remoteComponentId
561
+ ? localCatalog.byComponentId.get(remoteComponentId)
562
+ : null;
563
+ if (!idMatch) {
564
+ return null;
565
+ }
566
+ const localIdentityKey = readComponentIdentityValue(idMatch.component, localCatalog.identity);
567
+ if (localIdentityKey && localIdentityKey !== identityKey) {
568
+ throw new Error(`Cannot match component ${remoteComponentId}: local ${identityDescription(localCatalog.identity)} "${localIdentityKey}" does not match remote "${identityKey}".`);
569
+ }
570
+ return idMatch;
571
+ }
572
+ function upsertLockFromRemote(cwd, remote, targetPath, local, environmentName) {
573
+ const identity = resolveComponentIdentity(cwd);
574
+ const remoteComponentId = componentIdOf(remote);
575
+ const localComponentId = componentIdOf(local || remote);
576
+ const identityKey = readComponentIdentityValue(remote, identity);
577
+ if (!remoteComponentId || !localComponentId || !identityKey) {
578
+ return;
579
+ }
580
+ const remoteHash = componentSourceHash(remote, identity);
517
581
  upsertComponentLockEntry(getProjectRoot(cwd), {
518
- componentId,
519
- componentName: remote.name || '',
520
- category: normalizeValue(remote.category),
521
- path: targetPath || componentTargetPath(cwd, remote),
582
+ identityMode: identity.mode,
583
+ ...(identity.mode === 'stableKey'
584
+ ? {
585
+ metadataProperty: identity.metadataProperty,
586
+ stableKey: identityKey,
587
+ }
588
+ : {}),
589
+ componentId: localComponentId,
590
+ remoteComponentId,
591
+ componentName: remote.name || local?.name || '',
592
+ category: normalizeValue(remote.category, normalizeValue(local?.category)),
593
+ path: targetPath || componentTargetPath(cwd, local || remote),
522
594
  remoteVersion: componentVersion(remote),
523
595
  remoteHash,
524
- sourceHash: remoteHash,
596
+ sourceHash: local ? componentSourceHash(local, identity) : remoteHash,
525
597
  pulledAt: new Date().toISOString(),
526
- });
598
+ }, identity, environmentName);
527
599
  }
528
- function buildPullDecision(cwd, remote) {
600
+ function buildPullDecision(cwd, remote, localCatalog = buildLocalComponentCatalog(cwd, resolveComponentIdentity(cwd)), environmentName) {
601
+ const identity = localCatalog.identity;
529
602
  const remoteTargetPath = componentTargetPath(cwd, remote);
530
- const componentId = componentIdOf(remote);
531
- const localInfo = componentId ? findLocalManifestInfo(cwd, componentId) : null;
532
- if (!localInfo?.manifestPath) {
603
+ const identityKey = readComponentIdentityValue(remote, identity);
604
+ if (!identityKey) {
605
+ return {
606
+ kind: 'skip',
607
+ targetPath: remoteTargetPath,
608
+ reason: `missing ${identityDescription(identity)}; unmanaged`,
609
+ };
610
+ }
611
+ const local = findLocalEntryForRemote(remote, localCatalog);
612
+ if (!local?.manifestPath) {
533
613
  return {
534
614
  kind: 'pull',
535
615
  targetPath: remoteTargetPath,
536
616
  };
537
617
  }
538
- const localComponent = readWorkspaceComponentSafe(localInfo.manifestPath);
539
- if (!localComponent) {
618
+ const targetPath = componentWorkspacePath(cwd, local.manifestPath);
619
+ const lockEntry = getComponentLockEntry(readComponentLock(getProjectRoot(cwd)), identity, remote, environmentName);
620
+ const localHash = local.hash;
621
+ const remoteHash = componentSourceHash(remote, identity);
622
+ const base = {
623
+ targetPath,
624
+ manifestPath: local.manifestPath,
625
+ localComponent: local.component,
626
+ };
627
+ if (localHash === remoteHash) {
540
628
  return {
541
629
  kind: 'skip',
542
- targetPath: localInfo.targetPath || remoteTargetPath,
543
- reason: 'changed',
630
+ ...base,
631
+ reason: 'no changes',
544
632
  };
545
633
  }
546
- const lockEntry = readComponentLock(getProjectRoot(cwd)).components[componentId];
547
- const localHash = hashComponentSource(localComponent);
548
- const remoteHash = hashComponentSource(remote);
549
634
  if (!lockEntry) {
550
- if (localHash === remoteHash) {
551
- return {
552
- kind: 'skip',
553
- targetPath: localInfo.targetPath || remoteTargetPath,
554
- reason: 'no changes',
555
- };
556
- }
557
635
  return {
558
636
  kind: 'skip',
559
- targetPath: localInfo.targetPath || remoteTargetPath,
637
+ ...base,
560
638
  reason: 'missing lock',
561
639
  };
562
640
  }
563
641
  if (localHash === lockEntry.remoteHash && remoteHash !== lockEntry.remoteHash) {
564
642
  return {
565
643
  kind: 'pull',
566
- targetPath: localInfo.targetPath || remoteTargetPath,
644
+ ...base,
567
645
  reason: 'remote changed',
568
646
  };
569
647
  }
570
648
  if (localHash !== lockEntry.remoteHash && remoteHash === lockEntry.remoteHash) {
571
649
  return {
572
650
  kind: 'skip',
573
- targetPath: localInfo.targetPath || remoteTargetPath,
651
+ ...base,
574
652
  reason: 'local changes',
575
653
  };
576
654
  }
577
655
  if (localHash !== lockEntry.remoteHash && remoteHash !== lockEntry.remoteHash && localHash !== remoteHash) {
578
656
  return {
579
657
  kind: 'skip',
580
- targetPath: localInfo.targetPath || remoteTargetPath,
658
+ ...base,
581
659
  reason: 'conflict',
582
660
  };
583
661
  }
584
- if (isSameComponentContract(localComponent, remote)) {
662
+ if (identity.mode === 'componentId' && isSameComponentContract(local.component, remote)) {
585
663
  return {
586
664
  kind: 'skip',
587
- targetPath: localInfo.targetPath || remoteTargetPath,
665
+ ...base,
588
666
  reason: 'no changes',
589
667
  };
590
668
  }
591
669
  return {
592
670
  kind: 'skip',
593
- targetPath: localInfo.targetPath || remoteTargetPath,
671
+ ...base,
594
672
  reason: 'changed',
595
673
  };
596
674
  }
675
+ function syncLocalIdentityMetadata(manifestPath, remote, identity) {
676
+ if (identity.mode !== 'stableKey' || !manifestPath) {
677
+ return false;
678
+ }
679
+ const remoteStableKey = readComponentIdentityValue(remote, identity);
680
+ if (!remoteStableKey) {
681
+ return false;
682
+ }
683
+ let manifest;
684
+ try {
685
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
686
+ }
687
+ catch {
688
+ return false;
689
+ }
690
+ const localStableKey = readComponentIdentityValue(manifest, identity);
691
+ if (localStableKey) {
692
+ if (localStableKey !== remoteStableKey) {
693
+ throw new Error(`Cannot pull ${componentIdOf(remote)}: local ${identityDescription(identity)} "${localStableKey}" does not match remote "${remoteStableKey}".`);
694
+ }
695
+ return false;
696
+ }
697
+ const metadata = isRecord(manifest.metadata) ? deepClone(manifest.metadata) : {};
698
+ manifest.metadata = {
699
+ ...metadata,
700
+ [identity.metadataProperty]: remoteStableKey,
701
+ };
702
+ writeJsonFile(manifestPath, manifest);
703
+ return true;
704
+ }
705
+ function buildPulledManifest(remote, local, identity) {
706
+ const manifest = stripDetails(remote);
707
+ if (identity.mode !== 'stableKey' || !local) {
708
+ return manifest;
709
+ }
710
+ const localComponentId = componentIdOf(local);
711
+ if (localComponentId) {
712
+ manifest.componentId = localComponentId;
713
+ delete manifest.id;
714
+ }
715
+ const localElementsByKey = new Map((local.elements || []).map((element) => [element.key, element]));
716
+ manifest.elements = (manifest.elements || []).map((element) => {
717
+ const localElementId = localElementsByKey.get(element.key)?.componentElementId;
718
+ return {
719
+ ...element,
720
+ ...(localElementId ? { componentElementId: localElementId } : {}),
721
+ };
722
+ });
723
+ return manifest;
724
+ }
597
725
  async function confirmSingleStalePull(context, targetPath) {
598
726
  if (!isInteractiveTerminal()) {
599
727
  throw new Error(`Local component is stale at ${targetPath}. Re-run with --stale or --force, or use an interactive terminal to confirm overwrite.`);
600
728
  }
601
- printNotice(context.println, `Local component is stale at ${targetPath}.`);
729
+ printSyncNotice(context.println, `Local component is stale at ${targetPath}.`);
602
730
  return promptConfirm('Overwrite local component with the remote version?', false);
603
731
  }
604
- async function pullSingleComponent(context, componentId, mode = { force: false, stale: false }) {
605
- const { client, cwd, println } = context;
606
- let component = null;
607
- try {
608
- component = unwrapComponent(await client.getComponent(componentId));
732
+ async function resolveRemoteComponentForTarget(context, target, identity, remoteCatalog) {
733
+ const localManifestPath = findComponentManifestsById(context.cwd, target)[0];
734
+ const local = localManifestPath ? readWorkspaceComponentSafe(localManifestPath) : null;
735
+ const localIdentityKey = local ? readComponentIdentityValue(local, identity) : null;
736
+ let mismatchedDirectIdentity = null;
737
+ if (identity.mode === 'componentId' && !remoteCatalog) {
738
+ try {
739
+ return unwrapComponent(await context.client.getComponent(target));
740
+ }
741
+ catch (error) {
742
+ if (error instanceof ApiError && error.status === 404) {
743
+ return null;
744
+ }
745
+ rethrowComponentAccessError(error, 'pull');
746
+ }
609
747
  }
610
- catch (error) {
611
- rethrowComponentAccessError(error, 'pull');
748
+ if (!remoteCatalog) {
749
+ try {
750
+ const direct = unwrapComponent(await context.client.getComponent(target));
751
+ const directIdentityKey = direct ? readComponentIdentityValue(direct, identity) : null;
752
+ if (directIdentityKey && (!localIdentityKey || localIdentityKey === directIdentityKey)) {
753
+ return direct;
754
+ }
755
+ if (directIdentityKey && localIdentityKey && localIdentityKey !== directIdentityKey) {
756
+ mismatchedDirectIdentity = directIdentityKey;
757
+ }
758
+ }
759
+ catch (error) {
760
+ if (!(error instanceof ApiError && error.status === 404)) {
761
+ rethrowComponentAccessError(error, 'pull');
762
+ }
763
+ }
612
764
  }
613
- if (!component) {
765
+ const catalog = remoteCatalog || await buildRemoteComponentCatalog(context, identity);
766
+ const resolved = localIdentityKey
767
+ ? catalog.byIdentity.get(localIdentityKey)?.component || null
768
+ : catalog.byIdentity.get(target)?.component
769
+ || catalog.byComponentId.get(target)?.component
770
+ || null;
771
+ if (!resolved && mismatchedDirectIdentity && localIdentityKey) {
772
+ throw new Error(`Cannot match component ${target}: local ${identityDescription(identity)} "${localIdentityKey}" does not match remote "${mismatchedDirectIdentity}".`);
773
+ }
774
+ return resolved;
775
+ }
776
+ async function pullResolvedComponent(context, component, mode, localCatalog) {
777
+ const { cwd, println } = context;
778
+ const identity = resolveComponentIdentity(cwd);
779
+ const componentId = componentIdOf(component);
780
+ const decision = buildPullDecision(cwd, component, localCatalog || buildLocalComponentCatalog(cwd, identity), context.componentEnvironment);
781
+ if (decision.kind === 'skip' && decision.reason.includes('; unmanaged')) {
614
782
  const result = {
615
783
  status: 'skipped',
616
- targetPath: componentId,
617
- reason: 'component not found',
784
+ targetPath: decision.targetPath,
785
+ reason: decision.reason,
618
786
  };
619
787
  printSyncStatus(println, {
620
788
  status: 'Skipped',
@@ -624,7 +792,6 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
624
792
  });
625
793
  return result;
626
794
  }
627
- const decision = buildPullDecision(cwd, component);
628
795
  if (decision.kind === 'skip' && !mode.force) {
629
796
  if (decision.reason === 'stale version') {
630
797
  if (mode.stale) {
@@ -666,16 +833,21 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
666
833
  if (decision.reason === 'conflict' || decision.reason === 'missing lock') {
667
834
  throw new Error(`Cannot pull ${componentId}: ${decision.reason} at ${decision.targetPath}. Re-run with --force only if you want to overwrite local files.`);
668
835
  }
836
+ let metadataUpdated = false;
669
837
  if (decision.reason === 'no changes') {
670
- upsertLockFromRemote(cwd, component, decision.targetPath);
838
+ metadataUpdated = syncLocalIdentityMetadata(decision.manifestPath, component, identity);
839
+ const localComponent = decision.manifestPath
840
+ ? readWorkspaceComponentSafe(decision.manifestPath) || decision.localComponent
841
+ : decision.localComponent;
842
+ upsertLockFromRemote(cwd, component, decision.targetPath, localComponent, context.componentEnvironment);
671
843
  }
672
844
  const result = {
673
- status: 'skipped',
845
+ status: metadataUpdated ? 'pulled' : 'skipped',
674
846
  targetPath: decision.targetPath,
675
- reason: decision.reason,
847
+ reason: metadataUpdated ? 'stable-key metadata' : decision.reason,
676
848
  };
677
849
  printSyncStatus(println, {
678
- status: 'Skipped',
850
+ status: metadataUpdated ? 'Pulled' : 'Skipped',
679
851
  direction: 'pull',
680
852
  targetPath: result.targetPath,
681
853
  reason: result.reason,
@@ -684,18 +856,30 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
684
856
  }
685
857
  }
686
858
  const root = getWorkspaceRoot(cwd);
687
- const folder = path.join(root, getComponentFolder(component));
859
+ const folder = decision.manifestPath
860
+ ? path.dirname(decision.manifestPath)
861
+ : path.join(root, getComponentFolder(component));
688
862
  const targetPath = path.relative(cwd, folder);
689
- const elementsDir = path.join(folder, 'elements');
690
- fs.mkdirSync(elementsDir, { recursive: true });
691
- const extension = getDetailsExtension(component);
692
- for (const element of component.elements || []) {
693
- const content = element.details ?? element.logic ?? '';
694
- const filePath = path.join(elementsDir, `${element.order}_${element.key}.${extension}`);
695
- fs.writeFileSync(filePath, content);
696
- }
697
- writeJsonFile(path.join(folder, 'component.json'), stripDetails(component));
698
- upsertLockFromRemote(cwd, component, targetPath);
863
+ const populate = (directory) => {
864
+ const elementsDir = path.join(directory, 'elements');
865
+ fs.mkdirSync(elementsDir, { recursive: true });
866
+ const extension = getDetailsExtension(component);
867
+ for (const element of component.elements || []) {
868
+ const content = element.details ?? element.logic ?? '';
869
+ const filePath = path.join(elementsDir, `${element.order}_${element.key}.${extension}`);
870
+ fs.writeFileSync(filePath, content);
871
+ }
872
+ writeJsonFile(path.join(directory, 'component.json'), buildPulledManifest(component, decision.localComponent, identity));
873
+ };
874
+ if (mode.force) {
875
+ replaceWorkspaceResourceDirectory(root, folder, populate);
876
+ }
877
+ else {
878
+ populate(folder);
879
+ }
880
+ const manifestPath = path.join(folder, 'component.json');
881
+ const localComponent = readWorkspaceComponentSafe(manifestPath) || decision.localComponent;
882
+ upsertLockFromRemote(cwd, component, targetPath, localComponent, context.componentEnvironment);
699
883
  const result = {
700
884
  status: 'pulled',
701
885
  targetPath,
@@ -711,58 +895,40 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
711
895
  });
712
896
  return result;
713
897
  }
898
+ async function pullSingleComponent(context, target, mode = { force: false, stale: false }) {
899
+ const identity = resolveComponentIdentity(context.cwd);
900
+ let remoteCatalog;
901
+ let localCatalog;
902
+ if (identity.mode === 'stableKey'
903
+ && !componentLockMatchesIdentity(readComponentLock(getProjectRoot(context.cwd)), identity)) {
904
+ remoteCatalog = await buildRemoteComponentCatalog(context, identity);
905
+ localCatalog = buildLocalComponentCatalog(context.cwd, identity);
906
+ migrateComponentLockIdentity(context.cwd, identity, localCatalog, remoteCatalog);
907
+ }
908
+ const component = await resolveRemoteComponentForTarget(context, target, identity, remoteCatalog);
909
+ if (!component) {
910
+ const result = {
911
+ status: 'skipped',
912
+ targetPath: target,
913
+ reason: 'component not found',
914
+ };
915
+ printSyncStatus(context.println, {
916
+ status: 'Skipped',
917
+ direction: 'pull',
918
+ targetPath: result.targetPath,
919
+ reason: result.reason,
920
+ });
921
+ return result;
922
+ }
923
+ return pullResolvedComponent(context, component, mode, localCatalog);
924
+ }
714
925
  async function fetchAllComponentSummaries(context) {
715
- const { client, cwd } = context;
716
- const components = [];
717
- const seenRequests = new Set();
718
- let nextRequest = {
719
- query: buildActiveComponentListQuery(0),
720
- };
721
- let discoveredTotal = null;
722
- while (nextRequest) {
723
- const requestKey = JSON.stringify(nextRequest);
724
- if (seenRequests.has(requestKey)) {
725
- throw new Error('Component list pagination loop detected while pulling all components.');
726
- }
727
- seenRequests.add(requestKey);
728
- let pageValue;
729
- try {
730
- pageValue = await client.listComponents(nextRequest);
731
- }
732
- catch (error) {
733
- rethrowComponentAccessError(error, 'list');
734
- }
735
- const page = unwrapComponentListPage(pageValue);
736
- if (page.total !== null) {
737
- discoveredTotal = page.total;
738
- }
739
- for (const item of page.items) {
740
- if (isRecord(item)) {
741
- components.push(item);
742
- }
743
- }
744
- if (page.total !== null && components.length >= page.total) {
745
- nextRequest = null;
746
- continue;
747
- }
748
- if (page.nextRequest) {
749
- nextRequest = withActiveComponentListFilter(page.nextRequest);
750
- continue;
751
- }
752
- if (nextRequest.query
753
- && typeof nextRequest.query.take === 'number'
754
- && page.items.length === nextRequest.query.take) {
755
- nextRequest = {
756
- query: buildActiveComponentListQuery(components.length, nextRequest.query.take),
757
- };
758
- continue;
759
- }
760
- nextRequest = null;
926
+ try {
927
+ return await context.client.listAllComponents();
761
928
  }
762
- if (components.length === 0) {
763
- return { components, discoveredTotal };
929
+ catch (error) {
930
+ rethrowComponentAccessError(error, 'list');
764
931
  }
765
- return { components, discoveredTotal };
766
932
  }
767
933
  async function pullAllComponents(context, options) {
768
934
  const { components, discoveredTotal } = await fetchAllComponentSummaries(context);
@@ -770,79 +936,212 @@ async function pullAllComponents(context, options) {
770
936
  context.println('No components found.');
771
937
  return [];
772
938
  }
773
- await confirmBulkAction(context, 'pull', discoveredTotal ?? (components.length >= COMPONENT_LIST_PAGE_SIZE ? `at least ${components.length} components` : components.length), options.force || options.yes);
939
+ const identity = resolveComponentIdentity(context.cwd);
940
+ const remoteCatalog = await buildRemoteComponentCatalog(context, identity, components);
941
+ const managedCount = remoteCatalog.byIdentity.size;
942
+ if (managedCount === 0) {
943
+ context.println(`No components managed by ${identityDescription(identity)} were found.`);
944
+ return [];
945
+ }
946
+ await confirmBulkAction(context, 'pull', identity.mode === 'componentId'
947
+ ? discoveredTotal ?? (components.length >= COMPONENT_LIST_PAGE_SIZE ? `at least ${components.length} components` : components.length)
948
+ : managedCount, options.force || options.yes);
774
949
  const startedAt = Date.now();
775
950
  const results = [];
776
- for (const item of components) {
777
- const componentId = item.componentId || item.id;
778
- if (!componentId) {
779
- continue;
780
- }
781
- const result = await pullSingleComponent(context, String(componentId), {
951
+ const localCatalog = buildLocalComponentCatalog(context.cwd, identity);
952
+ migrateComponentLockIdentity(context.cwd, identity, localCatalog, remoteCatalog);
953
+ for (const entry of remoteCatalog.byComponentId.values()) {
954
+ const result = await pullResolvedComponent(context, entry.component, {
782
955
  force: options.force,
783
956
  stale: options.stale,
784
957
  promptOnStale: false,
785
- });
958
+ }, localCatalog);
786
959
  if (result) {
787
960
  results.push(result);
788
961
  }
789
962
  }
790
- printSummary(context.println, 'Pulled', results, Date.now() - startedAt);
963
+ printSyncSummary(context.println, 'Pulled', results, Date.now() - startedAt);
791
964
  return results;
792
965
  }
793
- async function pushSingleComponent(context, manifestPath) {
966
+ async function resolveRemoteComponentForLocal(context, component, identity, remoteCatalog) {
967
+ const localComponentId = componentIdOf(component);
968
+ if (identity.mode === 'componentId') {
969
+ if (!localComponentId) {
970
+ return null;
971
+ }
972
+ if (typeof context.client.getComponent !== 'function') {
973
+ return component;
974
+ }
975
+ try {
976
+ return unwrapComponent(await context.client.getComponent(localComponentId));
977
+ }
978
+ catch (error) {
979
+ if (error instanceof ApiError && error.status === 404) {
980
+ return null;
981
+ }
982
+ rethrowComponentAccessError(error, 'push');
983
+ }
984
+ }
985
+ const identityKey = readComponentIdentityValue(component, identity);
986
+ if (!identityKey) {
987
+ return null;
988
+ }
989
+ const catalog = remoteCatalog || await buildRemoteComponentCatalog(context, identity);
990
+ return catalog.byIdentity.get(identityKey)?.component || null;
991
+ }
992
+ function buildPushElements(component, remote, identity) {
993
+ const remoteElementsByKey = new Map((remote.elements || []).map((element) => [element.key, element]));
994
+ return (component.elements || []).map((element) => {
995
+ const targetElementId = identity.mode === 'stableKey'
996
+ ? remoteElementsByKey.get(element.key)?.componentElementId
997
+ : element.componentElementId;
998
+ return {
999
+ ...(typeof targetElementId === 'string' && targetElementId
1000
+ ? { componentElementId: targetElementId }
1001
+ : {}),
1002
+ key: element.key,
1003
+ desc: element.desc,
1004
+ details: element.details || '',
1005
+ hidden: Boolean(element.hidden),
1006
+ order: element.order,
1007
+ };
1008
+ });
1009
+ }
1010
+ function buildComponentCreatePayload(component, identity) {
1011
+ return {
1012
+ name: component.name || '',
1013
+ category: component.category ?? null,
1014
+ desc: component.desc ?? null,
1015
+ type: normalizeComponentType(component),
1016
+ compiler: compilerFromType(component.type, component.compiler),
1017
+ active: component.active ?? true,
1018
+ async: Boolean(component.async),
1019
+ metadata: sanitizeUserMetadata(component.metadata),
1020
+ elements: buildPushElements(component, { elements: [] }, identity),
1021
+ };
1022
+ }
1023
+ async function pushSingleComponent(context, manifestPath, remoteCatalog) {
794
1024
  const { client, println } = context;
795
1025
  const component = readWorkspaceComponent(manifestPath);
796
- const componentId = component.componentId || component.id;
1026
+ const localComponentId = componentIdOf(component);
797
1027
  const targetPath = componentWorkspacePath(context.cwd, manifestPath);
798
- if (!componentId) {
1028
+ const identity = resolveComponentIdentity(context.cwd);
1029
+ const identityKey = readComponentIdentityValue(component, identity);
1030
+ if (!localComponentId) {
799
1031
  throw new Error(`Missing componentId in ${manifestPath}.`);
800
1032
  }
801
1033
  if (!component.name) {
802
1034
  throw new Error(`Missing component name in ${manifestPath}.`);
803
1035
  }
804
- const elements = (component.elements || []).map((element) => ({
805
- ...(typeof element.componentElementId === 'string' && element.componentElementId
806
- ? { componentElementId: element.componentElementId }
807
- : {}),
808
- key: element.key,
809
- desc: element.desc,
810
- details: element.details || '',
811
- hidden: Boolean(element.hidden),
812
- order: element.order,
813
- }));
1036
+ if (!identityKey) {
1037
+ const result = {
1038
+ status: 'skipped',
1039
+ targetPath,
1040
+ reason: `missing ${identityDescription(identity)}; unmanaged`,
1041
+ };
1042
+ printSyncStatus(println, {
1043
+ status: 'Skipped',
1044
+ direction: 'push',
1045
+ targetPath,
1046
+ reason: result.reason,
1047
+ });
1048
+ return result;
1049
+ }
1050
+ let resolvedRemoteCatalog = remoteCatalog;
1051
+ if (identity.mode === 'stableKey'
1052
+ && !componentLockMatchesIdentity(readComponentLock(getProjectRoot(context.cwd)), identity)) {
1053
+ resolvedRemoteCatalog = resolvedRemoteCatalog || await buildRemoteComponentCatalog(context, identity);
1054
+ migrateComponentLockIdentity(context.cwd, identity, buildLocalComponentCatalog(context.cwd, identity), resolvedRemoteCatalog);
1055
+ }
814
1056
  try {
815
- if (!readBoolFlag(context.args, ['force', 'f'])) {
816
- let remote = null;
817
- try {
818
- remote = unwrapComponent(await client.getComponent(componentId));
1057
+ const remote = await resolveRemoteComponentForLocal(context, component, identity, resolvedRemoteCatalog);
1058
+ if (!remote) {
1059
+ if (context.componentEnvironment) {
1060
+ const created = unwrapComponent(await client.createComponent(buildComponentCreatePayload(component, identity)));
1061
+ const createdComponentId = componentIdOf(created);
1062
+ if (!createdComponentId) {
1063
+ throw new Error(`Cannot push ${targetPath}: create response did not include componentId.`);
1064
+ }
1065
+ await client.saveComponentElements(createdComponentId, buildPushElements(component, { elements: [] }, identity));
1066
+ const refreshedRemote = unwrapComponent(await client.getComponent(createdComponentId));
1067
+ if (componentSourceHash(refreshedRemote, identity) !== componentSourceHash(component, identity)) {
1068
+ const logPath = writeComponentPushVerificationLog({
1069
+ cwd: context.cwd,
1070
+ targetPath,
1071
+ environment: context.componentEnvironment,
1072
+ identity,
1073
+ desired: component,
1074
+ actual: refreshedRemote,
1075
+ });
1076
+ throw new Error(`Cannot push ${targetPath}: created component did not verify against local source. Diagnostic: ${logPath}`);
1077
+ }
1078
+ upsertLockFromRemote(context.cwd, refreshedRemote, targetPath, component, context.componentEnvironment);
1079
+ const result = { status: 'deployed', targetPath };
1080
+ printSyncStatus(println, { status: 'Deployed', direction: 'push', targetPath });
1081
+ return result;
819
1082
  }
820
- catch (error) {
821
- if (error instanceof ApiError && error.status === 404) {
1083
+ const result = {
1084
+ status: 'skipped',
1085
+ targetPath,
1086
+ reason: "doesn't exist remotely",
1087
+ };
1088
+ printSyncStatus(println, {
1089
+ status: 'Skipped',
1090
+ direction: 'push',
1091
+ targetPath,
1092
+ reason: result.reason,
1093
+ });
1094
+ return result;
1095
+ }
1096
+ const remoteComponentId = componentIdOf(remote);
1097
+ if (!remoteComponentId) {
1098
+ throw new Error(`Remote component matched by ${identityDescription(identity)} "${identityKey}" has no componentId.`);
1099
+ }
1100
+ if (!readBoolFlag(context.args, ['force', 'f'])) {
1101
+ const lockEntry = getComponentLockEntry(readComponentLock(getProjectRoot(context.cwd)), identity, component, context.componentEnvironment);
1102
+ if (!lockEntry) {
1103
+ const remoteHash = componentSourceHash(remote, identity);
1104
+ const localHash = componentSourceHash(component, identity);
1105
+ if (context.componentEnvironment && localHash === remoteHash) {
1106
+ upsertLockFromRemote(context.cwd, remote, targetPath, component, context.componentEnvironment);
822
1107
  const result = {
823
1108
  status: 'skipped',
824
1109
  targetPath,
825
- reason: "doesn't exist remotely",
1110
+ reason: 'local matches remote; target baseline adopted',
826
1111
  };
827
1112
  printSyncStatus(println, {
828
1113
  status: 'Skipped',
829
1114
  direction: 'push',
830
- targetPath,
1115
+ targetPath: result.targetPath,
831
1116
  reason: result.reason,
832
1117
  });
833
1118
  return result;
834
1119
  }
835
- rethrowComponentAccessError(error, 'push');
1120
+ throw new Error(`Cannot push ${targetPath}: missing lock entry. Run \`revo component pull ${identityKey} --force\` to establish a remote baseline.`);
836
1121
  }
837
- const lockEntry = readComponentLock(getProjectRoot(context.cwd)).components[componentId];
838
- if (!lockEntry) {
839
- throw new Error(`Cannot push ${targetPath}: missing lock entry. Run \`revo component pull ${componentId} --force\` to establish a remote baseline.`);
1122
+ if (lockEntry.remoteComponentId !== remoteComponentId) {
1123
+ throw new Error(`Cannot push ${targetPath}: ${identityDescription(identity)} "${identityKey}" now resolves to remote component ${remoteComponentId}, but the lock references ${lockEntry.remoteComponentId}. Pull/reconcile first.`);
840
1124
  }
841
- const remoteHash = hashComponentSource(remote);
1125
+ const remoteHash = componentSourceHash(remote, identity);
1126
+ const localHash = componentSourceHash(component, identity);
842
1127
  if (remoteHash !== lockEntry.remoteHash) {
1128
+ if (localHash === remoteHash) {
1129
+ upsertLockFromRemote(context.cwd, remote, targetPath, component, context.componentEnvironment);
1130
+ const result = {
1131
+ status: 'skipped',
1132
+ targetPath,
1133
+ reason: 'local matches remote; lock refreshed',
1134
+ };
1135
+ printSyncStatus(println, {
1136
+ status: 'Skipped',
1137
+ direction: 'push',
1138
+ targetPath,
1139
+ reason: result.reason,
1140
+ });
1141
+ return result;
1142
+ }
843
1143
  throw new Error(`Cannot push ${targetPath}: remote changed since the last lock baseline. Run \`revo component plan --all\` and pull/reconcile first.`);
844
1144
  }
845
- const localHash = hashComponentSource(component);
846
1145
  if (localHash === lockEntry.remoteHash) {
847
1146
  const result = {
848
1147
  status: 'skipped',
@@ -858,20 +1157,98 @@ async function pushSingleComponent(context, manifestPath) {
858
1157
  return result;
859
1158
  }
860
1159
  }
861
- const response = await client.saveComponentElements(componentId, elements);
862
- if (response.status === 304) {
1160
+ const hasLiveRemote = typeof client.getComponent === 'function';
1161
+ const portableDiff = hasLiveRemote
1162
+ ? diffPortableComponentSource(component, remote)
1163
+ : { configurationFields: [], elementsChanged: true, changedFields: ['elements'] };
1164
+ if (portableDiff.changedFields.length === 0) {
863
1165
  const result = {
864
1166
  status: 'skipped',
865
1167
  targetPath,
866
1168
  reason: 'no changes',
867
1169
  };
1170
+ upsertLockFromRemote(context.cwd, remote, targetPath, component, context.componentEnvironment);
1171
+ printSyncStatus(println, {
1172
+ status: 'Skipped',
1173
+ direction: 'push',
1174
+ targetPath,
1175
+ reason: result.reason,
1176
+ });
1177
+ return result;
1178
+ }
1179
+ let configurationChanged = false;
1180
+ const operations = {};
1181
+ let errorOperationId;
1182
+ if (portableDiff.configurationFields.length > 0) {
868
1183
  try {
869
- const remote = unwrapComponent(await client.getComponent(componentId));
870
- upsertLockFromRemote(context.cwd, remote, targetPath);
1184
+ await client.updateComponent(remoteComponentId, buildPortableComponentUpdatePayload(component, remote, portableDiff.configurationFields));
1185
+ configurationChanged = true;
1186
+ operations.configurationUpdate = { outcome: 'accepted' };
871
1187
  }
872
- catch {
873
- upsertLockFromRemote(context.cwd, component, targetPath);
1188
+ catch (error) {
1189
+ operations.configurationUpdate = describeComponentPushOperationError(error);
1190
+ if (error instanceof ApiError) {
1191
+ errorOperationId = readRevoOperationId(error.headers) || errorOperationId;
1192
+ }
1193
+ if (!isNotModifiedError(error)) {
1194
+ throw error;
1195
+ }
874
1196
  }
1197
+ }
1198
+ let elementsChanged = false;
1199
+ if (portableDiff.elementsChanged) {
1200
+ try {
1201
+ const elements = buildPushElements(component, remote, identity);
1202
+ const response = await client.saveComponentElements(remoteComponentId, elements);
1203
+ elementsChanged = response.status !== 304;
1204
+ const operationId = readRevoOperationId(response.headers);
1205
+ errorOperationId = operationId || errorOperationId;
1206
+ operations.elementsSave = {
1207
+ outcome: 'accepted',
1208
+ status: response.status,
1209
+ ok: response.ok,
1210
+ response: response.data,
1211
+ ...(operationId ? { errorOperationId: operationId } : {}),
1212
+ };
1213
+ }
1214
+ catch (error) {
1215
+ operations.elementsSave = describeComponentPushOperationError(error);
1216
+ if (error instanceof ApiError) {
1217
+ errorOperationId = readRevoOperationId(error.headers) || errorOperationId;
1218
+ }
1219
+ if (!isNotModifiedError(error)) {
1220
+ throw error;
1221
+ }
1222
+ }
1223
+ }
1224
+ const refreshedRemote = hasLiveRemote
1225
+ ? unwrapComponent(await client.getComponent(remoteComponentId))
1226
+ : {
1227
+ ...component,
1228
+ componentId: remoteComponentId,
1229
+ ...(componentVersion(remote) != null ? { version: componentVersion(remote) } : {}),
1230
+ };
1231
+ if (hasLiveRemote
1232
+ && componentSourceHash(refreshedRemote, identity) !== componentSourceHash(component, identity)) {
1233
+ const logPath = writeComponentPushVerificationLog({
1234
+ cwd: context.cwd,
1235
+ targetPath,
1236
+ environment: context.componentEnvironment,
1237
+ identity,
1238
+ desired: component,
1239
+ actual: refreshedRemote,
1240
+ operations,
1241
+ errorOperationId,
1242
+ });
1243
+ throw new Error(`Cannot push ${targetPath}: updated component did not verify against local source. Diagnostic: ${logPath}`);
1244
+ }
1245
+ upsertLockFromRemote(context.cwd, refreshedRemote, targetPath, component, context.componentEnvironment);
1246
+ if (!configurationChanged && !elementsChanged) {
1247
+ const result = {
1248
+ status: 'skipped',
1249
+ targetPath,
1250
+ reason: 'no changes',
1251
+ };
875
1252
  printSyncStatus(println, {
876
1253
  status: 'Skipped',
877
1254
  direction: 'push',
@@ -884,13 +1261,6 @@ async function pushSingleComponent(context, manifestPath) {
884
1261
  status: 'deployed',
885
1262
  targetPath,
886
1263
  };
887
- try {
888
- const remote = unwrapComponent(await client.getComponent(componentId));
889
- upsertLockFromRemote(context.cwd, remote, targetPath);
890
- }
891
- catch {
892
- upsertLockFromRemote(context.cwd, component, targetPath);
893
- }
894
1264
  printSyncStatus(println, {
895
1265
  status: 'Deployed',
896
1266
  direction: 'push',
@@ -911,7 +1281,6 @@ async function pushSingleComponent(context, manifestPath) {
911
1281
  targetPath,
912
1282
  reason: result.reason,
913
1283
  });
914
- upsertLockFromRemote(context.cwd, component, targetPath);
915
1284
  return result;
916
1285
  }
917
1286
  if (error instanceof PermissionDeniedError) {
@@ -937,6 +1306,7 @@ async function pushSingleComponent(context, manifestPath) {
937
1306
  async function pushAllComponents(context, options) {
938
1307
  const { cwd, println } = context;
939
1308
  const root = getWorkspaceRoot(cwd);
1309
+ const identity = resolveComponentIdentity(cwd);
940
1310
  let manifests = getComponentManifestPaths(root);
941
1311
  if (manifests.length === 0) {
942
1312
  println(`No component.json files found in ${path.relative(cwd, root) || 'Components'}.`);
@@ -954,67 +1324,64 @@ async function pushAllComponents(context, options) {
954
1324
  }
955
1325
  }
956
1326
  await confirmBulkAction(context, 'push', manifests.length, options.force || options.yes);
1327
+ const remoteCatalog = identity.mode === 'stableKey'
1328
+ ? await buildRemoteComponentCatalog(context, identity)
1329
+ : undefined;
1330
+ if (remoteCatalog) {
1331
+ migrateComponentLockIdentity(cwd, identity, buildLocalComponentCatalog(cwd, identity), remoteCatalog);
1332
+ }
957
1333
  const startedAt = Date.now();
958
1334
  const results = [];
959
1335
  for (const manifestPath of manifests) {
960
- const result = await pushSingleComponent(context, manifestPath);
1336
+ const result = await pushSingleComponent(context, manifestPath, remoteCatalog);
961
1337
  if (result) {
962
1338
  results.push(result);
963
1339
  }
964
1340
  }
965
- printSummary(context.println, 'Deployed', results, Date.now() - startedAt);
1341
+ printSyncSummary(context.println, 'Deployed', results, Date.now() - startedAt);
966
1342
  return results;
967
1343
  }
968
1344
  async function buildComponentPlan(context, scope = { enabled: false, base: '', componentIds: new Set() }) {
969
1345
  const projectRoot = getProjectRoot(context.cwd);
970
1346
  const lock = readComponentLock(projectRoot);
971
1347
  const root = getWorkspaceRoot(context.cwd);
972
- const localManifests = getComponentManifestPaths(root);
973
- const localById = new Map();
974
- for (const manifestPath of localManifests) {
975
- const component = readWorkspaceComponentSafe(manifestPath);
976
- const componentId = component ? componentIdOf(component) : '';
977
- if (component && componentId) {
978
- localById.set(componentId, {
979
- manifestPath,
980
- component,
981
- hash: hashComponentSource(component),
982
- });
983
- }
984
- }
1348
+ const identity = resolveComponentIdentity(context.cwd);
1349
+ const localCatalog = buildLocalComponentCatalog(context.cwd, identity);
985
1350
  const { components } = await fetchAllComponentSummaries(context);
986
- const remoteIds = components.map((item) => String(item.componentId || item.id || '')).filter(Boolean);
987
- const remoteById = new Map();
988
- for (const componentId of remoteIds) {
989
- const remote = unwrapComponent(await context.client.getComponent(componentId));
990
- remoteById.set(componentId, {
991
- component: remote,
992
- hash: hashComponentSource(remote),
993
- });
994
- }
995
- const allIds = [...new Set([
996
- ...localById.keys(),
997
- ...remoteById.keys(),
998
- ...Object.keys(lock.components),
1351
+ const remoteCatalog = await buildRemoteComponentCatalog(context, identity, components);
1352
+ const lockEntries = getComponentLockEntries(lock, identity, context.componentEnvironment);
1353
+ const allIdentityKeys = [...new Set([
1354
+ ...localCatalog.byIdentity.keys(),
1355
+ ...remoteCatalog.byIdentity.keys(),
1356
+ ...Object.keys(lockEntries),
999
1357
  ])].sort();
1000
- const items = allIds.map((componentId) => {
1001
- const local = localById.get(componentId) || null;
1002
- const remote = remoteById.get(componentId) || null;
1003
- const lockEntry = lock.components[componentId] || null;
1358
+ const items = allIdentityKeys.map((identityKey) => {
1359
+ const local = localCatalog.byIdentity.get(identityKey) || null;
1360
+ const remote = remoteCatalog.byIdentity.get(identityKey) || null;
1361
+ const lockEntry = lockEntries[identityKey] || null;
1004
1362
  const component = local?.component || remote?.component;
1005
- const componentName = component?.name || lockEntry?.componentName || componentId;
1363
+ const localComponentId = local ? componentIdOf(local.component) : '';
1364
+ const remoteComponentId = remote ? componentIdOf(remote.component) : lockEntry?.remoteComponentId || '';
1365
+ const componentId = localComponentId || remoteComponentId || lockEntry?.componentId || identityKey;
1366
+ const componentName = component?.name || lockEntry?.componentName || identityKey;
1006
1367
  const category = normalizeValue(component?.category, lockEntry?.category ?? null);
1007
- const targetPath = local
1368
+ const targetPath = local?.manifestPath
1008
1369
  ? componentWorkspacePath(context.cwd, local.manifestPath)
1009
1370
  : remote
1010
1371
  ? componentTargetPath(context.cwd, remote.component)
1011
- : lockEntry?.path || componentId;
1372
+ : lockEntry?.path || identityKey;
1012
1373
  const localHash = local?.hash || null;
1013
1374
  const remoteHash = remote?.hash || null;
1014
1375
  const lockHash = lockEntry?.remoteHash || null;
1376
+ const changedFields = local && remote
1377
+ ? diffPortableComponentSource(local.component, remote.component).changedFields
1378
+ : [];
1015
1379
  let status = 'clean';
1016
1380
  let reason = 'local, lock, and remote match';
1017
- if (!local && remote) {
1381
+ if (!local && !remote) {
1382
+ reason = 'lock entry has no matching local or remote component';
1383
+ }
1384
+ else if (!local && remote) {
1018
1385
  status = 'safe-update';
1019
1386
  reason = 'remote component is missing locally';
1020
1387
  }
@@ -1045,9 +1412,14 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
1045
1412
  status = 'conflict';
1046
1413
  reason = 'local and remote changed since lock';
1047
1414
  }
1048
- const inScope = !scope.enabled || scope.componentIds.has(componentId);
1415
+ const inScope = !scope.enabled
1416
+ || scope.componentIds.has(localComponentId)
1417
+ || scope.componentIds.has(remoteComponentId);
1049
1418
  return {
1050
1419
  componentId,
1420
+ identityKey,
1421
+ ...(identity.mode === 'stableKey' ? { stableKey: identityKey } : {}),
1422
+ ...(remoteComponentId ? { remoteComponentId } : {}),
1051
1423
  componentName,
1052
1424
  category,
1053
1425
  path: targetPath,
@@ -1057,6 +1429,7 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
1057
1429
  remoteHash,
1058
1430
  remoteVersion: remote ? componentVersion(remote.component) : lockEntry?.remoteVersion ?? null,
1059
1431
  reason,
1432
+ ...(changedFields.length > 0 ? { changedFields } : {}),
1060
1433
  ...(scope.enabled ? { inScope } : {}),
1061
1434
  };
1062
1435
  });
@@ -1065,6 +1438,11 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
1065
1438
  schemaVersion: 1,
1066
1439
  generatedAt: new Date().toISOString(),
1067
1440
  workspaceRoot: path.relative(context.cwd, root) || 'Components',
1441
+ componentIdentity: identity,
1442
+ unmanaged: {
1443
+ local: localCatalog.unmanaged.length,
1444
+ remote: remoteCatalog.unmanaged.length,
1445
+ },
1068
1446
  ...(scope.enabled ? {
1069
1447
  scope: {
1070
1448
  mode: 'changed',
@@ -1087,12 +1465,6 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
1087
1465
  items: scopedItems,
1088
1466
  };
1089
1467
  }
1090
- function printPlan(context, plan) {
1091
- for (const item of plan.items) {
1092
- context.println(`${item.status.padEnd(14)} ${item.path} ${item.reason ? `(${item.reason})` : ''}`.trimEnd());
1093
- }
1094
- context.println(`Plan: ${plan.items.length} components, ${plan.counts.conflict} conflicts, ${plan.counts['local-changed']} local changes, ${plan.counts['remote-changed']} remote changes.`);
1095
- }
1096
1468
  function parseTargets(args, names) {
1097
1469
  const values = readValues(args, names);
1098
1470
  if (values.length > 0) {
@@ -1140,21 +1512,45 @@ function buildDebugElements(component) {
1140
1512
  if (!componentName) {
1141
1513
  throw new Error('Missing component name in local manifest.');
1142
1514
  }
1143
- return [...(component.elements || [])].sort((left, right) => left.order - right.order).map((element) => {
1515
+ return sortDebugElements(component.elements || [], componentName).map((element) => {
1516
+ const key = readDebugElementKey(element, componentName);
1517
+ const order = readDebugElementOrder(element, key, componentName);
1144
1518
  const details = element.details || '';
1145
1519
  return {
1146
- key: element.key,
1520
+ key,
1147
1521
  desc: element.desc ?? null,
1148
1522
  initValue: details,
1149
1523
  details,
1150
1524
  componentId,
1151
1525
  componentName,
1152
- order: element.order,
1526
+ order,
1153
1527
  hidden: Boolean(element.hidden),
1154
1528
  uuid: typeof element.uuid === 'string' ? element.uuid : undefined,
1155
1529
  };
1156
1530
  });
1157
1531
  }
1532
+ function readDebugElementKey(element, componentName) {
1533
+ if (typeof element.key === 'string' && element.key.trim()) {
1534
+ return element.key;
1535
+ }
1536
+ const order = Number.isFinite(Number(element.order)) ? Number(element.order) : 'unknown';
1537
+ throw new Error(`Debug element at order ${order} in "${componentName}" is missing a valid key.`);
1538
+ }
1539
+ function readDebugElementOrder(element, key, componentName) {
1540
+ const order = Number(element.order);
1541
+ if (Number.isFinite(order)) {
1542
+ return order;
1543
+ }
1544
+ throw new Error(`Debug element "${key}" in "${componentName}" is missing a valid numeric order.`);
1545
+ }
1546
+ function sortDebugElements(elements, componentName) {
1547
+ return [...elements].sort((left, right) => {
1548
+ const leftOrder = readDebugElementOrder(left, readDebugElementKey(left, componentName), componentName);
1549
+ const rightOrder = readDebugElementOrder(right, readDebugElementKey(right, componentName), componentName);
1550
+ return leftOrder - rightOrder
1551
+ || readDebugElementKey(left, componentName).localeCompare(readDebugElementKey(right, componentName));
1552
+ });
1553
+ }
1158
1554
  function resolveElementSourcePath(manifestPath, component, element) {
1159
1555
  const componentDir = path.dirname(manifestPath);
1160
1556
  const detailDirs = [
@@ -1184,12 +1580,14 @@ function buildDebugSourceComponent(role, manifestPath, component) {
1184
1580
  componentName: component.name || '',
1185
1581
  type: normalizeComponentType(component),
1186
1582
  manifestPath,
1187
- elements: [...(component.elements || [])].sort((left, right) => left.order - right.order).map((element) => {
1583
+ elements: sortDebugElements(component.elements || [], component.name || component.componentId || component.id || 'component').map((element) => {
1584
+ const key = readDebugElementKey(element, component.name || component.componentId || component.id || 'component');
1585
+ const order = readDebugElementOrder(element, key, component.name || component.componentId || component.id || 'component');
1188
1586
  const sourcePath = resolveElementSourcePath(manifestPath, component, element);
1189
1587
  const details = element.details || '';
1190
1588
  return {
1191
- key: element.key,
1192
- order: element.order,
1589
+ key,
1590
+ order,
1193
1591
  sourcePath,
1194
1592
  bytes: Buffer.byteLength(details, 'utf8'),
1195
1593
  };
@@ -1243,19 +1641,31 @@ function collectDebugExtraLibs(cwd, currentComponentId) {
1243
1641
  .filter(({ component }) => {
1244
1642
  const type = normalizeComponentType(component);
1245
1643
  const componentId = component.componentId || component.id || '';
1246
- return isDebugLibraryType(type) && componentId !== currentComponentId && Boolean(component.name);
1644
+ return isDebugLibraryType(type) && Boolean(componentId) && componentId !== currentComponentId && Boolean(component.name);
1247
1645
  })
1248
1646
  .map(({ manifestPath, component }) => ({
1249
1647
  payload: {
1648
+ componentId: component.componentId || component.id || '',
1250
1649
  name: component.name,
1251
1650
  type: normalizeComponentType(component),
1651
+ ...(typeof component.version === 'number' ? { version: component.version } : {}),
1252
1652
  elements: buildDebugElements(component),
1253
1653
  },
1254
1654
  source: buildDebugSourceComponent('extraLib', manifestPath, component),
1255
1655
  }));
1256
1656
  }
1257
1657
  function writeRawDebugRequestDump(options) {
1258
- process.stderr.write(`[debug-request] ${JSON.stringify(options, null, 2)}\n`);
1658
+ const directory = path.join(getConfigDir(), 'debug-requests');
1659
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
1660
+ try {
1661
+ fs.chmodSync(directory, 0o700);
1662
+ }
1663
+ catch {
1664
+ // Some platforms do not support Unix-style permissions.
1665
+ }
1666
+ const filePath = path.join(directory, `debug-request-${randomUUID()}.json`);
1667
+ writeJsonFile(filePath, options);
1668
+ process.stderr.write(`[debug-request] Saved private payload to ${filePath}\n`);
1259
1669
  }
1260
1670
  function formatProcessLog(log) {
1261
1671
  if (!log || typeof log !== 'object' || Array.isArray(log)) {
@@ -1315,7 +1725,8 @@ async function debugSingleComponent(context, componentId) {
1315
1725
  const timeout = parseDebugNumber(context.args, ['timeout', 't'], 10, 600, 'Timeout');
1316
1726
  const memory = parseDebugNumber(context.args, ['memory', 'm'], 128, 1024, 'Memory');
1317
1727
  const stream = readBoolFlag(context.args, ['stream']);
1318
- const includeExtraLibs = !readBoolFlag(context.args, ['no-extra-libs']);
1728
+ const includeExtraLibs = context.args['extra-libs'] !== false
1729
+ && !readBoolFlag(context.args, ['no-extra-libs']);
1319
1730
  const rawDebug = readBoolFlag(context.args, ['raw-debug', 'debug-raw', 'debug-payload']);
1320
1731
  const profile = await context.client.me();
1321
1732
  const sandboxEndpoint = extractSandboxEndpoint(profile);
@@ -1379,6 +1790,12 @@ export async function handleComponentCommand(context) {
1379
1790
  const json = readBoolFlag(args, ['json']);
1380
1791
  const strict = readBoolFlag(args, ['strict']);
1381
1792
  const changedScope = resolveChangedComponentScope(context);
1793
+ if (context.componentEnvironment && ['pull', 'plan', 'push'].includes(subcommand)) {
1794
+ const identity = resolveComponentIdentity(context.cwd);
1795
+ if (identity.mode !== 'stableKey') {
1796
+ throw new Error('Component sync with a named project requires componentIdentity.mode "stableKey". Configure it in .revoengine/revo.json before pulling from a named project.');
1797
+ }
1798
+ }
1382
1799
  if (subcommand === 'pull-all') {
1383
1800
  await pullAllComponents(context, { force, stale, yes });
1384
1801
  return;
@@ -1393,7 +1810,17 @@ export async function handleComponentCommand(context) {
1393
1810
  context.print(plan);
1394
1811
  }
1395
1812
  else {
1396
- printPlan(context, plan);
1813
+ printSyncPlan(context.println, {
1814
+ title: 'Component',
1815
+ environment: context.componentEnvironment || 'default',
1816
+ counts: plan.counts,
1817
+ items: plan.items.map((item) => ({
1818
+ status: item.status,
1819
+ target: item.path,
1820
+ reason: item.reason,
1821
+ changedFields: item.changedFields,
1822
+ })),
1823
+ });
1397
1824
  }
1398
1825
  if (strict) {
1399
1826
  const blocking = plan.items.filter((item) => ((!changedScope.enabled || item.inScope)
@@ -1436,7 +1863,7 @@ export async function handleComponentCommand(context) {
1436
1863
  await pushAllComponents(context, { force, yes, scope: changedScope });
1437
1864
  return;
1438
1865
  }
1439
- const manifests = targets.flatMap((componentId) => findComponentManifestsById(context.cwd, componentId));
1866
+ const manifests = targets.flatMap((target) => findComponentManifestsByTarget(context.cwd, target));
1440
1867
  if (manifests.length === 0) {
1441
1868
  throw new Error(`Unable to find local component for ${targets.join(', ')}.`);
1442
1869
  }