@revoengine/cli 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,13 @@
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 { ApiError, COMPONENT_LIST_PAGE_SIZE, PermissionDeniedError } from "../client.js";
5
+ import { REVO_LOCK_FILE, componentLockMatchesIdentity, getComponentLockEntry, hashComponentSource, hashPortableComponentSource, hashStable, readComponentLock, sanitizeComponentSource, upsertComponentLockEntry, writeComponentLock, } from "../component-lock.js";
6
+ import { buildSandboxDebugStreamUrl, buildSandboxDebugUrl, extractSandboxEndpoint, readProjectComponentIdentity, readProjectMetadata, resolveProjectRoot, resolveProjectWorkspace, } from "../project.js";
7
7
  import { isInteractiveTerminal, promptConfirm } from "../prompt.js";
8
+ import { readComponentIdentityValue, } from "../resource-metadata.js";
8
9
  import { deepClone, readBoolFlag, readFlag, readValues, sanitizeSegment, writeJsonFile } from "../utils.js";
9
10
  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
11
  const ANSI = {
16
12
  reset: '\u001b[0m',
17
13
  bold: '\u001b[1m',
@@ -120,27 +116,6 @@ function readWorkspaceComponentSafe(manifestPath) {
120
116
  return null;
121
117
  }
122
118
  }
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
119
  function isNotModifiedError(error) {
145
120
  if (!(error instanceof ApiError) || error.status !== 400) {
146
121
  return false;
@@ -198,7 +173,28 @@ function getWorkspaceRoot(cwd) {
198
173
  return path.join(projectWorkspace || cwd, 'Components');
199
174
  }
200
175
  function getProjectRoot(cwd) {
201
- return resolveProjectRoot(cwd) || cwd;
176
+ const configuredRoot = resolveProjectRoot(cwd);
177
+ if (configuredRoot) {
178
+ return configuredRoot;
179
+ }
180
+ let currentDir = path.resolve(cwd);
181
+ const { root } = path.parse(currentDir);
182
+ while (true) {
183
+ if (fs.existsSync(path.join(currentDir, REVO_LOCK_FILE))) {
184
+ return currentDir;
185
+ }
186
+ if (currentDir === root) {
187
+ return cwd;
188
+ }
189
+ currentDir = path.dirname(currentDir);
190
+ }
191
+ }
192
+ function resolveComponentIdentity(cwd) {
193
+ const metadata = readProjectMetadata(cwd);
194
+ if (metadata) {
195
+ return readProjectComponentIdentity(cwd);
196
+ }
197
+ return readComponentLock(getProjectRoot(cwd)).componentIdentity;
202
198
  }
203
199
  function getCategoryFolder(component) {
204
200
  if (component.category == null || component.category === '') {
@@ -298,6 +294,18 @@ function findComponentManifestsById(cwd, componentId) {
298
294
  return getComponentIdFromPath(path.dirname(manifestPath)).includes(componentId);
299
295
  });
300
296
  }
297
+ function findComponentManifestsByTarget(cwd, target) {
298
+ const identity = resolveComponentIdentity(cwd);
299
+ const matches = findComponentManifestsById(cwd, target);
300
+ if (identity.mode !== 'stableKey') {
301
+ return matches;
302
+ }
303
+ const stableMatches = getComponentManifestPaths(getWorkspaceRoot(cwd)).filter((manifestPath) => {
304
+ const component = readWorkspaceComponentSafe(manifestPath);
305
+ return component && readComponentIdentityValue(component, identity) === target;
306
+ });
307
+ return [...new Set([...stableMatches, ...matches])];
308
+ }
301
309
  function isRelativeInside(parent, child) {
302
310
  const relative = path.relative(parent, child);
303
311
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
@@ -392,79 +400,6 @@ function unwrapList(value) {
392
400
  function isRecord(value) {
393
401
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
394
402
  }
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
403
  function unwrapComponent(value) {
469
404
  if (value && typeof value === 'object' && !Array.isArray(value)) {
470
405
  const candidate = value;
@@ -478,11 +413,12 @@ function normalizeValue(value, fallback = null) {
478
413
  return value ?? fallback;
479
414
  }
480
415
  function normalizeElementContract(element) {
416
+ const order = Number.isFinite(Number(element.order)) ? Number(element.order) : 0;
481
417
  return {
482
- key: element.key,
418
+ key: typeof element.key === 'string' ? element.key : '',
483
419
  desc: normalizeValue(element.desc),
484
420
  hidden: Boolean(element.hidden),
485
- order: element.order,
421
+ order,
486
422
  details: element.details ?? '',
487
423
  };
488
424
  }
@@ -508,92 +444,317 @@ function componentIdOf(component) {
508
444
  function componentTargetPath(cwd, component) {
509
445
  return path.relative(cwd, path.join(getWorkspaceRoot(cwd), getComponentFolder(component)));
510
446
  }
511
- function upsertLockFromRemote(cwd, remote, targetPath) {
512
- const componentId = componentIdOf(remote);
513
- if (!componentId) {
447
+ function componentSourceHash(component, identity) {
448
+ return identity.mode === 'stableKey'
449
+ ? hashPortableComponentSource(component)
450
+ : hashComponentSource(component);
451
+ }
452
+ function identityDescription(identity) {
453
+ return identity.mode === 'stableKey'
454
+ ? `metadata.${identity.metadataProperty}`
455
+ : 'componentId';
456
+ }
457
+ function addCatalogEntry(catalog, entry, source) {
458
+ const componentId = componentIdOf(entry.component);
459
+ if (componentId) {
460
+ const duplicateId = catalog.byComponentId.get(componentId);
461
+ if (duplicateId && duplicateId.manifestPath !== entry.manifestPath) {
462
+ throw new Error(`Duplicate ${source} componentId "${componentId}".`);
463
+ }
464
+ catalog.byComponentId.set(componentId, entry);
465
+ }
466
+ const identityKey = readComponentIdentityValue(entry.component, catalog.identity);
467
+ if (!identityKey) {
468
+ catalog.unmanaged.push(entry);
469
+ return;
470
+ }
471
+ const duplicate = catalog.byIdentity.get(identityKey);
472
+ if (duplicate && duplicate.component !== entry.component) {
473
+ const duplicateIds = [componentIdOf(duplicate.component), componentId]
474
+ .filter(Boolean)
475
+ .join(', ');
476
+ throw new Error(`Duplicate ${source} ${identityDescription(catalog.identity)} "${identityKey}"${duplicateIds ? ` (${duplicateIds})` : ''}.`);
477
+ }
478
+ catalog.byIdentity.set(identityKey, entry);
479
+ }
480
+ function createComponentCatalog(identity) {
481
+ return {
482
+ identity,
483
+ byIdentity: new Map(),
484
+ byComponentId: new Map(),
485
+ unmanaged: [],
486
+ };
487
+ }
488
+ function buildLocalComponentCatalog(cwd, identity) {
489
+ const catalog = createComponentCatalog(identity);
490
+ for (const manifestPath of getComponentManifestPaths(getWorkspaceRoot(cwd))) {
491
+ const component = readWorkspaceComponentSafe(manifestPath);
492
+ if (!component) {
493
+ continue;
494
+ }
495
+ addCatalogEntry(catalog, {
496
+ component,
497
+ hash: componentSourceHash(component, identity),
498
+ manifestPath,
499
+ }, 'local');
500
+ }
501
+ return catalog;
502
+ }
503
+ async function buildRemoteComponentCatalog(context, identity, summaries) {
504
+ const catalog = createComponentCatalog(identity);
505
+ const source = summaries || (await fetchAllComponentSummaries(context)).components;
506
+ for (const summary of source) {
507
+ const componentId = String(summary.componentId || summary.id || '');
508
+ if (!componentId) {
509
+ continue;
510
+ }
511
+ const component = unwrapComponent(await context.client.getComponent(componentId));
512
+ addCatalogEntry(catalog, {
513
+ component,
514
+ hash: componentSourceHash(component, identity),
515
+ }, 'remote');
516
+ }
517
+ return catalog;
518
+ }
519
+ function migrateComponentLockIdentity(cwd, identity, localCatalog, remoteCatalog) {
520
+ const projectRoot = getProjectRoot(cwd);
521
+ const lock = readComponentLock(projectRoot);
522
+ if (componentLockMatchesIdentity(lock, identity)) {
523
+ return lock;
524
+ }
525
+ const migratedComponents = {};
526
+ if (identity.mode === 'stableKey' && lock.componentIdentity.mode === 'componentId') {
527
+ const legacyEntries = Object.values(lock.components);
528
+ for (const [stableKey, remote] of remoteCatalog.byIdentity) {
529
+ const local = findLocalEntryForRemote(remote.component, localCatalog);
530
+ const localComponentId = local ? componentIdOf(local.component) : '';
531
+ const remoteComponentId = componentIdOf(remote.component);
532
+ const legacy = legacyEntries.find((entry) => (entry.componentId === localComponentId
533
+ || entry.remoteComponentId === localComponentId
534
+ || entry.componentId === remoteComponentId
535
+ || entry.remoteComponentId === remoteComponentId));
536
+ if (!legacy || !remoteComponentId) {
537
+ continue;
538
+ }
539
+ const remoteLegacyHash = hashComponentSource(remote.component);
540
+ const localLegacyHash = local ? hashComponentSource(local.component) : null;
541
+ let portableBaselineHash = null;
542
+ if (legacy.remoteHash === remoteLegacyHash) {
543
+ portableBaselineHash = remote.hash;
544
+ }
545
+ else if (local && legacy.remoteHash === localLegacyHash) {
546
+ portableBaselineHash = local.hash;
547
+ }
548
+ else if (local && local.hash === remote.hash) {
549
+ portableBaselineHash = remote.hash;
550
+ }
551
+ if (!portableBaselineHash) {
552
+ continue;
553
+ }
554
+ migratedComponents[stableKey] = {
555
+ identityMode: 'stableKey',
556
+ metadataProperty: identity.metadataProperty,
557
+ stableKey,
558
+ componentId: localComponentId || legacy.componentId || remoteComponentId,
559
+ remoteComponentId,
560
+ componentName: remote.component.name || local?.component.name || legacy.componentName,
561
+ category: normalizeValue(remote.component.category, normalizeValue(local?.component.category, legacy.category)),
562
+ path: local?.manifestPath
563
+ ? componentWorkspacePath(cwd, local.manifestPath)
564
+ : legacy.path || componentTargetPath(cwd, remote.component),
565
+ remoteVersion: legacy.remoteVersion,
566
+ remoteHash: portableBaselineHash,
567
+ sourceHash: local?.hash || portableBaselineHash,
568
+ pulledAt: legacy.pulledAt,
569
+ };
570
+ }
571
+ }
572
+ const migrated = {
573
+ schemaVersion: 2,
574
+ componentIdentity: identity,
575
+ components: migratedComponents,
576
+ };
577
+ writeComponentLock(projectRoot, migrated);
578
+ return migrated;
579
+ }
580
+ function findLocalEntryForRemote(remote, localCatalog) {
581
+ const identityKey = readComponentIdentityValue(remote, localCatalog.identity);
582
+ if (!identityKey) {
583
+ return null;
584
+ }
585
+ const identityMatch = localCatalog.byIdentity.get(identityKey);
586
+ if (identityMatch) {
587
+ return identityMatch;
588
+ }
589
+ if (localCatalog.identity.mode !== 'stableKey') {
590
+ return null;
591
+ }
592
+ const remoteComponentId = componentIdOf(remote);
593
+ const idMatch = remoteComponentId
594
+ ? localCatalog.byComponentId.get(remoteComponentId)
595
+ : null;
596
+ if (!idMatch) {
597
+ return null;
598
+ }
599
+ const localIdentityKey = readComponentIdentityValue(idMatch.component, localCatalog.identity);
600
+ if (localIdentityKey && localIdentityKey !== identityKey) {
601
+ throw new Error(`Cannot match component ${remoteComponentId}: local ${identityDescription(localCatalog.identity)} "${localIdentityKey}" does not match remote "${identityKey}".`);
602
+ }
603
+ return idMatch;
604
+ }
605
+ function upsertLockFromRemote(cwd, remote, targetPath, local) {
606
+ const identity = resolveComponentIdentity(cwd);
607
+ const remoteComponentId = componentIdOf(remote);
608
+ const localComponentId = componentIdOf(local || remote);
609
+ const identityKey = readComponentIdentityValue(remote, identity);
610
+ if (!remoteComponentId || !localComponentId || !identityKey) {
514
611
  return;
515
612
  }
516
- const remoteHash = hashComponentSource(remote);
613
+ const remoteHash = componentSourceHash(remote, identity);
517
614
  upsertComponentLockEntry(getProjectRoot(cwd), {
518
- componentId,
519
- componentName: remote.name || '',
520
- category: normalizeValue(remote.category),
521
- path: targetPath || componentTargetPath(cwd, remote),
615
+ identityMode: identity.mode,
616
+ ...(identity.mode === 'stableKey'
617
+ ? {
618
+ metadataProperty: identity.metadataProperty,
619
+ stableKey: identityKey,
620
+ }
621
+ : {}),
622
+ componentId: localComponentId,
623
+ remoteComponentId,
624
+ componentName: remote.name || local?.name || '',
625
+ category: normalizeValue(remote.category, normalizeValue(local?.category)),
626
+ path: targetPath || componentTargetPath(cwd, local || remote),
522
627
  remoteVersion: componentVersion(remote),
523
628
  remoteHash,
524
- sourceHash: remoteHash,
629
+ sourceHash: local ? componentSourceHash(local, identity) : remoteHash,
525
630
  pulledAt: new Date().toISOString(),
526
- });
631
+ }, identity);
527
632
  }
528
- function buildPullDecision(cwd, remote) {
633
+ function buildPullDecision(cwd, remote, localCatalog = buildLocalComponentCatalog(cwd, resolveComponentIdentity(cwd))) {
634
+ const identity = localCatalog.identity;
529
635
  const remoteTargetPath = componentTargetPath(cwd, remote);
530
- const componentId = componentIdOf(remote);
531
- const localInfo = componentId ? findLocalManifestInfo(cwd, componentId) : null;
532
- if (!localInfo?.manifestPath) {
636
+ const identityKey = readComponentIdentityValue(remote, identity);
637
+ if (!identityKey) {
638
+ return {
639
+ kind: 'skip',
640
+ targetPath: remoteTargetPath,
641
+ reason: `missing ${identityDescription(identity)}; unmanaged`,
642
+ };
643
+ }
644
+ const local = findLocalEntryForRemote(remote, localCatalog);
645
+ if (!local?.manifestPath) {
533
646
  return {
534
647
  kind: 'pull',
535
648
  targetPath: remoteTargetPath,
536
649
  };
537
650
  }
538
- const localComponent = readWorkspaceComponentSafe(localInfo.manifestPath);
539
- if (!localComponent) {
651
+ const targetPath = componentWorkspacePath(cwd, local.manifestPath);
652
+ const lockEntry = getComponentLockEntry(readComponentLock(getProjectRoot(cwd)), identity, remote);
653
+ const localHash = local.hash;
654
+ const remoteHash = componentSourceHash(remote, identity);
655
+ const base = {
656
+ targetPath,
657
+ manifestPath: local.manifestPath,
658
+ localComponent: local.component,
659
+ };
660
+ if (localHash === remoteHash) {
540
661
  return {
541
662
  kind: 'skip',
542
- targetPath: localInfo.targetPath || remoteTargetPath,
543
- reason: 'changed',
663
+ ...base,
664
+ reason: 'no changes',
544
665
  };
545
666
  }
546
- const lockEntry = readComponentLock(getProjectRoot(cwd)).components[componentId];
547
- const localHash = hashComponentSource(localComponent);
548
- const remoteHash = hashComponentSource(remote);
549
667
  if (!lockEntry) {
550
- if (localHash === remoteHash) {
551
- return {
552
- kind: 'skip',
553
- targetPath: localInfo.targetPath || remoteTargetPath,
554
- reason: 'no changes',
555
- };
556
- }
557
668
  return {
558
669
  kind: 'skip',
559
- targetPath: localInfo.targetPath || remoteTargetPath,
670
+ ...base,
560
671
  reason: 'missing lock',
561
672
  };
562
673
  }
563
674
  if (localHash === lockEntry.remoteHash && remoteHash !== lockEntry.remoteHash) {
564
675
  return {
565
676
  kind: 'pull',
566
- targetPath: localInfo.targetPath || remoteTargetPath,
677
+ ...base,
567
678
  reason: 'remote changed',
568
679
  };
569
680
  }
570
681
  if (localHash !== lockEntry.remoteHash && remoteHash === lockEntry.remoteHash) {
571
682
  return {
572
683
  kind: 'skip',
573
- targetPath: localInfo.targetPath || remoteTargetPath,
684
+ ...base,
574
685
  reason: 'local changes',
575
686
  };
576
687
  }
577
688
  if (localHash !== lockEntry.remoteHash && remoteHash !== lockEntry.remoteHash && localHash !== remoteHash) {
578
689
  return {
579
690
  kind: 'skip',
580
- targetPath: localInfo.targetPath || remoteTargetPath,
691
+ ...base,
581
692
  reason: 'conflict',
582
693
  };
583
694
  }
584
- if (isSameComponentContract(localComponent, remote)) {
695
+ if (identity.mode === 'componentId' && isSameComponentContract(local.component, remote)) {
585
696
  return {
586
697
  kind: 'skip',
587
- targetPath: localInfo.targetPath || remoteTargetPath,
698
+ ...base,
588
699
  reason: 'no changes',
589
700
  };
590
701
  }
591
702
  return {
592
703
  kind: 'skip',
593
- targetPath: localInfo.targetPath || remoteTargetPath,
704
+ ...base,
594
705
  reason: 'changed',
595
706
  };
596
707
  }
708
+ function syncLocalIdentityMetadata(manifestPath, remote, identity) {
709
+ if (identity.mode !== 'stableKey' || !manifestPath) {
710
+ return false;
711
+ }
712
+ const remoteStableKey = readComponentIdentityValue(remote, identity);
713
+ if (!remoteStableKey) {
714
+ return false;
715
+ }
716
+ let manifest;
717
+ try {
718
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
719
+ }
720
+ catch {
721
+ return false;
722
+ }
723
+ const localStableKey = readComponentIdentityValue(manifest, identity);
724
+ if (localStableKey) {
725
+ if (localStableKey !== remoteStableKey) {
726
+ throw new Error(`Cannot pull ${componentIdOf(remote)}: local ${identityDescription(identity)} "${localStableKey}" does not match remote "${remoteStableKey}".`);
727
+ }
728
+ return false;
729
+ }
730
+ const metadata = isRecord(manifest.metadata) ? deepClone(manifest.metadata) : {};
731
+ manifest.metadata = {
732
+ ...metadata,
733
+ [identity.metadataProperty]: remoteStableKey,
734
+ };
735
+ writeJsonFile(manifestPath, manifest);
736
+ return true;
737
+ }
738
+ function buildPulledManifest(remote, local, identity) {
739
+ const manifest = stripDetails(remote);
740
+ if (identity.mode !== 'stableKey' || !local) {
741
+ return manifest;
742
+ }
743
+ const localComponentId = componentIdOf(local);
744
+ if (localComponentId) {
745
+ manifest.componentId = localComponentId;
746
+ delete manifest.id;
747
+ }
748
+ const localElementsByKey = new Map((local.elements || []).map((element) => [element.key, element]));
749
+ manifest.elements = (manifest.elements || []).map((element) => {
750
+ const localElementId = localElementsByKey.get(element.key)?.componentElementId;
751
+ return {
752
+ ...element,
753
+ ...(localElementId ? { componentElementId: localElementId } : {}),
754
+ };
755
+ });
756
+ return manifest;
757
+ }
597
758
  async function confirmSingleStalePull(context, targetPath) {
598
759
  if (!isInteractiveTerminal()) {
599
760
  throw new Error(`Local component is stale at ${targetPath}. Re-run with --stale or --force, or use an interactive terminal to confirm overwrite.`);
@@ -601,20 +762,60 @@ async function confirmSingleStalePull(context, targetPath) {
601
762
  printNotice(context.println, `Local component is stale at ${targetPath}.`);
602
763
  return promptConfirm('Overwrite local component with the remote version?', false);
603
764
  }
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));
765
+ async function resolveRemoteComponentForTarget(context, target, identity, remoteCatalog) {
766
+ const localManifestPath = findComponentManifestsById(context.cwd, target)[0];
767
+ const local = localManifestPath ? readWorkspaceComponentSafe(localManifestPath) : null;
768
+ const localIdentityKey = local ? readComponentIdentityValue(local, identity) : null;
769
+ let mismatchedDirectIdentity = null;
770
+ if (identity.mode === 'componentId' && !remoteCatalog) {
771
+ try {
772
+ return unwrapComponent(await context.client.getComponent(target));
773
+ }
774
+ catch (error) {
775
+ if (error instanceof ApiError && error.status === 404) {
776
+ return null;
777
+ }
778
+ rethrowComponentAccessError(error, 'pull');
779
+ }
609
780
  }
610
- catch (error) {
611
- rethrowComponentAccessError(error, 'pull');
781
+ if (!remoteCatalog) {
782
+ try {
783
+ const direct = unwrapComponent(await context.client.getComponent(target));
784
+ const directIdentityKey = direct ? readComponentIdentityValue(direct, identity) : null;
785
+ if (directIdentityKey && (!localIdentityKey || localIdentityKey === directIdentityKey)) {
786
+ return direct;
787
+ }
788
+ if (directIdentityKey && localIdentityKey && localIdentityKey !== directIdentityKey) {
789
+ mismatchedDirectIdentity = directIdentityKey;
790
+ }
791
+ }
792
+ catch (error) {
793
+ if (!(error instanceof ApiError && error.status === 404)) {
794
+ rethrowComponentAccessError(error, 'pull');
795
+ }
796
+ }
612
797
  }
613
- if (!component) {
798
+ const catalog = remoteCatalog || await buildRemoteComponentCatalog(context, identity);
799
+ const resolved = localIdentityKey
800
+ ? catalog.byIdentity.get(localIdentityKey)?.component || null
801
+ : catalog.byIdentity.get(target)?.component
802
+ || catalog.byComponentId.get(target)?.component
803
+ || null;
804
+ if (!resolved && mismatchedDirectIdentity && localIdentityKey) {
805
+ throw new Error(`Cannot match component ${target}: local ${identityDescription(identity)} "${localIdentityKey}" does not match remote "${mismatchedDirectIdentity}".`);
806
+ }
807
+ return resolved;
808
+ }
809
+ async function pullResolvedComponent(context, component, mode, localCatalog) {
810
+ const { cwd, println } = context;
811
+ const identity = resolveComponentIdentity(cwd);
812
+ const componentId = componentIdOf(component);
813
+ const decision = buildPullDecision(cwd, component, localCatalog || buildLocalComponentCatalog(cwd, identity));
814
+ if (decision.kind === 'skip' && decision.reason.includes('; unmanaged')) {
614
815
  const result = {
615
816
  status: 'skipped',
616
- targetPath: componentId,
617
- reason: 'component not found',
817
+ targetPath: decision.targetPath,
818
+ reason: decision.reason,
618
819
  };
619
820
  printSyncStatus(println, {
620
821
  status: 'Skipped',
@@ -624,7 +825,6 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
624
825
  });
625
826
  return result;
626
827
  }
627
- const decision = buildPullDecision(cwd, component);
628
828
  if (decision.kind === 'skip' && !mode.force) {
629
829
  if (decision.reason === 'stale version') {
630
830
  if (mode.stale) {
@@ -666,16 +866,21 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
666
866
  if (decision.reason === 'conflict' || decision.reason === 'missing lock') {
667
867
  throw new Error(`Cannot pull ${componentId}: ${decision.reason} at ${decision.targetPath}. Re-run with --force only if you want to overwrite local files.`);
668
868
  }
869
+ let metadataUpdated = false;
669
870
  if (decision.reason === 'no changes') {
670
- upsertLockFromRemote(cwd, component, decision.targetPath);
871
+ metadataUpdated = syncLocalIdentityMetadata(decision.manifestPath, component, identity);
872
+ const localComponent = decision.manifestPath
873
+ ? readWorkspaceComponentSafe(decision.manifestPath) || decision.localComponent
874
+ : decision.localComponent;
875
+ upsertLockFromRemote(cwd, component, decision.targetPath, localComponent);
671
876
  }
672
877
  const result = {
673
- status: 'skipped',
878
+ status: metadataUpdated ? 'pulled' : 'skipped',
674
879
  targetPath: decision.targetPath,
675
- reason: decision.reason,
880
+ reason: metadataUpdated ? 'stable-key metadata' : decision.reason,
676
881
  };
677
882
  printSyncStatus(println, {
678
- status: 'Skipped',
883
+ status: metadataUpdated ? 'Pulled' : 'Skipped',
679
884
  direction: 'pull',
680
885
  targetPath: result.targetPath,
681
886
  reason: result.reason,
@@ -684,7 +889,9 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
684
889
  }
685
890
  }
686
891
  const root = getWorkspaceRoot(cwd);
687
- const folder = path.join(root, getComponentFolder(component));
892
+ const folder = decision.manifestPath
893
+ ? path.dirname(decision.manifestPath)
894
+ : path.join(root, getComponentFolder(component));
688
895
  const targetPath = path.relative(cwd, folder);
689
896
  const elementsDir = path.join(folder, 'elements');
690
897
  fs.mkdirSync(elementsDir, { recursive: true });
@@ -694,8 +901,10 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
694
901
  const filePath = path.join(elementsDir, `${element.order}_${element.key}.${extension}`);
695
902
  fs.writeFileSync(filePath, content);
696
903
  }
697
- writeJsonFile(path.join(folder, 'component.json'), stripDetails(component));
698
- upsertLockFromRemote(cwd, component, targetPath);
904
+ const manifestPath = path.join(folder, 'component.json');
905
+ writeJsonFile(manifestPath, buildPulledManifest(component, decision.localComponent, identity));
906
+ const localComponent = readWorkspaceComponentSafe(manifestPath) || decision.localComponent;
907
+ upsertLockFromRemote(cwd, component, targetPath, localComponent);
699
908
  const result = {
700
909
  status: 'pulled',
701
910
  targetPath,
@@ -711,58 +920,40 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
711
920
  });
712
921
  return result;
713
922
  }
923
+ async function pullSingleComponent(context, target, mode = { force: false, stale: false }) {
924
+ const identity = resolveComponentIdentity(context.cwd);
925
+ let remoteCatalog;
926
+ let localCatalog;
927
+ if (identity.mode === 'stableKey'
928
+ && !componentLockMatchesIdentity(readComponentLock(getProjectRoot(context.cwd)), identity)) {
929
+ remoteCatalog = await buildRemoteComponentCatalog(context, identity);
930
+ localCatalog = buildLocalComponentCatalog(context.cwd, identity);
931
+ migrateComponentLockIdentity(context.cwd, identity, localCatalog, remoteCatalog);
932
+ }
933
+ const component = await resolveRemoteComponentForTarget(context, target, identity, remoteCatalog);
934
+ if (!component) {
935
+ const result = {
936
+ status: 'skipped',
937
+ targetPath: target,
938
+ reason: 'component not found',
939
+ };
940
+ printSyncStatus(context.println, {
941
+ status: 'Skipped',
942
+ direction: 'pull',
943
+ targetPath: result.targetPath,
944
+ reason: result.reason,
945
+ });
946
+ return result;
947
+ }
948
+ return pullResolvedComponent(context, component, mode, localCatalog);
949
+ }
714
950
  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;
951
+ try {
952
+ return await context.client.listAllComponents();
761
953
  }
762
- if (components.length === 0) {
763
- return { components, discoveredTotal };
954
+ catch (error) {
955
+ rethrowComponentAccessError(error, 'list');
764
956
  }
765
- return { components, discoveredTotal };
766
957
  }
767
958
  async function pullAllComponents(context, options) {
768
959
  const { components, discoveredTotal } = await fetchAllComponentSummaries(context);
@@ -770,19 +961,26 @@ async function pullAllComponents(context, options) {
770
961
  context.println('No components found.');
771
962
  return [];
772
963
  }
773
- await confirmBulkAction(context, 'pull', discoveredTotal ?? (components.length >= COMPONENT_LIST_PAGE_SIZE ? `at least ${components.length} components` : components.length), options.force || options.yes);
964
+ const identity = resolveComponentIdentity(context.cwd);
965
+ const remoteCatalog = await buildRemoteComponentCatalog(context, identity, components);
966
+ const managedCount = remoteCatalog.byIdentity.size;
967
+ if (managedCount === 0) {
968
+ context.println(`No components managed by ${identityDescription(identity)} were found.`);
969
+ return [];
970
+ }
971
+ await confirmBulkAction(context, 'pull', identity.mode === 'componentId'
972
+ ? discoveredTotal ?? (components.length >= COMPONENT_LIST_PAGE_SIZE ? `at least ${components.length} components` : components.length)
973
+ : managedCount, options.force || options.yes);
774
974
  const startedAt = Date.now();
775
975
  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), {
976
+ const localCatalog = buildLocalComponentCatalog(context.cwd, identity);
977
+ migrateComponentLockIdentity(context.cwd, identity, localCatalog, remoteCatalog);
978
+ for (const entry of remoteCatalog.byComponentId.values()) {
979
+ const result = await pullResolvedComponent(context, entry.component, {
782
980
  force: options.force,
783
981
  stale: options.stale,
784
982
  promptOnStale: false,
785
- });
983
+ }, localCatalog);
786
984
  if (result) {
787
985
  results.push(result);
788
986
  }
@@ -790,39 +988,120 @@ async function pullAllComponents(context, options) {
790
988
  printSummary(context.println, 'Pulled', results, Date.now() - startedAt);
791
989
  return results;
792
990
  }
793
- async function pushSingleComponent(context, manifestPath) {
991
+ async function resolveRemoteComponentForLocal(context, component, identity, remoteCatalog) {
992
+ const localComponentId = componentIdOf(component);
993
+ if (identity.mode === 'componentId') {
994
+ if (!localComponentId) {
995
+ return null;
996
+ }
997
+ if (typeof context.client.getComponent !== 'function') {
998
+ return component;
999
+ }
1000
+ try {
1001
+ return unwrapComponent(await context.client.getComponent(localComponentId));
1002
+ }
1003
+ catch (error) {
1004
+ if (error instanceof ApiError && error.status === 404) {
1005
+ return null;
1006
+ }
1007
+ rethrowComponentAccessError(error, 'push');
1008
+ }
1009
+ }
1010
+ const identityKey = readComponentIdentityValue(component, identity);
1011
+ if (!identityKey) {
1012
+ return null;
1013
+ }
1014
+ const catalog = remoteCatalog || await buildRemoteComponentCatalog(context, identity);
1015
+ return catalog.byIdentity.get(identityKey)?.component || null;
1016
+ }
1017
+ function buildPushElements(component, remote, identity) {
1018
+ const remoteElementsByKey = new Map((remote.elements || []).map((element) => [element.key, element]));
1019
+ return (component.elements || []).map((element) => {
1020
+ const targetElementId = identity.mode === 'stableKey'
1021
+ ? remoteElementsByKey.get(element.key)?.componentElementId
1022
+ : element.componentElementId;
1023
+ return {
1024
+ ...(typeof targetElementId === 'string' && targetElementId
1025
+ ? { componentElementId: targetElementId }
1026
+ : {}),
1027
+ key: element.key,
1028
+ desc: element.desc,
1029
+ details: element.details || '',
1030
+ hidden: Boolean(element.hidden),
1031
+ order: element.order,
1032
+ };
1033
+ });
1034
+ }
1035
+ async function pushSingleComponent(context, manifestPath, remoteCatalog) {
794
1036
  const { client, println } = context;
795
1037
  const component = readWorkspaceComponent(manifestPath);
796
- const componentId = component.componentId || component.id;
1038
+ const localComponentId = componentIdOf(component);
797
1039
  const targetPath = componentWorkspacePath(context.cwd, manifestPath);
798
- if (!componentId) {
1040
+ const identity = resolveComponentIdentity(context.cwd);
1041
+ const identityKey = readComponentIdentityValue(component, identity);
1042
+ if (!localComponentId) {
799
1043
  throw new Error(`Missing componentId in ${manifestPath}.`);
800
1044
  }
801
1045
  if (!component.name) {
802
1046
  throw new Error(`Missing component name in ${manifestPath}.`);
803
1047
  }
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
- }));
1048
+ if (!identityKey) {
1049
+ const result = {
1050
+ status: 'skipped',
1051
+ targetPath,
1052
+ reason: `missing ${identityDescription(identity)}; unmanaged`,
1053
+ };
1054
+ printSyncStatus(println, {
1055
+ status: 'Skipped',
1056
+ direction: 'push',
1057
+ targetPath,
1058
+ reason: result.reason,
1059
+ });
1060
+ return result;
1061
+ }
1062
+ let resolvedRemoteCatalog = remoteCatalog;
1063
+ if (identity.mode === 'stableKey'
1064
+ && !componentLockMatchesIdentity(readComponentLock(getProjectRoot(context.cwd)), identity)) {
1065
+ resolvedRemoteCatalog = resolvedRemoteCatalog || await buildRemoteComponentCatalog(context, identity);
1066
+ migrateComponentLockIdentity(context.cwd, identity, buildLocalComponentCatalog(context.cwd, identity), resolvedRemoteCatalog);
1067
+ }
814
1068
  try {
1069
+ const remote = await resolveRemoteComponentForLocal(context, component, identity, resolvedRemoteCatalog);
1070
+ if (!remote) {
1071
+ const result = {
1072
+ status: 'skipped',
1073
+ targetPath,
1074
+ reason: "doesn't exist remotely",
1075
+ };
1076
+ printSyncStatus(println, {
1077
+ status: 'Skipped',
1078
+ direction: 'push',
1079
+ targetPath,
1080
+ reason: result.reason,
1081
+ });
1082
+ return result;
1083
+ }
1084
+ const remoteComponentId = componentIdOf(remote);
1085
+ if (!remoteComponentId) {
1086
+ throw new Error(`Remote component matched by ${identityDescription(identity)} "${identityKey}" has no componentId.`);
1087
+ }
815
1088
  if (!readBoolFlag(context.args, ['force', 'f'])) {
816
- let remote = null;
817
- try {
818
- remote = unwrapComponent(await client.getComponent(componentId));
1089
+ const lockEntry = getComponentLockEntry(readComponentLock(getProjectRoot(context.cwd)), identity, component);
1090
+ if (!lockEntry) {
1091
+ throw new Error(`Cannot push ${targetPath}: missing lock entry. Run \`revo component pull ${identityKey} --force\` to establish a remote baseline.`);
819
1092
  }
820
- catch (error) {
821
- if (error instanceof ApiError && error.status === 404) {
1093
+ if (lockEntry.remoteComponentId !== remoteComponentId) {
1094
+ throw new Error(`Cannot push ${targetPath}: ${identityDescription(identity)} "${identityKey}" now resolves to remote component ${remoteComponentId}, but the lock references ${lockEntry.remoteComponentId}. Pull/reconcile first.`);
1095
+ }
1096
+ const remoteHash = componentSourceHash(remote, identity);
1097
+ const localHash = componentSourceHash(component, identity);
1098
+ if (remoteHash !== lockEntry.remoteHash) {
1099
+ if (localHash === remoteHash) {
1100
+ upsertLockFromRemote(context.cwd, remote, targetPath, component);
822
1101
  const result = {
823
1102
  status: 'skipped',
824
1103
  targetPath,
825
- reason: "doesn't exist remotely",
1104
+ reason: 'local matches remote; lock refreshed',
826
1105
  };
827
1106
  printSyncStatus(println, {
828
1107
  status: 'Skipped',
@@ -832,17 +1111,8 @@ async function pushSingleComponent(context, manifestPath) {
832
1111
  });
833
1112
  return result;
834
1113
  }
835
- rethrowComponentAccessError(error, 'push');
836
- }
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.`);
840
- }
841
- const remoteHash = hashComponentSource(remote);
842
- if (remoteHash !== lockEntry.remoteHash) {
843
1114
  throw new Error(`Cannot push ${targetPath}: remote changed since the last lock baseline. Run \`revo component plan --all\` and pull/reconcile first.`);
844
1115
  }
845
- const localHash = hashComponentSource(component);
846
1116
  if (localHash === lockEntry.remoteHash) {
847
1117
  const result = {
848
1118
  status: 'skipped',
@@ -858,7 +1128,8 @@ async function pushSingleComponent(context, manifestPath) {
858
1128
  return result;
859
1129
  }
860
1130
  }
861
- const response = await client.saveComponentElements(componentId, elements);
1131
+ const elements = buildPushElements(component, remote, identity);
1132
+ const response = await client.saveComponentElements(remoteComponentId, elements);
862
1133
  if (response.status === 304) {
863
1134
  const result = {
864
1135
  status: 'skipped',
@@ -866,11 +1137,11 @@ async function pushSingleComponent(context, manifestPath) {
866
1137
  reason: 'no changes',
867
1138
  };
868
1139
  try {
869
- const remote = unwrapComponent(await client.getComponent(componentId));
870
- upsertLockFromRemote(context.cwd, remote, targetPath);
1140
+ const refreshedRemote = unwrapComponent(await client.getComponent(remoteComponentId));
1141
+ upsertLockFromRemote(context.cwd, refreshedRemote, targetPath, component);
871
1142
  }
872
1143
  catch {
873
- upsertLockFromRemote(context.cwd, component, targetPath);
1144
+ upsertLockFromRemote(context.cwd, remote, targetPath, component);
874
1145
  }
875
1146
  printSyncStatus(println, {
876
1147
  status: 'Skipped',
@@ -885,11 +1156,15 @@ async function pushSingleComponent(context, manifestPath) {
885
1156
  targetPath,
886
1157
  };
887
1158
  try {
888
- const remote = unwrapComponent(await client.getComponent(componentId));
889
- upsertLockFromRemote(context.cwd, remote, targetPath);
1159
+ const refreshedRemote = unwrapComponent(await client.getComponent(remoteComponentId));
1160
+ upsertLockFromRemote(context.cwd, refreshedRemote, targetPath, component);
890
1161
  }
891
1162
  catch {
892
- upsertLockFromRemote(context.cwd, component, targetPath);
1163
+ upsertLockFromRemote(context.cwd, {
1164
+ ...component,
1165
+ componentId: remoteComponentId,
1166
+ ...(componentVersion(remote) != null ? { version: componentVersion(remote) } : {}),
1167
+ }, targetPath, component);
893
1168
  }
894
1169
  printSyncStatus(println, {
895
1170
  status: 'Deployed',
@@ -911,7 +1186,6 @@ async function pushSingleComponent(context, manifestPath) {
911
1186
  targetPath,
912
1187
  reason: result.reason,
913
1188
  });
914
- upsertLockFromRemote(context.cwd, component, targetPath);
915
1189
  return result;
916
1190
  }
917
1191
  if (error instanceof PermissionDeniedError) {
@@ -937,6 +1211,7 @@ async function pushSingleComponent(context, manifestPath) {
937
1211
  async function pushAllComponents(context, options) {
938
1212
  const { cwd, println } = context;
939
1213
  const root = getWorkspaceRoot(cwd);
1214
+ const identity = resolveComponentIdentity(cwd);
940
1215
  let manifests = getComponentManifestPaths(root);
941
1216
  if (manifests.length === 0) {
942
1217
  println(`No component.json files found in ${path.relative(cwd, root) || 'Components'}.`);
@@ -954,10 +1229,16 @@ async function pushAllComponents(context, options) {
954
1229
  }
955
1230
  }
956
1231
  await confirmBulkAction(context, 'push', manifests.length, options.force || options.yes);
1232
+ const remoteCatalog = identity.mode === 'stableKey'
1233
+ ? await buildRemoteComponentCatalog(context, identity)
1234
+ : undefined;
1235
+ if (remoteCatalog) {
1236
+ migrateComponentLockIdentity(cwd, identity, buildLocalComponentCatalog(cwd, identity), remoteCatalog);
1237
+ }
957
1238
  const startedAt = Date.now();
958
1239
  const results = [];
959
1240
  for (const manifestPath of manifests) {
960
- const result = await pushSingleComponent(context, manifestPath);
1241
+ const result = await pushSingleComponent(context, manifestPath, remoteCatalog);
961
1242
  if (result) {
962
1243
  results.push(result);
963
1244
  }
@@ -969,52 +1250,40 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
969
1250
  const projectRoot = getProjectRoot(context.cwd);
970
1251
  const lock = readComponentLock(projectRoot);
971
1252
  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
- }
1253
+ const identity = resolveComponentIdentity(context.cwd);
1254
+ const localCatalog = buildLocalComponentCatalog(context.cwd, identity);
985
1255
  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),
1256
+ const remoteCatalog = await buildRemoteComponentCatalog(context, identity, components);
1257
+ const lockEntries = componentLockMatchesIdentity(lock, identity) ? lock.components : {};
1258
+ const allIdentityKeys = [...new Set([
1259
+ ...localCatalog.byIdentity.keys(),
1260
+ ...remoteCatalog.byIdentity.keys(),
1261
+ ...Object.keys(lockEntries),
999
1262
  ])].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;
1263
+ const items = allIdentityKeys.map((identityKey) => {
1264
+ const local = localCatalog.byIdentity.get(identityKey) || null;
1265
+ const remote = remoteCatalog.byIdentity.get(identityKey) || null;
1266
+ const lockEntry = lockEntries[identityKey] || null;
1004
1267
  const component = local?.component || remote?.component;
1005
- const componentName = component?.name || lockEntry?.componentName || componentId;
1268
+ const localComponentId = local ? componentIdOf(local.component) : '';
1269
+ const remoteComponentId = remote ? componentIdOf(remote.component) : lockEntry?.remoteComponentId || '';
1270
+ const componentId = localComponentId || remoteComponentId || lockEntry?.componentId || identityKey;
1271
+ const componentName = component?.name || lockEntry?.componentName || identityKey;
1006
1272
  const category = normalizeValue(component?.category, lockEntry?.category ?? null);
1007
- const targetPath = local
1273
+ const targetPath = local?.manifestPath
1008
1274
  ? componentWorkspacePath(context.cwd, local.manifestPath)
1009
1275
  : remote
1010
1276
  ? componentTargetPath(context.cwd, remote.component)
1011
- : lockEntry?.path || componentId;
1277
+ : lockEntry?.path || identityKey;
1012
1278
  const localHash = local?.hash || null;
1013
1279
  const remoteHash = remote?.hash || null;
1014
1280
  const lockHash = lockEntry?.remoteHash || null;
1015
1281
  let status = 'clean';
1016
1282
  let reason = 'local, lock, and remote match';
1017
- if (!local && remote) {
1283
+ if (!local && !remote) {
1284
+ reason = 'lock entry has no matching local or remote component';
1285
+ }
1286
+ else if (!local && remote) {
1018
1287
  status = 'safe-update';
1019
1288
  reason = 'remote component is missing locally';
1020
1289
  }
@@ -1045,9 +1314,14 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
1045
1314
  status = 'conflict';
1046
1315
  reason = 'local and remote changed since lock';
1047
1316
  }
1048
- const inScope = !scope.enabled || scope.componentIds.has(componentId);
1317
+ const inScope = !scope.enabled
1318
+ || scope.componentIds.has(localComponentId)
1319
+ || scope.componentIds.has(remoteComponentId);
1049
1320
  return {
1050
1321
  componentId,
1322
+ identityKey,
1323
+ ...(identity.mode === 'stableKey' ? { stableKey: identityKey } : {}),
1324
+ ...(remoteComponentId ? { remoteComponentId } : {}),
1051
1325
  componentName,
1052
1326
  category,
1053
1327
  path: targetPath,
@@ -1065,6 +1339,11 @@ async function buildComponentPlan(context, scope = { enabled: false, base: '', c
1065
1339
  schemaVersion: 1,
1066
1340
  generatedAt: new Date().toISOString(),
1067
1341
  workspaceRoot: path.relative(context.cwd, root) || 'Components',
1342
+ componentIdentity: identity,
1343
+ unmanaged: {
1344
+ local: localCatalog.unmanaged.length,
1345
+ remote: remoteCatalog.unmanaged.length,
1346
+ },
1068
1347
  ...(scope.enabled ? {
1069
1348
  scope: {
1070
1349
  mode: 'changed',
@@ -1140,21 +1419,45 @@ function buildDebugElements(component) {
1140
1419
  if (!componentName) {
1141
1420
  throw new Error('Missing component name in local manifest.');
1142
1421
  }
1143
- return [...(component.elements || [])].sort((left, right) => left.order - right.order).map((element) => {
1422
+ return sortDebugElements(component.elements || [], componentName).map((element) => {
1423
+ const key = readDebugElementKey(element, componentName);
1424
+ const order = readDebugElementOrder(element, key, componentName);
1144
1425
  const details = element.details || '';
1145
1426
  return {
1146
- key: element.key,
1427
+ key,
1147
1428
  desc: element.desc ?? null,
1148
1429
  initValue: details,
1149
1430
  details,
1150
1431
  componentId,
1151
1432
  componentName,
1152
- order: element.order,
1433
+ order,
1153
1434
  hidden: Boolean(element.hidden),
1154
1435
  uuid: typeof element.uuid === 'string' ? element.uuid : undefined,
1155
1436
  };
1156
1437
  });
1157
1438
  }
1439
+ function readDebugElementKey(element, componentName) {
1440
+ if (typeof element.key === 'string' && element.key.trim()) {
1441
+ return element.key;
1442
+ }
1443
+ const order = Number.isFinite(Number(element.order)) ? Number(element.order) : 'unknown';
1444
+ throw new Error(`Debug element at order ${order} in "${componentName}" is missing a valid key.`);
1445
+ }
1446
+ function readDebugElementOrder(element, key, componentName) {
1447
+ const order = Number(element.order);
1448
+ if (Number.isFinite(order)) {
1449
+ return order;
1450
+ }
1451
+ throw new Error(`Debug element "${key}" in "${componentName}" is missing a valid numeric order.`);
1452
+ }
1453
+ function sortDebugElements(elements, componentName) {
1454
+ return [...elements].sort((left, right) => {
1455
+ const leftOrder = readDebugElementOrder(left, readDebugElementKey(left, componentName), componentName);
1456
+ const rightOrder = readDebugElementOrder(right, readDebugElementKey(right, componentName), componentName);
1457
+ return leftOrder - rightOrder
1458
+ || readDebugElementKey(left, componentName).localeCompare(readDebugElementKey(right, componentName));
1459
+ });
1460
+ }
1158
1461
  function resolveElementSourcePath(manifestPath, component, element) {
1159
1462
  const componentDir = path.dirname(manifestPath);
1160
1463
  const detailDirs = [
@@ -1184,12 +1487,14 @@ function buildDebugSourceComponent(role, manifestPath, component) {
1184
1487
  componentName: component.name || '',
1185
1488
  type: normalizeComponentType(component),
1186
1489
  manifestPath,
1187
- elements: [...(component.elements || [])].sort((left, right) => left.order - right.order).map((element) => {
1490
+ elements: sortDebugElements(component.elements || [], component.name || component.componentId || component.id || 'component').map((element) => {
1491
+ const key = readDebugElementKey(element, component.name || component.componentId || component.id || 'component');
1492
+ const order = readDebugElementOrder(element, key, component.name || component.componentId || component.id || 'component');
1188
1493
  const sourcePath = resolveElementSourcePath(manifestPath, component, element);
1189
1494
  const details = element.details || '';
1190
1495
  return {
1191
- key: element.key,
1192
- order: element.order,
1496
+ key,
1497
+ order,
1193
1498
  sourcePath,
1194
1499
  bytes: Buffer.byteLength(details, 'utf8'),
1195
1500
  };
@@ -1243,12 +1548,14 @@ function collectDebugExtraLibs(cwd, currentComponentId) {
1243
1548
  .filter(({ component }) => {
1244
1549
  const type = normalizeComponentType(component);
1245
1550
  const componentId = component.componentId || component.id || '';
1246
- return isDebugLibraryType(type) && componentId !== currentComponentId && Boolean(component.name);
1551
+ return isDebugLibraryType(type) && Boolean(componentId) && componentId !== currentComponentId && Boolean(component.name);
1247
1552
  })
1248
1553
  .map(({ manifestPath, component }) => ({
1249
1554
  payload: {
1555
+ componentId: component.componentId || component.id || '',
1250
1556
  name: component.name,
1251
1557
  type: normalizeComponentType(component),
1558
+ ...(typeof component.version === 'number' ? { version: component.version } : {}),
1252
1559
  elements: buildDebugElements(component),
1253
1560
  },
1254
1561
  source: buildDebugSourceComponent('extraLib', manifestPath, component),
@@ -1315,7 +1622,8 @@ async function debugSingleComponent(context, componentId) {
1315
1622
  const timeout = parseDebugNumber(context.args, ['timeout', 't'], 10, 600, 'Timeout');
1316
1623
  const memory = parseDebugNumber(context.args, ['memory', 'm'], 128, 1024, 'Memory');
1317
1624
  const stream = readBoolFlag(context.args, ['stream']);
1318
- const includeExtraLibs = !readBoolFlag(context.args, ['no-extra-libs']);
1625
+ const includeExtraLibs = context.args['extra-libs'] !== false
1626
+ && !readBoolFlag(context.args, ['no-extra-libs']);
1319
1627
  const rawDebug = readBoolFlag(context.args, ['raw-debug', 'debug-raw', 'debug-payload']);
1320
1628
  const profile = await context.client.me();
1321
1629
  const sandboxEndpoint = extractSandboxEndpoint(profile);
@@ -1436,7 +1744,7 @@ export async function handleComponentCommand(context) {
1436
1744
  await pushAllComponents(context, { force, yes, scope: changedScope });
1437
1745
  return;
1438
1746
  }
1439
- const manifests = targets.flatMap((componentId) => findComponentManifestsById(context.cwd, componentId));
1747
+ const manifests = targets.flatMap((target) => findComponentManifestsByTarget(context.cwd, target));
1440
1748
  if (manifests.length === 0) {
1441
1749
  throw new Error(`Unable to find local component for ${targets.join(', ')}.`);
1442
1750
  }