@revoengine/cli 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,11 +54,39 @@ Initialize a project for ambient low-code editor globals:
54
54
  ```bash
55
55
  revo project
56
56
  revo project init ./app
57
+ revo project init ./app --identity-mode stableKey --stable-key-name resourceKey --resources component
58
+ revo project init ./app --identity-mode componentId
57
59
  revo project update ./app
58
60
  ```
59
61
 
60
62
  This writes or refreshes root `.revoengine/types/revo.editor.d.ts`, `.revoengine/types/revo.editor.definitions.json`, and `.revoengine/revo.json`, patches the workspace `tsconfig.json` or `jsconfig.json`, and updates root `.gitignore` so the generated editor bundle stays local by default. Pass a path such as `./backend` when the RevoEngine workspace is nested; the path is saved as `.revoengine/revo.json.workspace` while root `.revoengine/` remains the single state directory.
61
63
 
64
+ When compiler options are missing, project initialization sets `target: ES2022`, `module: ESNext`, and `moduleDetection: force`. Revo TypeScript elements are runtime-wrapped snippets, so module detection is required for top-level `await`; existing explicit compiler options are preserved.
65
+
66
+ `--identity-mode stableKey|componentId` stores the component matching strategy in `.revoengine/revo.json`. New projects default to `stableKey`; existing projects without this setting retain legacy `componentId` matching. In stable-key mode, `--stable-key-name <name>` selects the metadata property and defaults to `stableKey`.
67
+
68
+ ```json
69
+ {
70
+ "componentIdentity": {
71
+ "mode": "stableKey",
72
+ "metadataProperty": "resourceKey"
73
+ }
74
+ }
75
+ ```
76
+
77
+ Stable-key mode manages only components that contain the configured metadata property. Component UUIDs remain in API requests and lock diagnostics, but they are not used as durable identity. Duplicate stable-key values block plan, pull, and push. Use `--identity-mode componentId` only for a workspace that intentionally remains bound to one RevoEngine instance.
78
+
79
+ During interactive init, the CLI asks for the stable key metadata name and which resource families it should track. Press Enter to use `stableKey` and track `all` resources. For non-interactive runs, `--resources <list>` accepts `all` or comma-separated resource names such as `component`; when omitted it defaults to `all`. Project initialization also backfills missing stable-key metadata for every tracked component while preserving existing keys. Pass `--no-metadata-backfill` only when initialization must not write remote component metadata.
80
+
81
+ Backfill stable-key metadata for tracked resources with an explicit plan/apply flow:
82
+
83
+ ```bash
84
+ revo metadata plan --env test
85
+ revo metadata apply --env test --yes
86
+ ```
87
+
88
+ Backfill only writes resources missing the configured stable-key metadata unless `--force` is passed. Generated component keys use `category/name#<random-hex>`.
89
+
62
90
  ## Common commands
63
91
 
64
92
  ```bash
@@ -135,12 +163,12 @@ Components/
135
163
  Each component folder keeps its `component.json` manifest alongside an `elements/` directory with the source payload for every element.
136
164
  Components with `category: null` are stored under `Components/__no_category__/...` while the manifest keeps `"category": null`.
137
165
 
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.
166
+ Remote sync state is stored separately in `.revoengine/revo.lock.json`. Lock schema v2 records the configured identity strategy and, in stable-key mode, keys each entry by the configured metadata value while retaining both the repository-local and current remote UUID. The lock 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
167
 
140
168
  Bulk sync behavior:
141
169
 
142
170
  - `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.
171
+ - `--yes` only confirms the bulk operation. `--force` bypasses lock-content safety, but never bypasses stable-key ownership: missing or duplicate configured identities are still blocked or ignored.
144
172
  - `revo component pull --all` requests only active remote components where `deletedAt` is empty.
145
173
  - Pull compares local files, `.revoengine/revo.lock.json`, and the current remote component before overwriting anything.
146
174
  - Pull fast-forwards clean local files when the remote moved and the local copy still matches the lock.
@@ -148,6 +176,7 @@ Bulk sync behavior:
148
176
  - Pull writes stable source manifests and keeps remote version churn out of `Components/**/component.json`.
149
177
  - `revo component plan --all --strict` exits with an error when conflicts or missing lock entries are present.
150
178
  - Push checks the current remote component against the lock before saving local changes.
179
+ - In stable-key mode, push resolves the target remote UUID from metadata and remaps element IDs by element key before saving. Source-instance UUIDs are never sent as target identity.
151
180
  - Push rejects stale remote state instead of trusting the version inside local component JSON.
152
181
  - Push treats backend `Not modified` responses as skipped instead of failing the whole run.
153
182
  - Push treats backend `404` responses as skipped with `doesn't exist remotely`; restore the component in RevoEngine before pushing local changes to it.
@@ -158,13 +187,161 @@ Bulk sync behavior:
158
187
  - Sync logs show direction explicitly: `RevoEngine -> path` for pull and `RevoEngine <- path` for push.
159
188
  - Bulk runs print a summary such as `Deployed 54/67, Skipped 13/67 in 13s`.
160
189
 
190
+ ## Environments
191
+
192
+ Save named environment profiles once, then compare and mirror components between environments terraform-style:
193
+
194
+ ```bash
195
+ revo env add develop --url https://api.dev.revong.com
196
+ revo env add e2e --url https://api.e2e.revong.com
197
+ revo env list
198
+ revo env remove e2e
199
+ ```
200
+
201
+ `revo env add` prompts for the API key, validates it against `/api/v1/me`, and stores the profile (with the inferred instance) next to your default login in the OS config directory. Use `--offline` to skip validation. `revo auth logout` clears only the default login and keeps named environment profiles.
202
+
203
+ Compare two environments:
204
+
205
+ ```bash
206
+ revo env diff --from develop --to e2e
207
+ revo env diff --from develop --to e2e --json
208
+ revo env diff --from develop --to e2e --strict # CI drift gate: non-zero exit on any difference
209
+ revo env diff --from develop --to e2e --prune # classify target-only components as deletes
210
+ revo env diff --from develop --to e2e --key Forms/Card --category Shared
211
+ ```
212
+
213
+ The special name `default` refers to your normal login (`revo auth login` / `REVO_TOKEN`), so `--from default --to e2e` works without saving a profile for the default.
214
+
215
+ Components are matched across environments by a **stable key** stored in resource metadata by the platform. The metadata property defaults to `stableKey`; initialize a workspace with `revo project init --stable-key-name <name>` or pass `--stable-key-name <name>` to env commands to use a different property. `revo metadata apply` writes this value to `metadata` for components. Env sync reads `metadata`, `metaData`, and `resourceMetadata` for compatibility, but it does not generate stable keys while creating resources. Project metadata also stores `trackedResources`, defaulting to `all`; env commands read it or accept `--resources <list>` directly. For older components that do not have metadata yet, the CLI falls back to normalized `category/name`, because component ids and element ids are instance-local. Content comparison uses a portable hash that ignores ids, versions, and timestamps. Two components in one environment that share the same stable key are reported as a `collision` and block `apply` until resolved or scoped out with `--key`/`--category`.
216
+
217
+ Mirror components from a source environment into a target:
218
+
219
+ ```bash
220
+ revo env apply --from develop --to e2e # prints the plan, then asks for confirmation
221
+ revo env apply --from develop --to e2e --dry-run # plan only, never writes
222
+ revo env apply --from develop --to e2e --yes # confirm creates/updates non-interactively
223
+ revo env apply --from develop --to e2e --yes --prune --force # full mirror including deletes
224
+ ```
225
+
226
+ Apply semantics:
227
+
228
+ - Creates run first, then updates, then deletes; failures are reported per component and the run continues.
229
+ - `--yes` only confirms the bulk apply. Deletes additionally require `--force` (non-interactive) or a second dedicated confirmation (interactive), matching the component sync semantics.
230
+ - Deletes only happen with `--prune`; without it, target-only components are reported as `orphan` and left alone.
231
+ - Element identity is preserved on update: source elements are matched to the target's elements by key, so the target keeps its own element ids.
232
+ - After every create/update the CLI re-fetches the target component and verifies its content hash against the source; mismatches are flagged as `verification mismatch`.
233
+
234
+ ## Environment sync runbook
235
+
236
+ Use this flow when preparing one environment to track and mirror resources with the CLI. The examples use `test` as the source and `dev` as the target.
237
+
238
+ 1. Initialize the local project metadata:
239
+
240
+ ```bash
241
+ revo project init
242
+ ```
243
+
244
+ For CI or non-interactive setup:
245
+
246
+ ```bash
247
+ revo project init --identity-mode stableKey --stable-key-name stableKey --resources all
248
+ ```
249
+
250
+ This writes `.revoengine/revo.json` with:
251
+
252
+ - `componentIdentity.mode`: `stableKey` for portable identity or `componentId` for legacy instance-bound identity.
253
+ - `componentIdentity.metadataProperty`: the component metadata property used in stable-key mode. Default: `stableKey`.
254
+ - `trackedResources`: resource families the CLI should track. Default: `all`; this CLI currently tracks components.
255
+
256
+ 2. Save environment profiles:
257
+
258
+ ```bash
259
+ revo env add test --url https://api.test.revong.com
260
+ revo env add dev --url https://api.dev.revong.com
261
+ revo env list
262
+ ```
263
+
264
+ For one-off commands without a saved profile, use `default` plus environment variables:
265
+
266
+ ```bash
267
+ REVO_URL="https://api.dev.revong.com" REVO_TOKEN="$REVO_DEV_TOKEN" revo metadata plan --env default
268
+ ```
269
+
270
+ 3. Plan metadata backfill before writing anything:
271
+
272
+ ```bash
273
+ revo metadata plan --env test
274
+ revo metadata plan --env dev
275
+ ```
276
+
277
+ Use JSON when reviewing exact resources:
278
+
279
+ ```bash
280
+ revo metadata plan --env test --json
281
+ ```
282
+
283
+ 4. Apply metadata backfill:
284
+
285
+ ```bash
286
+ revo metadata apply --env test --yes
287
+ revo metadata apply --env dev --yes
288
+ ```
289
+
290
+ Important: if the same resources already exist in multiple environments, do not treat independently generated random suffixes as interchangeable. `category/name#aaaa1111` and `category/name#bbbb2222` are different stable keys, even when the readable prefix matches. Pick a source of truth and make the target environment use the exact same metadata value, including casing, before running destructive sync.
291
+
292
+ 5. Verify metadata backfill:
293
+
294
+ ```bash
295
+ revo metadata plan --env test
296
+ revo metadata plan --env dev
297
+ ```
298
+
299
+ Expected after a successful backfill:
300
+
301
+ ```text
302
+ Plan: 0 to update, N already populated.
303
+ ```
304
+
305
+ 6. Preview environment drift:
306
+
307
+ ```bash
308
+ revo env diff --from test --to dev
309
+ revo env diff --from test --to dev --json
310
+ ```
311
+
312
+ Use `--strict` in CI:
313
+
314
+ ```bash
315
+ revo env diff --from test --to dev --strict
316
+ ```
317
+
318
+ 7. Apply non-destructive sync:
319
+
320
+ ```bash
321
+ revo env apply --from test --to dev --dry-run
322
+ revo env apply --from test --to dev --yes
323
+ ```
324
+
325
+ Without `--prune`, target-only resources are reported as `orphan` and left untouched.
326
+
327
+ 8. Apply full mirror with orphan removal:
328
+
329
+ ```bash
330
+ revo env diff --from test --to dev --prune
331
+ revo env apply --from test --to dev --prune --dry-run
332
+ revo env apply --from test --to dev --prune --yes --force
333
+ ```
334
+
335
+ Only use `--prune --force` after reviewing the plan. It deletes target resources that are missing from the source.
336
+
161
337
  ## Configuration
162
338
 
163
339
  The CLI reads configuration in this order:
164
340
 
165
341
  1. CLI flags such as `--url` and `--token`
166
- 2. Environment variables such as `REVO_URL` and `REVO_TOKEN`
167
- 3. Stored config in the OS config directory
342
+ 2. A named environment profile when a command is invoked with `--from`/`--to` (profiles resolve exclusively and are not mixed with environment variables or the stored default login)
343
+ 3. Environment variables such as `REVO_URL` and `REVO_TOKEN`
344
+ 4. Stored config in the OS config directory
168
345
 
169
346
  This means environment variables override the locally stored config, which is the intended setup for CI and non-interactive automation.
170
347
 
package/dist/src/cli.js CHANGED
@@ -2,7 +2,7 @@ import { realpathSync } from 'node:fs';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { RevoClient } from "./client.js";
4
4
  import { resolveRuntimeConfig } from "./config.js";
5
- import { handleAuthCommand, handleComponentCommand, handleEndpointsCommand, handleInfoCommand, handleProjectCommand, handleRequestCommand, handleSearchCommand, } from "./commands/index.js";
5
+ import { handleAuthCommand, handleComponentCommand, handleEndpointsCommand, handleEnvCommand, handleInfoCommand, handleMetadataCommand, handleProjectCommand, handleRequestCommand, handleSearchCommand, } from "./commands/index.js";
6
6
  import { buildRuntimeViewModel } from "./runtime-view.js";
7
7
  import { renderHelp, renderOverview } from "./ui.js";
8
8
  import { maybeNotifyUpdateAvailable } from "./update-notifier.js";
@@ -196,6 +196,14 @@ export async function main(argv = process.argv.slice(2)) {
196
196
  await handleEndpointsCommand(context);
197
197
  return;
198
198
  }
199
+ if (command === 'env') {
200
+ await handleEnvCommand(context);
201
+ return;
202
+ }
203
+ if (command === 'metadata') {
204
+ await handleMetadataCommand(context);
205
+ return;
206
+ }
199
207
  if (command === 'project') {
200
208
  await handleProjectCommand(context);
201
209
  return;
@@ -17,6 +17,7 @@ export type ClientOptions = {
17
17
  baseUrl?: string;
18
18
  instance?: string;
19
19
  token?: string;
20
+ envName?: string;
20
21
  fetch?: typeof fetch;
21
22
  };
22
23
  export type ApiResponse<T = unknown> = {
@@ -37,6 +38,7 @@ export declare class PermissionDeniedError extends ApiError {
37
38
  path: string;
38
39
  constructor(path: string, message: string, data: unknown);
39
40
  }
41
+ declare const COMPONENT_LIST_PAGE_SIZE = 200;
40
42
  declare function buildUrl(baseUrl: string, requestPath: string, query?: Record<string, unknown>): URL;
41
43
  declare function normalizeToken(token?: string): string;
42
44
  export declare function extractProfileInstanceId(profile: unknown): string;
@@ -71,7 +73,15 @@ export declare class RevoClient {
71
73
  debugComponentStream(requestPath: string, body: unknown): AsyncGenerator<DebugStreamEvent>;
72
74
  search(params: Record<string, unknown>): Promise<unknown>;
73
75
  listComponents(options?: ComponentListRequest): Promise<unknown>;
76
+ listAllComponents(): Promise<{
77
+ components: Record<string, unknown>[];
78
+ discoveredTotal: number | null;
79
+ }>;
74
80
  getComponent(componentId: string): Promise<unknown>;
81
+ createComponent(body: unknown): Promise<unknown>;
82
+ updateComponent(componentId: string, body: unknown): Promise<unknown>;
83
+ deleteComponent(componentId: string): Promise<ApiResponse<unknown>>;
75
84
  saveComponentElements(componentId: string, body: unknown): Promise<ApiResponse<unknown>>;
76
85
  }
86
+ export { COMPONENT_LIST_PAGE_SIZE };
77
87
  export { buildUrl, normalizeToken };
@@ -25,6 +25,102 @@ export class PermissionDeniedError extends ApiError {
25
25
  }
26
26
  }
27
27
  const REQUEST_DELAY_MS = 50;
28
+ const COMPONENT_LIST_PAGE_SIZE = 200;
29
+ const ACTIVE_COMPONENT_LIST_FILTER = {
30
+ 'filter[and][0][field]': 'deletedAt',
31
+ 'filter[and][0][op]': 'isNull',
32
+ };
33
+ function isRecord(value) {
34
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+ function readNumber(value) {
37
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
38
+ }
39
+ function unwrapList(value) {
40
+ if (Array.isArray(value)) {
41
+ return value;
42
+ }
43
+ if (value && typeof value === 'object') {
44
+ const candidate = value;
45
+ if (Array.isArray(candidate.data)) {
46
+ return candidate.data;
47
+ }
48
+ if (Array.isArray(candidate.items)) {
49
+ return candidate.items;
50
+ }
51
+ }
52
+ return [];
53
+ }
54
+ function resolveNextComponentListRequest(value) {
55
+ if (typeof value === 'string' && value) {
56
+ return {
57
+ path: value,
58
+ };
59
+ }
60
+ if (!isRecord(value)) {
61
+ return null;
62
+ }
63
+ for (const key of ['path', 'url', 'href']) {
64
+ if (typeof value[key] === 'string' && value[key]) {
65
+ return {
66
+ path: value[key],
67
+ };
68
+ }
69
+ }
70
+ const query = {};
71
+ for (const key of ['cursor', 'page', 'skip', 'take', 'limit', 'offset']) {
72
+ const candidate = value[key];
73
+ if (typeof candidate === 'string' || typeof candidate === 'number') {
74
+ query[key] = candidate;
75
+ }
76
+ }
77
+ return Object.keys(query).length > 0 ? { query } : null;
78
+ }
79
+ function buildActiveComponentListQuery(skip, take = COMPONENT_LIST_PAGE_SIZE) {
80
+ return {
81
+ take,
82
+ skip,
83
+ count: true,
84
+ ...ACTIVE_COMPONENT_LIST_FILTER,
85
+ };
86
+ }
87
+ function withActiveComponentListFilter(request) {
88
+ if (!request.query) {
89
+ return request;
90
+ }
91
+ return {
92
+ ...request,
93
+ query: {
94
+ ...request.query,
95
+ count: request.query.count ?? true,
96
+ ...ACTIVE_COMPONENT_LIST_FILTER,
97
+ },
98
+ };
99
+ }
100
+ function unwrapComponentListPage(value) {
101
+ const items = unwrapList(value);
102
+ if (!isRecord(value)) {
103
+ return {
104
+ items,
105
+ total: null,
106
+ nextRequest: null,
107
+ };
108
+ }
109
+ const meta = isRecord(value.meta) ? value.meta : null;
110
+ const pagination = isRecord(value.pagination) ? value.pagination : null;
111
+ const total = readNumber(value.total)
112
+ ?? readNumber(value.count)
113
+ ?? (meta ? readNumber(meta.total) ?? readNumber(meta.count) : null)
114
+ ?? (pagination ? readNumber(pagination.total) ?? readNumber(pagination.count) : null);
115
+ const nextRequest = resolveNextComponentListRequest(value.next)
116
+ ?? (meta ? resolveNextComponentListRequest(meta.next) : null)
117
+ ?? (pagination ? resolveNextComponentListRequest(pagination.next) : null);
118
+ return {
119
+ items,
120
+ total,
121
+ nextRequest,
122
+ };
123
+ }
28
124
  function normalizeBaseUrl(baseUrl) {
29
125
  const url = new URL(baseUrl || DEFAULT_BASE_URL);
30
126
  url.pathname = url.pathname.replace(/\/+$/, '');
@@ -428,11 +524,72 @@ export class RevoClient {
428
524
  query: options.query,
429
525
  });
430
526
  }
527
+ async listAllComponents() {
528
+ const components = [];
529
+ const seenRequests = new Set();
530
+ let nextRequest = {
531
+ query: buildActiveComponentListQuery(0),
532
+ };
533
+ let discoveredTotal = null;
534
+ while (nextRequest) {
535
+ const requestKey = JSON.stringify(nextRequest);
536
+ if (seenRequests.has(requestKey)) {
537
+ throw new Error('Component list pagination loop detected while listing all components.');
538
+ }
539
+ seenRequests.add(requestKey);
540
+ const pageValue = await this.listComponents(nextRequest);
541
+ const page = unwrapComponentListPage(pageValue);
542
+ if (page.total !== null) {
543
+ discoveredTotal = page.total;
544
+ }
545
+ for (const item of page.items) {
546
+ if (isRecord(item)) {
547
+ components.push(item);
548
+ }
549
+ }
550
+ if (page.total !== null && components.length >= page.total) {
551
+ nextRequest = null;
552
+ continue;
553
+ }
554
+ if (page.nextRequest) {
555
+ nextRequest = withActiveComponentListFilter(page.nextRequest);
556
+ continue;
557
+ }
558
+ if (nextRequest.query
559
+ && typeof nextRequest.query.take === 'number'
560
+ && page.items.length === nextRequest.query.take) {
561
+ nextRequest = {
562
+ query: buildActiveComponentListQuery(components.length, nextRequest.query.take),
563
+ };
564
+ continue;
565
+ }
566
+ nextRequest = null;
567
+ }
568
+ return { components, discoveredTotal };
569
+ }
431
570
  async getComponent(componentId) {
432
571
  return this.requestData('GET', `/api/v1/component/${componentId}`);
433
572
  }
573
+ async createComponent(body) {
574
+ return this.requestData('POST', '/api/v1/component', {
575
+ body,
576
+ spinnerLabel: 'Creating component',
577
+ });
578
+ }
579
+ async updateComponent(componentId, body) {
580
+ return this.requestData('PUT', `/api/v1/component/${componentId}`, {
581
+ body,
582
+ spinnerLabel: 'Updating component',
583
+ });
584
+ }
585
+ async deleteComponent(componentId) {
586
+ return this.request('DELETE', `/api/v1/component/${componentId}`, {
587
+ spinnerLabel: 'Deleting component',
588
+ });
589
+ }
434
590
  async saveComponentElements(componentId, body) {
435
591
  return this.request('POST', `/api/v1/component/${componentId}/save`, { body });
436
592
  }
437
593
  }
594
+ export { COMPONENT_LIST_PAGE_SIZE };
438
595
  export { buildUrl, normalizeToken };