@revoengine/cli 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,17 +90,16 @@ Pull every available component:
90
90
 
91
91
  ```bash
92
92
  revo component pull --all
93
+ revo component pull --all --yes
93
94
  revo component pull --all --force
94
95
  ```
95
96
 
96
- Pull one or more remote components:
97
+ Preview sync status before changing local or remote state:
97
98
 
98
99
  ```bash
99
- revo component pull 6dfb536a-1111-4222-8333-123456789abc
100
- revo component pull 6dfb536a-1111-4222-8333-123456789abc --stale
101
- revo component pull --all
102
- revo component pull --all --stale
103
- revo component pull --all --force
100
+ revo component plan --all
101
+ revo component plan --all --json
102
+ revo component plan --all --strict
104
103
  ```
105
104
 
106
105
  Push one or more local components:
@@ -108,6 +107,7 @@ Push one or more local components:
108
107
  ```bash
109
108
  revo component push 6dfb536a-1111-4222-8333-123456789abc
110
109
  revo component push --all
110
+ revo component push --all --yes
111
111
  revo component push --all --force
112
112
  ```
113
113
 
@@ -135,14 +135,20 @@ Components/
135
135
  Each component folder keeps its `component.json` manifest alongside an `elements/` directory with the source payload for every element.
136
136
  Components with `category: null` are stored under `Components/__no_category__/...` while the manifest keeps `"category": null`.
137
137
 
138
+ Remote sync state is stored separately in `.revoengine/revo.lock.json`. The lock file records the last remote component hash/version that this workspace was pulled from or successfully pushed to. It is a baseline, not the deployed source of truth; the server remains authoritative for deployed state and local component files remain the desired source.
139
+
138
140
  Bulk sync behavior:
139
141
 
140
- - `revo component pull --all` and `revo component push --all` require terminal confirmation unless `--force` is passed.
142
+ - `revo component pull --all` and `revo component push --all` require terminal confirmation unless `--yes` or `--force` is passed.
143
+ - `--yes` only confirms the bulk operation. `--force` is the destructive escape hatch that bypasses lock safety.
141
144
  - `revo component pull --all` requests only active remote components where `deletedAt` is empty.
142
- - Pull compares the full local workspace contract before overwriting anything.
143
- - Pull skips with `no changes` when the local workspace already matches the remote component.
144
- - Pull skips with `changed` when local files differ from the remote contract.
145
- - Pull skips with `stale version` when the local version is older than the remote version, unless `--stale` or `--force` is passed.
145
+ - Pull compares local files, `.revoengine/revo.lock.json`, and the current remote component before overwriting anything.
146
+ - Pull fast-forwards clean local files when the remote moved and the local copy still matches the lock.
147
+ - Pull rejects conflicts when both local files and the remote changed since the lock baseline.
148
+ - Pull writes stable source manifests and keeps remote version churn out of `Components/**/component.json`.
149
+ - `revo component plan --all --strict` exits with an error when conflicts or missing lock entries are present.
150
+ - Push checks the current remote component against the lock before saving local changes.
151
+ - Push rejects stale remote state instead of trusting the version inside local component JSON.
146
152
  - Push treats backend `Not modified` responses as skipped instead of failing the whole run.
147
153
  - Push treats backend `404` responses as skipped with `doesn't exist remotely`; restore the component in RevoEngine before pushing local changes to it.
148
154
  - Debug posts the local `component.json` plus `elements/{order}_{key}.{js|ts}` source files to the authenticated sandbox `debug` endpoint.
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ApiError, PermissionDeniedError } from "../client.js";
4
- import { buildSandboxDebugStreamUrl, buildSandboxDebugUrl, extractSandboxEndpoint, resolveProjectWorkspace } from "../project.js";
4
+ import { hashComponentSource, hashStable, readComponentLock, sanitizeComponentSource, upsertComponentLockEntry, } from "../component-lock.js";
5
+ import { buildSandboxDebugStreamUrl, buildSandboxDebugUrl, extractSandboxEndpoint, resolveProjectRoot, resolveProjectWorkspace, } from "../project.js";
5
6
  import { isInteractiveTerminal, promptConfirm } from "../prompt.js";
6
7
  import { deepClone, readBoolFlag, readFlag, readValues, sanitizeSegment, writeJsonFile } from "../utils.js";
7
8
  const NULL_CATEGORY_FOLDER = '__no_category__';
@@ -195,6 +196,9 @@ function getWorkspaceRoot(cwd) {
195
196
  const projectWorkspace = resolveProjectWorkspace(cwd);
196
197
  return path.join(projectWorkspace || cwd, 'Components');
197
198
  }
199
+ function getProjectRoot(cwd) {
200
+ return resolveProjectRoot(cwd) || cwd;
201
+ }
198
202
  function getCategoryFolder(component) {
199
203
  if (component.category == null || component.category === '') {
200
204
  return NULL_CATEGORY_FOLDER;
@@ -211,7 +215,7 @@ function getDetailsExtension(component) {
211
215
  return componentTypeToExtension(normalizeComponentType(component));
212
216
  }
213
217
  function stripDetails(component) {
214
- const clone = deepClone(component);
218
+ const clone = sanitizeComponentSource(deepClone(component));
215
219
  clone.elements = (clone.elements || []).map((element) => {
216
220
  const next = deepClone(element);
217
221
  delete next.details;
@@ -406,28 +410,47 @@ function normalizeElementContract(element) {
406
410
  };
407
411
  }
408
412
  function normalizeComponentContract(component) {
409
- const componentId = component.componentId || component.id || '';
410
- return {
411
- componentId,
412
- name: component.name || '',
413
- category: normalizeValue(component.category),
414
- desc: normalizeValue(component.desc),
415
- active: component.active ?? true,
413
+ return hashStable({
414
+ ...sanitizeComponentSource(component),
416
415
  type: normalizeComponentType(component),
417
416
  compiler: compilerFromType(component.type, component.compiler),
418
- version: typeof component.version === 'number' ? component.version : null,
419
417
  elements: [...(component.elements || [])]
420
418
  .map((element) => normalizeElementContract(element))
421
419
  .sort((left, right) => left.order - right.order || left.key.localeCompare(right.key)),
422
- };
420
+ });
423
421
  }
424
422
  function isSameComponentContract(left, right) {
425
- return JSON.stringify(normalizeComponentContract(left)) === JSON.stringify(normalizeComponentContract(right));
423
+ return normalizeComponentContract(left) === normalizeComponentContract(right);
424
+ }
425
+ function componentVersion(component) {
426
+ return typeof component.version === 'number' ? component.version : null;
427
+ }
428
+ function componentIdOf(component) {
429
+ return component.componentId || component.id || '';
430
+ }
431
+ function componentTargetPath(cwd, component) {
432
+ return path.relative(cwd, path.join(getWorkspaceRoot(cwd), getComponentFolder(component)));
433
+ }
434
+ function upsertLockFromRemote(cwd, remote, targetPath) {
435
+ const componentId = componentIdOf(remote);
436
+ if (!componentId) {
437
+ return;
438
+ }
439
+ const remoteHash = hashComponentSource(remote);
440
+ upsertComponentLockEntry(getProjectRoot(cwd), {
441
+ componentId,
442
+ componentName: remote.name || '',
443
+ category: normalizeValue(remote.category),
444
+ path: targetPath || componentTargetPath(cwd, remote),
445
+ remoteVersion: componentVersion(remote),
446
+ remoteHash,
447
+ sourceHash: remoteHash,
448
+ pulledAt: new Date().toISOString(),
449
+ });
426
450
  }
427
451
  function buildPullDecision(cwd, remote) {
428
- const remoteFolder = path.join(getWorkspaceRoot(cwd), getComponentFolder(remote));
429
- const remoteTargetPath = path.relative(cwd, remoteFolder);
430
- const componentId = remote.componentId || remote.id || '';
452
+ const remoteTargetPath = componentTargetPath(cwd, remote);
453
+ const componentId = componentIdOf(remote);
431
454
  const localInfo = componentId ? findLocalManifestInfo(cwd, componentId) : null;
432
455
  if (!localInfo?.manifestPath) {
433
456
  return {
@@ -443,13 +466,42 @@ function buildPullDecision(cwd, remote) {
443
466
  reason: 'changed',
444
467
  };
445
468
  }
446
- const localVersion = typeof localComponent.version === 'number' ? localComponent.version : null;
447
- const remoteVersion = typeof remote.version === 'number' ? remote.version : null;
448
- if (localVersion !== null && remoteVersion !== null && localVersion < remoteVersion) {
469
+ const lockEntry = readComponentLock(getProjectRoot(cwd)).components[componentId];
470
+ const localHash = hashComponentSource(localComponent);
471
+ const remoteHash = hashComponentSource(remote);
472
+ if (!lockEntry) {
473
+ if (localHash === remoteHash) {
474
+ return {
475
+ kind: 'skip',
476
+ targetPath: localInfo.targetPath || remoteTargetPath,
477
+ reason: 'no changes',
478
+ };
479
+ }
480
+ return {
481
+ kind: 'skip',
482
+ targetPath: localInfo.targetPath || remoteTargetPath,
483
+ reason: 'missing lock',
484
+ };
485
+ }
486
+ if (localHash === lockEntry.remoteHash && remoteHash !== lockEntry.remoteHash) {
487
+ return {
488
+ kind: 'pull',
489
+ targetPath: localInfo.targetPath || remoteTargetPath,
490
+ reason: 'remote changed',
491
+ };
492
+ }
493
+ if (localHash !== lockEntry.remoteHash && remoteHash === lockEntry.remoteHash) {
449
494
  return {
450
495
  kind: 'skip',
451
496
  targetPath: localInfo.targetPath || remoteTargetPath,
452
- reason: 'stale version',
497
+ reason: 'local changes',
498
+ };
499
+ }
500
+ if (localHash !== lockEntry.remoteHash && remoteHash !== lockEntry.remoteHash && localHash !== remoteHash) {
501
+ return {
502
+ kind: 'skip',
503
+ targetPath: localInfo.targetPath || remoteTargetPath,
504
+ reason: 'conflict',
453
505
  };
454
506
  }
455
507
  if (isSameComponentContract(localComponent, remote)) {
@@ -534,6 +586,12 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
534
586
  }
535
587
  }
536
588
  else {
589
+ if (decision.reason === 'conflict' || decision.reason === 'missing lock') {
590
+ throw new Error(`Cannot pull ${componentId}: ${decision.reason} at ${decision.targetPath}. Re-run with --force only if you want to overwrite local files.`);
591
+ }
592
+ if (decision.reason === 'no changes') {
593
+ upsertLockFromRemote(cwd, component, decision.targetPath);
594
+ }
537
595
  const result = {
538
596
  status: 'skipped',
539
597
  targetPath: decision.targetPath,
@@ -560,6 +618,7 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
560
618
  fs.writeFileSync(filePath, content);
561
619
  }
562
620
  writeJsonFile(path.join(folder, 'component.json'), stripDetails(component));
621
+ upsertLockFromRemote(cwd, component, targetPath);
563
622
  const result = {
564
623
  status: 'pulled',
565
624
  targetPath,
@@ -575,7 +634,7 @@ async function pullSingleComponent(context, componentId, mode = { force: false,
575
634
  });
576
635
  return result;
577
636
  }
578
- async function pullAllComponents(context, options) {
637
+ async function fetchAllComponentSummaries(context) {
579
638
  const { client, cwd } = context;
580
639
  const components = [];
581
640
  const seenRequests = new Set();
@@ -623,11 +682,18 @@ async function pullAllComponents(context, options) {
623
682
  }
624
683
  nextRequest = null;
625
684
  }
685
+ if (components.length === 0) {
686
+ return { components, discoveredTotal };
687
+ }
688
+ return { components, discoveredTotal };
689
+ }
690
+ async function pullAllComponents(context, options) {
691
+ const { components, discoveredTotal } = await fetchAllComponentSummaries(context);
626
692
  if (components.length === 0) {
627
693
  context.println('No components found.');
628
694
  return [];
629
695
  }
630
- await confirmBulkAction(context, 'pull', discoveredTotal ?? (components.length >= COMPONENT_LIST_PAGE_SIZE ? `at least ${components.length} components` : components.length), options.force);
696
+ await confirmBulkAction(context, 'pull', discoveredTotal ?? (components.length >= COMPONENT_LIST_PAGE_SIZE ? `at least ${components.length} components` : components.length), options.force || options.yes);
631
697
  const startedAt = Date.now();
632
698
  const results = [];
633
699
  for (const item of components) {
@@ -666,6 +732,52 @@ async function pushSingleComponent(context, manifestPath) {
666
732
  order: element.order,
667
733
  }));
668
734
  try {
735
+ if (!readBoolFlag(context.args, ['force', 'f'])) {
736
+ let remote = null;
737
+ try {
738
+ remote = unwrapComponent(await client.getComponent(componentId));
739
+ }
740
+ catch (error) {
741
+ if (error instanceof ApiError && error.status === 404) {
742
+ const result = {
743
+ status: 'skipped',
744
+ targetPath,
745
+ reason: "doesn't exist remotely",
746
+ };
747
+ printSyncStatus(println, {
748
+ status: 'Skipped',
749
+ direction: 'push',
750
+ targetPath,
751
+ reason: result.reason,
752
+ });
753
+ return result;
754
+ }
755
+ rethrowComponentAccessError(error, 'push');
756
+ }
757
+ const lockEntry = readComponentLock(getProjectRoot(context.cwd)).components[componentId];
758
+ if (!lockEntry) {
759
+ throw new Error(`Cannot push ${targetPath}: missing lock entry. Run \`revo component pull ${componentId} --force\` to establish a remote baseline.`);
760
+ }
761
+ const remoteHash = hashComponentSource(remote);
762
+ if (remoteHash !== lockEntry.remoteHash) {
763
+ throw new Error(`Cannot push ${targetPath}: remote changed since the last lock baseline. Run \`revo component plan --all\` and pull/reconcile first.`);
764
+ }
765
+ const localHash = hashComponentSource(component);
766
+ if (localHash === lockEntry.remoteHash) {
767
+ const result = {
768
+ status: 'skipped',
769
+ targetPath,
770
+ reason: 'no local changes',
771
+ };
772
+ printSyncStatus(println, {
773
+ status: 'Skipped',
774
+ direction: 'push',
775
+ targetPath,
776
+ reason: result.reason,
777
+ });
778
+ return result;
779
+ }
780
+ }
669
781
  const response = await client.saveComponentElements(componentId, elements);
670
782
  if (response.status === 304) {
671
783
  const result = {
@@ -685,6 +797,13 @@ async function pushSingleComponent(context, manifestPath) {
685
797
  status: 'deployed',
686
798
  targetPath,
687
799
  };
800
+ try {
801
+ const remote = unwrapComponent(await client.getComponent(componentId));
802
+ upsertLockFromRemote(context.cwd, remote, targetPath);
803
+ }
804
+ catch {
805
+ upsertLockFromRemote(context.cwd, component, targetPath);
806
+ }
688
807
  printSyncStatus(println, {
689
808
  status: 'Deployed',
690
809
  direction: 'push',
@@ -705,6 +824,7 @@ async function pushSingleComponent(context, manifestPath) {
705
824
  targetPath,
706
825
  reason: result.reason,
707
826
  });
827
+ upsertLockFromRemote(context.cwd, component, targetPath);
708
828
  return result;
709
829
  }
710
830
  if (error instanceof PermissionDeniedError) {
@@ -735,7 +855,7 @@ async function pushAllComponents(context, options) {
735
855
  println(`No component.json files found in ${path.relative(cwd, root) || 'Components'}.`);
736
856
  return [];
737
857
  }
738
- await confirmBulkAction(context, 'push', manifests.length, options.force);
858
+ await confirmBulkAction(context, 'push', manifests.length, options.force || options.yes);
739
859
  const startedAt = Date.now();
740
860
  const results = [];
741
861
  for (const manifestPath of manifests) {
@@ -747,6 +867,124 @@ async function pushAllComponents(context, options) {
747
867
  printSummary(context.println, 'Deployed', results, Date.now() - startedAt);
748
868
  return results;
749
869
  }
870
+ async function buildComponentPlan(context) {
871
+ const projectRoot = getProjectRoot(context.cwd);
872
+ const lock = readComponentLock(projectRoot);
873
+ const root = getWorkspaceRoot(context.cwd);
874
+ const localManifests = getComponentManifestPaths(root);
875
+ const localById = new Map();
876
+ for (const manifestPath of localManifests) {
877
+ const component = readWorkspaceComponentSafe(manifestPath);
878
+ const componentId = component ? componentIdOf(component) : '';
879
+ if (component && componentId) {
880
+ localById.set(componentId, {
881
+ manifestPath,
882
+ component,
883
+ hash: hashComponentSource(component),
884
+ });
885
+ }
886
+ }
887
+ const { components } = await fetchAllComponentSummaries(context);
888
+ const remoteIds = components.map((item) => String(item.componentId || item.id || '')).filter(Boolean);
889
+ const remoteById = new Map();
890
+ for (const componentId of remoteIds) {
891
+ const remote = unwrapComponent(await context.client.getComponent(componentId));
892
+ remoteById.set(componentId, {
893
+ component: remote,
894
+ hash: hashComponentSource(remote),
895
+ });
896
+ }
897
+ const allIds = [...new Set([
898
+ ...localById.keys(),
899
+ ...remoteById.keys(),
900
+ ...Object.keys(lock.components),
901
+ ])].sort();
902
+ const items = allIds.map((componentId) => {
903
+ const local = localById.get(componentId) || null;
904
+ const remote = remoteById.get(componentId) || null;
905
+ const lockEntry = lock.components[componentId] || null;
906
+ const component = local?.component || remote?.component;
907
+ const componentName = component?.name || lockEntry?.componentName || componentId;
908
+ const category = normalizeValue(component?.category, lockEntry?.category ?? null);
909
+ const targetPath = local
910
+ ? componentWorkspacePath(context.cwd, local.manifestPath)
911
+ : remote
912
+ ? componentTargetPath(context.cwd, remote.component)
913
+ : lockEntry?.path || componentId;
914
+ const localHash = local?.hash || null;
915
+ const remoteHash = remote?.hash || null;
916
+ const lockHash = lockEntry?.remoteHash || null;
917
+ let status = 'clean';
918
+ let reason = 'local, lock, and remote match';
919
+ if (!local && remote) {
920
+ status = 'safe-update';
921
+ reason = 'remote component is missing locally';
922
+ }
923
+ else if (local && !remote) {
924
+ status = 'create';
925
+ reason = 'local component does not exist remotely';
926
+ }
927
+ else if (!lockEntry) {
928
+ status = localHash === remoteHash ? 'clean' : 'missing-lock';
929
+ reason = localHash === remoteHash ? 'local matches remote; lock can be adopted' : 'no lock baseline exists';
930
+ }
931
+ else if (localHash === lockHash && remoteHash === lockHash) {
932
+ status = 'clean';
933
+ }
934
+ else if (localHash !== lockHash && remoteHash === lockHash) {
935
+ status = 'local-changed';
936
+ reason = 'local source changed and remote still matches lock';
937
+ }
938
+ else if (localHash === lockHash && remoteHash !== lockHash) {
939
+ status = 'remote-changed';
940
+ reason = 'remote changed and local is clean';
941
+ }
942
+ else if (localHash === remoteHash) {
943
+ status = 'clean';
944
+ reason = 'local matches remote; lock is stale';
945
+ }
946
+ else {
947
+ status = 'conflict';
948
+ reason = 'local and remote changed since lock';
949
+ }
950
+ return {
951
+ componentId,
952
+ componentName,
953
+ category,
954
+ path: targetPath,
955
+ status,
956
+ localHash,
957
+ lockHash,
958
+ remoteHash,
959
+ remoteVersion: remote ? componentVersion(remote.component) : lockEntry?.remoteVersion ?? null,
960
+ reason,
961
+ };
962
+ });
963
+ return {
964
+ schemaVersion: 1,
965
+ generatedAt: new Date().toISOString(),
966
+ workspaceRoot: path.relative(context.cwd, root) || 'Components',
967
+ counts: items.reduce((counts, item) => {
968
+ counts[item.status] += 1;
969
+ return counts;
970
+ }, {
971
+ clean: 0,
972
+ 'local-changed': 0,
973
+ 'remote-changed': 0,
974
+ 'safe-update': 0,
975
+ conflict: 0,
976
+ create: 0,
977
+ 'missing-lock': 0,
978
+ }),
979
+ items,
980
+ };
981
+ }
982
+ function printPlan(context, plan) {
983
+ for (const item of plan.items) {
984
+ context.println(`${item.status.padEnd(14)} ${item.path} ${item.reason ? `(${item.reason})` : ''}`.trimEnd());
985
+ }
986
+ context.println(`Plan: ${plan.items.length} components, ${plan.counts.conflict} conflicts, ${plan.counts['local-changed']} local changes, ${plan.counts['remote-changed']} remote changes.`);
987
+ }
750
988
  function parseTargets(args, names) {
751
989
  const values = readValues(args, names);
752
990
  if (values.length > 0) {
@@ -965,13 +1203,35 @@ export async function handleComponentCommand(context) {
965
1203
  const targets = parseTargets(args, ['id', 'i']);
966
1204
  const all = readFlag(args, ['all', 'a']) === 'true' || args.all === true || args.a === true;
967
1205
  const force = readBoolFlag(args, ['force', 'f']);
1206
+ const yes = readBoolFlag(args, ['yes', 'y']);
968
1207
  const stale = readBoolFlag(args, ['stale', 's']);
1208
+ const json = readBoolFlag(args, ['json']);
1209
+ const strict = readBoolFlag(args, ['strict']);
969
1210
  if (subcommand === 'pull-all') {
970
- await pullAllComponents(context, { force, stale });
1211
+ await pullAllComponents(context, { force, stale, yes });
971
1212
  return;
972
1213
  }
973
1214
  if (subcommand === 'push-all') {
974
- await pushAllComponents(context, { force });
1215
+ await pushAllComponents(context, { force, yes });
1216
+ return;
1217
+ }
1218
+ if (subcommand === 'plan') {
1219
+ const plan = await buildComponentPlan(context);
1220
+ if (json) {
1221
+ context.print(plan);
1222
+ }
1223
+ else {
1224
+ printPlan(context, plan);
1225
+ }
1226
+ if (strict) {
1227
+ const blocking = plan.items.filter((item) => (item.status === 'conflict'
1228
+ || item.status === 'missing-lock'
1229
+ || item.status === 'remote-changed'
1230
+ || item.status === 'safe-update'));
1231
+ if (blocking.length > 0) {
1232
+ throw new Error(`Component plan is not safe: ${blocking.length} blocking item(s).`);
1233
+ }
1234
+ }
975
1235
  return;
976
1236
  }
977
1237
  if (subcommand === 'debug') {
@@ -987,7 +1247,7 @@ export async function handleComponentCommand(context) {
987
1247
  throw new Error('Provide --id <componentId> or --all.');
988
1248
  }
989
1249
  if (all) {
990
- await pullAllComponents(context, { force, stale });
1250
+ await pullAllComponents(context, { force, stale, yes });
991
1251
  return;
992
1252
  }
993
1253
  for (const componentId of targets) {
@@ -1000,7 +1260,7 @@ export async function handleComponentCommand(context) {
1000
1260
  throw new Error('Provide --id <componentId> or --all.');
1001
1261
  }
1002
1262
  if (all) {
1003
- await pushAllComponents(context, { force });
1263
+ await pushAllComponents(context, { force, yes });
1004
1264
  return;
1005
1265
  }
1006
1266
  const manifests = targets.flatMap((componentId) => findComponentManifestsById(context.cwd, componentId));
@@ -0,0 +1,40 @@
1
+ import type { ComponentRecord } from './types.ts';
2
+ export declare const REVO_LOCK_FILE: string;
3
+ export type ComponentLockEntry = {
4
+ componentId: string;
5
+ componentName: string;
6
+ category: string | null;
7
+ path: string;
8
+ remoteVersion: number | null;
9
+ remoteHash: string;
10
+ sourceHash: string;
11
+ pulledAt: string;
12
+ };
13
+ export type RevoLockFile = {
14
+ schemaVersion: 1;
15
+ components: Record<string, ComponentLockEntry>;
16
+ };
17
+ export declare function hashStable(value: unknown): string;
18
+ export declare function sanitizeComponentSource<T extends ComponentRecord>(component: T): T;
19
+ export declare function normalizeComponentSource(component: ComponentRecord): {
20
+ componentId: string;
21
+ name: string;
22
+ category: string | null;
23
+ desc: string | null;
24
+ active: boolean;
25
+ type: string;
26
+ compiler: string;
27
+ async: boolean;
28
+ elements: {
29
+ key: string;
30
+ desc: string | null;
31
+ hidden: boolean;
32
+ order: number;
33
+ details: string;
34
+ }[];
35
+ };
36
+ export declare function hashComponentSource(component: ComponentRecord): string;
37
+ export declare function getLockPath(projectRoot: string): string;
38
+ export declare function readComponentLock(projectRoot: string): RevoLockFile;
39
+ export declare function writeComponentLock(projectRoot: string, lock: RevoLockFile): void;
40
+ export declare function upsertComponentLockEntry(projectRoot: string, entry: ComponentLockEntry): void;
@@ -0,0 +1,107 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { REVO_PROJECT_DIR } from "./project.js";
5
+ import { readJsonFile, writeJsonFile } from "./utils.js";
6
+ export const REVO_LOCK_FILE = path.join(REVO_PROJECT_DIR, 'revo.lock.json');
7
+ const VOLATILE_COMPONENT_FIELDS = new Set([
8
+ 'version',
9
+ 'revision',
10
+ 'deployedAt',
11
+ 'lastDeployedAt',
12
+ 'lastSyncAt',
13
+ 'lastUpdateAt',
14
+ 'lastUpdateBy',
15
+ 'createdAt',
16
+ 'createdBy',
17
+ 'updatedAt',
18
+ 'updatedBy',
19
+ 'deletedAt',
20
+ 'deletedBy',
21
+ 'hash',
22
+ 'remoteHash',
23
+ ]);
24
+ function normalizeValue(value, fallback = null) {
25
+ return value ?? fallback;
26
+ }
27
+ function stableObject(value) {
28
+ if (Array.isArray(value)) {
29
+ return value.map((item) => stableObject(item));
30
+ }
31
+ if (!value || typeof value !== 'object') {
32
+ return value;
33
+ }
34
+ const output = {};
35
+ for (const key of Object.keys(value).sort()) {
36
+ output[key] = stableObject(value[key]);
37
+ }
38
+ return output;
39
+ }
40
+ export function hashStable(value) {
41
+ return crypto
42
+ .createHash('sha256')
43
+ .update(JSON.stringify(stableObject(value)))
44
+ .digest('hex');
45
+ }
46
+ export function sanitizeComponentSource(component) {
47
+ const clone = JSON.parse(JSON.stringify(component));
48
+ for (const field of VOLATILE_COMPONENT_FIELDS) {
49
+ delete clone[field];
50
+ }
51
+ return clone;
52
+ }
53
+ export function normalizeComponentSource(component) {
54
+ const source = sanitizeComponentSource(component);
55
+ const componentId = source.componentId || source.id || '';
56
+ return {
57
+ componentId,
58
+ name: source.name || '',
59
+ category: normalizeValue(source.category),
60
+ desc: normalizeValue(source.desc),
61
+ active: source.active ?? true,
62
+ type: source.type || '',
63
+ compiler: source.compiler || '',
64
+ async: Boolean(source.async),
65
+ elements: [...(source.elements || [])]
66
+ .map((element) => ({
67
+ key: element.key,
68
+ desc: normalizeValue(element.desc),
69
+ hidden: Boolean(element.hidden),
70
+ order: element.order,
71
+ details: element.details ?? '',
72
+ }))
73
+ .sort((left, right) => left.order - right.order || left.key.localeCompare(right.key)),
74
+ };
75
+ }
76
+ export function hashComponentSource(component) {
77
+ return hashStable(normalizeComponentSource(component));
78
+ }
79
+ export function getLockPath(projectRoot) {
80
+ return path.join(projectRoot, REVO_LOCK_FILE);
81
+ }
82
+ export function readComponentLock(projectRoot) {
83
+ const lockPath = getLockPath(projectRoot);
84
+ if (!fs.existsSync(lockPath)) {
85
+ return {
86
+ schemaVersion: 1,
87
+ components: {},
88
+ };
89
+ }
90
+ const raw = readJsonFile(lockPath);
91
+ return {
92
+ schemaVersion: 1,
93
+ components: raw.components && typeof raw.components === 'object' ? raw.components : {},
94
+ };
95
+ }
96
+ export function writeComponentLock(projectRoot, lock) {
97
+ const sortedComponents = Object.fromEntries(Object.entries(lock.components).sort(([left], [right]) => left.localeCompare(right)));
98
+ writeJsonFile(getLockPath(projectRoot), {
99
+ schemaVersion: 1,
100
+ components: sortedComponents,
101
+ });
102
+ }
103
+ export function upsertComponentLockEntry(projectRoot, entry) {
104
+ const lock = readComponentLock(projectRoot);
105
+ lock.components[entry.componentId] = entry;
106
+ writeComponentLock(projectRoot, lock);
107
+ }
package/dist/src/ui.js CHANGED
@@ -108,10 +108,11 @@ function renderCommandCatalog() {
108
108
  commandRow('revo endpoints', 'List available API endpoints'),
109
109
  '',
110
110
  paintHeader('Components'),
111
- commandRow('revo component pull <componentId...> [--force] [--stale]', 'Pull one or more components safely'),
112
- commandRow('revo component pull --all [--force] [--stale]', 'Pull every available component with confirmation'),
111
+ commandRow('revo component plan --all [--json] [--strict]', 'Preview local/remote sync status'),
112
+ commandRow('revo component pull <componentId...> [--force]', 'Pull one or more components safely'),
113
+ commandRow('revo component pull --all [--yes] [--force]', 'Pull every available component safely'),
113
114
  commandRow('revo component push <componentId...>', 'Push one or more local components'),
114
- commandRow('revo component push --all [--force]', 'Push every local component.json with confirmation'),
115
+ commandRow('revo component push --all [--yes] [--force]', 'Push every local component with lock checks'),
115
116
  commandRow('revo component debug <componentId> [BODY] [--stream]', 'Debug one local component against sandbox'),
116
117
  '',
117
118
  paintHeader('Low-Level'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revoengine/cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "CLI package for the RevoEngine Platform API",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.build.json",
13
- "check": "node --input-type=module -e \"await import('./src/config.ts'); await import('./src/legacy.ts'); await import('./src/client.ts'); await import('./src/ui.ts'); await import('./src/update-notifier.ts'); await import('./src/commands/auth.ts'); await import('./src/commands/component.ts'); await import('./src/commands/endpoints.ts'); await import('./src/commands/info.ts'); await import('./src/commands/request.ts'); await import('./src/commands/search.ts'); await import('./src/cli.ts'); await import('./src/index.ts'); await import('./bin/revo.ts'); await import('./scripts/dev.ts')\"",
13
+ "check": "node --input-type=module -e \"await import('./src/config.ts'); await import('./src/legacy.ts'); await import('./src/client.ts'); await import('./src/component-lock.ts'); await import('./src/ui.ts'); await import('./src/update-notifier.ts'); await import('./src/commands/auth.ts'); await import('./src/commands/component.ts'); await import('./src/commands/endpoints.ts'); await import('./src/commands/info.ts'); await import('./src/commands/request.ts'); await import('./src/commands/search.ts'); await import('./src/cli.ts'); await import('./src/index.ts'); await import('./bin/revo.ts'); await import('./scripts/dev.ts')\"",
14
14
  "dev": "node ./scripts/dev.ts",
15
15
  "prepack": "npm run build",
16
16
  "test": "node --test ./test/*.test.ts"