@remits/remits-cli 0.1.103 → 0.1.106

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
@@ -33,7 +33,7 @@ remits-cli components sync
33
33
  remits-cli components sync --branch feature_branch --dry-run
34
34
  remits-cli components sync --branch feature_branch --dry-run --summary
35
35
  remits-cli components sync --branch feature_branch --force-tombstones
36
- remits-cli token --path page/my-embeddable
36
+ remits-cli token --path page/my-embeddable # returns tokenKey/embeddableUrl, plus embedTokenKey/embedSnippet for host embeds
37
37
  remits-cli token --path page/my-embeddable --variant-branch feature_branch
38
38
  remits-cli token inspect --token https://example.test/s/<tokenKey>/page/my-embeddable
39
39
  remits-cli tool --name mcp_account_user_admin --data-mode prod --input '{"action":"account_create","parentAccountId":4,"name":"Freto","type":"PRODUCT","dryRun":true}'
package/index.js CHANGED
@@ -74,7 +74,7 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
74
74
 
75
75
  // Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
76
76
  // flag keeps last-wins so existing callers are unaffected.
77
- const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved']);
77
+ const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names']);
78
78
 
79
79
  function parseArgs(argv) {
80
80
  const out = { _: [] };
@@ -1386,7 +1386,15 @@ function collectComponents(cwd) {
1386
1386
  const key = type + ':' + (id ? 'id:' + id : 'name:' + name.toLowerCase());
1387
1387
 
1388
1388
  if (!byKey.has(key)) {
1389
- byKey.set(key, { type, id, name });
1389
+ // `metadataAuthoritative` tells the server this payload was assembled from the repo, so the
1390
+ // .meta.yml it carries is the COMPLETE metadata for the component. The server then removes
1391
+ // staged metadata keys the sidecar no longer declares, instead of carrying them forever —
1392
+ // without it, deleting a key from a sidecar and re-staging left the old value staged, so
1393
+ // "restore the file and re-stage" did not restore the staged state.
1394
+ //
1395
+ // A partial stage (mcp_component_edit writing one field) deliberately does NOT set this and
1396
+ // keeps the merge semantics it needs.
1397
+ byKey.set(key, { type, id, name, metadataAuthoritative: true });
1390
1398
  }
1391
1399
  const component = byKey.get(key);
1392
1400
  const filePath = path.join(dir, fileName);
@@ -1564,6 +1572,42 @@ function changedComponentsFromWorkingTree(cwd) {
1564
1572
  });
1565
1573
  }
1566
1574
 
1575
+ /**
1576
+ * Components touched by commits between `ref` and HEAD.
1577
+ *
1578
+ * `changedComponentsFromWorkingTree` reads `git status`, i.e. UNCOMMITTED edits only — which is empty
1579
+ * at exactly the moment `--changed-only` is meant to run, because the documented flow is
1580
+ * commit -> push -> sync (sync reads the pushed remote, so it cannot see uncommitted work at all).
1581
+ * The gate was therefore unusable in the flow it exists to guard: it refused every plan with
1582
+ * "plan touches N component(s) this working tree did not edit". `--changed-since <ref>` supplies the
1583
+ * base so the same guard works after the commit.
1584
+ */
1585
+ function changedComponentsSinceRef(cwd, ref) {
1586
+ let output = '';
1587
+ try {
1588
+ output = execSync('git diff --name-only ' + JSON.stringify(ref) + '...HEAD -- components', {
1589
+ cwd,
1590
+ stdio: ['ignore', 'pipe', 'pipe']
1591
+ }).toString();
1592
+ } catch (_) {
1593
+ return null;
1594
+ }
1595
+ const byKey = new Map();
1596
+ for (const rawLine of output.split(/\r?\n/)) {
1597
+ const filePath = rawLine.trim();
1598
+ if (!filePath) continue;
1599
+ const info = componentPathInfo(cwd, filePath);
1600
+ if (!info) continue;
1601
+ if (!byKey.has(info.key)) {
1602
+ byKey.set(info.key, { type: info.type, id: info.id, name: info.name, fields: [], paths: [] });
1603
+ }
1604
+ const entry = byKey.get(info.key);
1605
+ if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
1606
+ if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
1607
+ }
1608
+ return Array.from(byKey.values());
1609
+ }
1610
+
1567
1611
  function parsePositiveInt(value, fallback) {
1568
1612
  const parsed = Number(value);
1569
1613
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
@@ -2301,7 +2345,7 @@ async function syncComponentsCommand(flags) {
2301
2345
  throw new Error(previewResponse.message || 'Server sync dry-run failed');
2302
2346
  }
2303
2347
 
2304
- preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree);
2348
+ preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree, cwd);
2305
2349
 
2306
2350
  if (namesOnly) {
2307
2351
  printSessionResolutionWarning(sessionContext);
@@ -2324,7 +2368,7 @@ async function syncComponentsCommand(flags) {
2324
2368
  }
2325
2369
 
2326
2370
  const summary = buildSyncSummary(response);
2327
- const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree);
2371
+ const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree, cwd);
2328
2372
  summary.gates = gate.checks;
2329
2373
 
2330
2374
  if (flagEnabled(flags.json)) {
@@ -2342,19 +2386,97 @@ async function syncComponentsCommand(flags) {
2342
2386
  }
2343
2387
  if (namesOnly) {
2344
2388
  printSyncNames(response);
2389
+ printPromotionSignals(response);
2345
2390
  failOnSyncGate(gate);
2346
2391
  return response;
2347
2392
  }
2348
2393
  if (flagEnabled(flags.summary)) {
2349
2394
  console.log('Components sync summary:', JSON.stringify(summary, null, 2));
2395
+ printPromotionSignals(response);
2350
2396
  failOnSyncGate(gate);
2351
2397
  return response;
2352
2398
  }
2353
2399
  console.log('Components sync:', JSON.stringify(response, null, 2));
2400
+ printPromotionSignals(response);
2354
2401
  failOnSyncGate(gate);
2355
2402
  return response;
2356
2403
  }
2357
2404
 
2405
+ // A sync is a step in a longer loop, and the steps after it are the ones that get skipped — so print what
2406
+ // the server just observed about this branch's relationship to trunk, not only what it wrote.
2407
+ function printPromotionSignals(response) {
2408
+ const sync = (response && response.sync) || {};
2409
+
2410
+ const cmp = sync.branchComparison;
2411
+ if (cmp && cmp.comparable) {
2412
+ console.log('');
2413
+ console.log('Branch vs trunk "' + cmp.trunkBranch + '": ' + cmp.aheadBy + ' ahead, ' + cmp.behindBy +
2414
+ ' behind [phase ' + cmp.phase + ']');
2415
+ if (cmp.phase === 'diverged') {
2416
+ console.log(' This branch has not merged trunk in. Its overlays assert its OLD copy of everything');
2417
+ console.log(' trunk has changed since, and its subscribers are running that copy right now.');
2418
+ console.log(' Fix: git merge ' + cmp.trunkBranch + ' into this branch, push, then sync again.');
2419
+ } else if (cmp.phase === 'awaiting-merge-back') {
2420
+ console.log(' Every commit here is already in trunk and trunk has moved on — a promotion merge-back');
2421
+ console.log(' is outstanding. Until it happens the subscribers stay pinned to the pre-promotion copy.');
2422
+ console.log(' Fix: git merge ' + cmp.trunkBranch + ' into this branch, push, then sync again.');
2423
+ }
2424
+ if (cmp.deletionEvidence !== 'complete') {
2425
+ console.log(' NOTE: deletion evidence is ' + cmp.deletionEvidence + ', so removals fall back to the');
2426
+ console.log(' conservative ratio guard rather than git\'s own answer.');
2427
+ }
2428
+ } else if (cmp) {
2429
+ console.log('');
2430
+ console.log('Branch vs trunk: UNAVAILABLE' + (cmp.message ? ' (' + cmp.message + ')' : ''));
2431
+ }
2432
+
2433
+ const promotion = sync.promotion;
2434
+ if (promotion) {
2435
+ (promotion.tags || []).forEach((t) => {
2436
+ console.log('');
2437
+ console.log('Tagged promotion: ' + t.tag + ' -> ' + short(t.sha) + ' (' + t.ref + ')');
2438
+ });
2439
+ // Older servers sent bare branch names; newer ones send {branch, overlayCount}. Normalize, because the
2440
+ // count decides whether this is an alarm or a tidy-up and the difference must not be guessed.
2441
+ const pending = (promotion.pendingMergeBack || []).map((entry) =>
2442
+ (typeof entry === 'string' ? { branch: entry, overlayCount: -1 } : entry));
2443
+ if (pending.length) {
2444
+ const shadowing = pending.filter((p) => p.overlayCount > 0);
2445
+ const unknown = pending.filter((p) => !(p.overlayCount >= 0));
2446
+ console.log('');
2447
+ if (shadowing.length) {
2448
+ console.log('THE PROMOTION IS NOT FINISHED. Trunk is synced, but ' + shadowing.length +
2449
+ ' variant branch(es) still SHADOW it — their subscribers resolve the PRE-promotion code:');
2450
+ } else if (unknown.length) {
2451
+ // The overlay count could not be read, so say nothing about what is shadowed. "Nothing is
2452
+ // shadowed" would be a claim, and an unverifiable one is the failure this whole change is about.
2453
+ console.log('THE PROMOTION IS NOT FINISHED. Trunk is synced, but ' + pending.length +
2454
+ ' variant branch(es) are behind it and owe a merge-back:');
2455
+ } else {
2456
+ console.log('Merge-back outstanding on ' + pending.length + ' variant branch(es). Nothing is shadowed' +
2457
+ ' (no overlays stored), but the branch is behind trunk until you merge it back:');
2458
+ }
2459
+ pending.forEach((p) => {
2460
+ const detail = p.overlayCount > 0 ? ' (' + p.overlayCount + ' overlay(s) shadowing trunk)'
2461
+ : p.overlayCount === 0 ? ' (0 overlays — converged, just behind)' : '';
2462
+ console.log(' ' + p.branch + ':' + detail);
2463
+ console.log(' git checkout ' + p.branch + ' && git merge ' + (sync.branchName || 'main') + ' && git push');
2464
+ console.log(' remits-cli components sync --branch ' + p.branch);
2465
+ });
2466
+ console.log(' Verify with: remits-cli components promotion --branch <name>');
2467
+ }
2468
+ }
2469
+
2470
+ (sync.advisories || []).forEach((advisory) => {
2471
+ console.log('');
2472
+ console.log('ADVISORY [' + advisory.code + ']: ' + advisory.message);
2473
+ (advisory.findings || []).slice(0, 15).forEach((f) => {
2474
+ console.log(' ' + f.path + ':' + f.line + ' ' + f.call);
2475
+ });
2476
+ if (advisory.truncated) console.log(' ... (list truncated; re-run with --json for the rest)');
2477
+ });
2478
+ }
2479
+
2358
2480
  function syncPreflightRequested(flags) {
2359
2481
  return Boolean(
2360
2482
  flagEnabled(flags['names-only']) ||
@@ -2419,7 +2541,7 @@ function parseExpectedRemoved(flags) {
2419
2541
  }
2420
2542
 
2421
2543
  // Fail-closed gates. Each returns a violation string or null; the command exits non-zero if any fire.
2422
- function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2544
+ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
2423
2545
  const entries = syncPlanEntries(response);
2424
2546
  const results = ((response && response.sync) || {}).syncResults || {};
2425
2547
  const removed = entries.filter((e) => e.bucket === 'removed');
@@ -2450,13 +2572,19 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2450
2572
  }
2451
2573
 
2452
2574
  if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
2453
- if (changedFromWorkingTree === null) {
2575
+ const since = flags['changed-since'] || flags.changedSince;
2576
+ const fromRef = since ? changedComponentsSinceRef(cwd, String(since)) : null;
2577
+ if (since && fromRef === null) {
2578
+ violations.push('--changed-since: could not diff against "' + since + '". Check the ref exists (git fetch first for a remote ref).');
2579
+ checks.changedOnly = false;
2580
+ } else if (changedFromWorkingTree === null && fromRef === null) {
2454
2581
  violations.push('--changed-only: this checkout is not a git working tree, so the changed set cannot be established.');
2455
2582
  checks.changedOnly = false;
2456
2583
  } else {
2584
+ const changedSet = (changedFromWorkingTree || []).concat(fromRef || []);
2457
2585
  // A component the checkout edited is identified by type + id, or type + name for `new_` files.
2458
- const allowedIds = new Set(changedFromWorkingTree.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2459
- const allowedNames = new Set(changedFromWorkingTree.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2586
+ const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2587
+ const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2460
2588
  const unexpected = entries.filter((entry) => {
2461
2589
  if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
2462
2590
  if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
@@ -2464,8 +2592,15 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2464
2592
  });
2465
2593
  checks.changedOnly = unexpected.length === 0;
2466
2594
  if (unexpected.length) {
2467
- violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this working tree did not edit: ' +
2468
- unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : ''));
2595
+ // Name the most likely cause instead of only the symptom. An empty changed set with a non-empty
2596
+ // plan is the ordinary post-commit state, not evidence that the plan is dangerous.
2597
+ const hint = (!changedSet.length && !since)
2598
+ ? '\n The changed set is EMPTY: --changed-only reads UNCOMMITTED edits, and the documented flow ' +
2599
+ 'commits and pushes before syncing. Pass --changed-since <ref> (e.g. the commit you branched ' +
2600
+ 'from, or origin/main) so the gate can see committed work.'
2601
+ : '';
2602
+ violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
2603
+ unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
2469
2604
  }
2470
2605
  }
2471
2606
  }
@@ -2482,9 +2617,71 @@ function failOnSyncGate(gate) {
2482
2617
  function buildSyncSummary(response) {
2483
2618
  const sync = (response && response.sync) || {};
2484
2619
  const results = sync.syncResults || {};
2485
- const removed = Array.isArray(results.removed) ? results.removed : [];
2486
2620
  const errors = Array.isArray(results.errors) ? results.errors : [];
2487
2621
  const skipped = Array.isArray(results.skipped) ? results.skipped : [];
2622
+
2623
+ // A sync that SHORT-CIRCUITED on the cached branch SHA did no work at all, and the server says so
2624
+ // (`skipped: true` plus a message). Rendering it through the normal shape below printed
2625
+ // `overridden: 0, added: 0, removed: []` — which is what a sync that ran and found nothing to do
2626
+ // prints, so the two were indistinguishable. That matters because the cache can hold a SHA whose
2627
+ // overlays were since removed by another path: an ordinary re-sync then reports zeros forever and
2628
+ // the operator has no reason to suspect the branch was never re-read. Report the skip as itself.
2629
+ // `syncResults` absent is the fallback signal, so this still reads correctly against a server that
2630
+ // predates the explicit `skipped` flag rather than silently falling back to the misleading zeros.
2631
+ const shortCircuited = sync.skipped === true
2632
+ || (sync.syncResults == null && !!sync.message && sync.dryRun !== true);
2633
+ if (shortCircuited) {
2634
+ return {
2635
+ success: response && response.success === true,
2636
+ accountId: response && response.accountId,
2637
+ branchName: sync.branchName || (response && response.branchName),
2638
+ mode: sync.mode || 'variant',
2639
+ skipped: true,
2640
+ reason: sync.message || 'branch head matches the cached sync SHA; the branch was not re-read',
2641
+ branchHeadSha: sync.branchHeadSha || sync.postSyncSha || null,
2642
+ warnings: [
2643
+ // Scoped to COMPONENTS deliberately: the short-circuit still refreshes the delivered guides and
2644
+ // account metadata, so "nothing happened" would be its own small untruth.
2645
+ 'NO COMPONENTS WERE SYNCED. This is the cached-SHA short-circuit, not an empty plan — the ' +
2646
+ 'branch was never re-read.',
2647
+ 'Use --dry-run to see the real plan (a dry run always re-reads the branch), or ' +
2648
+ '--force-tombstones to re-read and apply it.'
2649
+ ]
2650
+ };
2651
+ }
2652
+
2653
+ // A TRUNK sync and a VARIANT sync report in different vocabularies, and this summary only ever spoke
2654
+ // variant. So the single most consequential line of a promotion — "component X was CREATED on trunk as
2655
+ // id N" — printed as `added: 0`, and the runbook's definition of done ("components created, with their
2656
+ // new ids") could not be satisfied from the summary it tells you to read.
2657
+ if ((sync.mode || 'trunk') === 'trunk') {
2658
+ const created = Array.isArray(results.created) ? results.created : [];
2659
+ const updated = Array.isArray(results.updated) ? results.updated : [];
2660
+ const renamed = Array.isArray(results.renamed) ? results.renamed : [];
2661
+ const deleted = Array.isArray(results.deleted) ? results.deleted : [];
2662
+ const trunkWarnings = [];
2663
+ if (deleted.length) trunkWarnings.push(String(deleted.length) + ' live component(s) DELETED');
2664
+ if (errors.length) trunkWarnings.push(String(errors.length) + ' sync error' + (errors.length === 1 ? '' : 's') + ' present');
2665
+ return {
2666
+ success: response && response.success === true,
2667
+ accountId: response && response.accountId,
2668
+ branchName: sync.branchName || (response && response.branchName),
2669
+ mode: 'trunk',
2670
+ dryRun: false,
2671
+ // Ids are the part that cannot be recovered later: a promotion mints them once, and anything that
2672
+ // recorded the old identity will not line up if they are re-minted.
2673
+ created: created.map((e) => ({ type: e.type, name: e.name, id: e.newId != null ? e.newId : e.id })),
2674
+ updated: updated.length,
2675
+ renamed: renamed.length,
2676
+ deleted: deleted.map((e) => ({ type: e.type, name: e.name, id: e.id })),
2677
+ skipped: skipped.length,
2678
+ errors: errors.length,
2679
+ errorDetails: errors.map((entry) => ({ type: entry.type, id: entry.id, error: entry.error })),
2680
+ warnings: trunkWarnings
2681
+ };
2682
+ }
2683
+
2684
+ const removed = Array.isArray(results.removed) ? results.removed : [];
2488
2685
  const added = Array.isArray(results.added) ? results.added : [];
2489
2686
  const overridden = Array.isArray(results.overridden) ? results.overridden : [];
2490
2687
  const unchanged = Array.isArray(results.unchanged) ? results.unchanged : [];
@@ -2573,6 +2770,8 @@ async function branchesComponentsCommand(flags) {
2573
2770
  // Optional branch-scoped custom host set on the same edge as the subscription.
2574
2771
  // `--domain none` clears it; omitting the flag leaves any existing host untouched.
2575
2772
  domainName: flags.domain || flags['domain-name'],
2773
+ dryRun: flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun),
2774
+ confirmPrimaryEdge: flagEnabled(flags['confirm-primary-edge']) || flagEnabled(flags.confirmPrimaryEdge),
2576
2775
  force: flagEnabled(flags.force)
2577
2776
  }).then((r) => r.data);
2578
2777
 
@@ -2606,9 +2805,13 @@ function printBranchesSummary(response) {
2606
2805
  console.log(' ' + b.branch +
2607
2806
  ' (overridden ' + b.overridden + ', added ' + b.added + ', removed ' + b.removed +
2608
2807
  ', subscribers ' + b.subscribers + ')' + drift);
2808
+ // Name the commit these counts describe. Without it a three-day-old inventory reads exactly like a
2809
+ // current one, which is how a promotion gets reviewed against the wrong tree.
2810
+ console.log(' computed from ' + short(b.syncedSha) + (b.updated ? ' at ' + b.updated : ''));
2609
2811
  });
2610
2812
  console.log('');
2611
- console.log('Detail: remits-cli components branch <name>');
2813
+ console.log('Detail: remits-cli components branch <name>');
2814
+ console.log('Promotion: remits-cli components promotion --branch <name> # is that commit still the branch HEAD?');
2612
2815
  return;
2613
2816
  }
2614
2817
 
@@ -2666,6 +2869,12 @@ function printBranchesSummary(response) {
2666
2869
 
2667
2870
  if (response.mode === 'subscribe' || response.mode === 'unsubscribe' || response.mode === 'retire') {
2668
2871
  console.log(response.message);
2872
+ if (response.requiresConfirmation) {
2873
+ console.log('Confirmation required: re-run with --confirm-primary-edge if that structural edge should subscribe.');
2874
+ }
2875
+ if (response.edge) {
2876
+ console.log('Edge: ' + response.edge.edgeType + ' -> parent ' + response.edge.parentAccountId);
2877
+ }
2669
2878
  return;
2670
2879
  }
2671
2880
 
@@ -2679,6 +2888,167 @@ function printBranchesSummary(response) {
2679
2888
  });
2680
2889
  }
2681
2890
 
2891
+ // The promotion "lay of the land": where this branch sits relative to trunk, whether the platform's
2892
+ // stored overlays actually describe the branch's current HEAD, who is affected, and the ordered commands
2893
+ // for the next step. Every fact here was already obtainable one command at a time; the point is that a
2894
+ // promotion goes wrong by assembling a wrong picture out of individually true answers, so this assembles
2895
+ // the picture once, server-side, from the REMOTE (which is what the platform syncs from — not your
2896
+ // working tree).
2897
+ async function promotionComponentsCommand(flags) {
2898
+ const cwd = process.cwd();
2899
+ ensureLocalState(cwd);
2900
+ const sessionContext = resolvePromotionSessionContext(cwd, flags);
2901
+ const { session, accountId } = sessionContext;
2902
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2903
+ const api = buildAxios(baseUrl, session.token);
2904
+
2905
+ const positional = flags._ && flags._[2];
2906
+ const branchName = flags.branch || positional || currentBranch(cwd);
2907
+
2908
+ const response = await loggedPost(api, cwd, '/cli/branches', {
2909
+ token: session.token,
2910
+ accountId,
2911
+ mode: 'promotion',
2912
+ branchName
2913
+ }).then((r) => r.data);
2914
+
2915
+ if (!response.success) {
2916
+ throw new Error(response.message || 'Promotion status failed');
2917
+ }
2918
+
2919
+ if (flagEnabled(flags.json)) {
2920
+ console.log(JSON.stringify(response, null, 2));
2921
+ } else {
2922
+ printSessionResolutionWarning(sessionContext);
2923
+ printResolvedBaseUrl(baseUrl);
2924
+ // Say this out loud: the command is named for the operation it reports on, and an agent that reads
2925
+ // "promotion" as "promoted" would skip the git steps this output is telling it to run.
2926
+ console.log('This REPORTS promotion readiness. It performs no git operation and writes nothing.');
2927
+ if (Array.isArray(response.branches)) {
2928
+ response.branches.forEach((entry) => printPromotionStatus(entry, response));
2929
+ } else {
2930
+ printPromotionStatus(response, response);
2931
+ }
2932
+ }
2933
+
2934
+ // Fail closed by default. This command exists to be the gate an agent runs BEFORE touching git, and a
2935
+ // gate that always exits 0 is a suggestion. `--no-fail` is for a human who only wants to look.
2936
+ const blockers = collectPromotionBlockers(response);
2937
+ if (blockers.length && !flagEnabled(flags['no-fail'])) {
2938
+ throw new Error('promotion is blocked (' + blockers.length + '):\n ' +
2939
+ blockers.map((b) => b.code + ': ' + b.message).join('\n ') +
2940
+ '\nRe-run with --no-fail to inspect without failing.');
2941
+ }
2942
+ return response;
2943
+ }
2944
+
2945
+ // A promotion is normally driven from the BRANCH's checkout, and on a single-subscriber branch that
2946
+ // checkout's account-info.json describes the SUBSCRIBER — so the CLI resolves the subscriber's account id
2947
+ // and demands a session for it, which an owner-side agent has no reason to hold. Every other branch
2948
+ // command has the same shape and tells you to pass `--account-id <ownerId>`; this one should not need to
2949
+ // be told, because it is READ-ONLY and the server already resolves the branch owner itself.
2950
+ //
2951
+ // So: if there is no session for the local account, and the checkout names its component owner, retry as
2952
+ // the owner. Deliberately scoped to this command — `sync`/`commit` mutate, and there the account the CLI
2953
+ // acts as is a decision, not a detail.
2954
+ function resolvePromotionSessionContext(cwd, flags) {
2955
+ try {
2956
+ return resolveSessionContext(cwd, flags);
2957
+ } catch (err) {
2958
+ if (flags && flags['account-id']) throw err;
2959
+ let ownerId = null;
2960
+ try {
2961
+ const info = JSON.parse(fs.readFileSync(path.join(cwd, 'account-info.json'), 'utf8'));
2962
+ const resolution = (info && info.resolution) || {};
2963
+ const localId = Number(accountIdFromAccountInfo(info));
2964
+ const candidate = Number(resolution.componentOwnerAccountId);
2965
+ if (Number.isFinite(candidate) && candidate > 0 && candidate !== localId) ownerId = candidate;
2966
+ } catch (_) { /* not an account repo, or no owner recorded */ }
2967
+ if (!ownerId) throw err;
2968
+
2969
+ const ownerContext = resolveSessionContext(cwd, Object.assign({}, flags, { 'account-id': ownerId }));
2970
+ console.log('Note: no session for this checkout\'s account; reporting as its component OWNER, account ' +
2971
+ ownerId + '. Branch overlays belong to the owner, so this is the account that can answer.');
2972
+ return ownerContext;
2973
+ }
2974
+ }
2975
+
2976
+ function collectPromotionBlockers(response) {
2977
+ if (Array.isArray(response.branches)) {
2978
+ return response.branches.reduce((all, entry) => all.concat(entry.blockers || []), []);
2979
+ }
2980
+ return response.blockers || [];
2981
+ }
2982
+
2983
+ function printPromotionStatus(entry, envelope) {
2984
+ const git = entry.git || {};
2985
+ const platform = entry.platform || {};
2986
+ console.log('');
2987
+ console.log('Branch: ' + entry.branch + ' (trunk "' + (entry.trunkBranch || envelope.trunk) + '")');
2988
+ console.log('Owner: account ' + entry.ownerAccountId + (entry.ownerAccountName ? ' (' + entry.ownerAccountName + ')' : '') +
2989
+ ' repo ' + (entry.repository || '?'));
2990
+ console.log('Phase: ' + String(entry.phase || 'unknown').toUpperCase());
2991
+ if (entry.summary) console.log(' ' + entry.summary);
2992
+
2993
+ console.log('');
2994
+ console.log(' git (remote):');
2995
+ if (git.comparable) {
2996
+ console.log(' ahead of trunk: ' + git.aheadBy + ' commit(s) [work not yet promoted]');
2997
+ console.log(' behind trunk: ' + git.behindBy + ' commit(s) [trunk this branch has not merged in]');
2998
+ console.log(' merge base: ' + short(git.mergeBaseSha));
2999
+ console.log(' branch HEAD: ' + short(git.branchHeadSha));
3000
+ console.log(' trunk HEAD: ' + short(git.trunkHeadSha));
3001
+ const deletions = git.deliberateDeletions || [];
3002
+ console.log(' deliberate component deletions on this branch: ' + deletions.length +
3003
+ (git.deletionEvidence === 'complete' ? '' : ' (evidence ' + git.deletionEvidence + ')'));
3004
+ deletions.slice(0, 20).forEach((p) => console.log(' D ' + p));
3005
+ } else {
3006
+ console.log(' UNAVAILABLE' + (git.message ? ': ' + git.message : ''));
3007
+ }
3008
+
3009
+ console.log('');
3010
+ console.log(' platform (what subscribers resolve right now):');
3011
+ console.log(' overridden ' + platform.overridden + ', branch-only ' + platform.added +
3012
+ ', tombstoned ' + platform.removed + ', drifted ' + platform.drifted);
3013
+ // Freshness is decided server-side (BranchPromotion.freshnessOf) because sparseness compares BOTH sides
3014
+ // and there are two independent ways to go stale. Re-deriving that here from separate booleans would be
3015
+ // a second copy of one rule.
3016
+ const FRESHNESS_LABELS = {
3017
+ 'current': ' [current]',
3018
+ 'stale-branch-moved': ' [STALE — does not match the branch HEAD]',
3019
+ 'stale-trunk-moved': ' [STALE — matches the branch HEAD, but trunk has moved since]'
3020
+ };
3021
+ console.log(' overlays computed from: ' + short(platform.syncedSha) +
3022
+ (FRESHNESS_LABELS[platform.freshness] || ' [unknown]'));
3023
+ (platform.subscribers || []).forEach((s) => {
3024
+ console.log(' subscriber: account ' + s.accountId + (s.accountName ? ' (' + s.accountName + ')' : '') +
3025
+ (s.domainName ? ' [host ' + s.domainName + ']' : ''));
3026
+ });
3027
+
3028
+ const blockers = entry.blockers || [];
3029
+ if (blockers.length) {
3030
+ console.log('');
3031
+ console.log(' BLOCKERS (' + blockers.length + '):');
3032
+ blockers.forEach((b) => {
3033
+ console.log(' * ' + b.code);
3034
+ console.log(' ' + b.message);
3035
+ console.log(' fix: ' + b.fix);
3036
+ });
3037
+ }
3038
+
3039
+ const steps = entry.nextSteps || [];
3040
+ if (steps.length) {
3041
+ console.log('');
3042
+ console.log(' NEXT, IN ORDER:');
3043
+ steps.forEach((s, i) => console.log(' ' + (i + 1) + '. ' + s));
3044
+ }
3045
+ console.log('');
3046
+ }
3047
+
3048
+ function short(sha) {
3049
+ return sha ? String(sha).slice(0, 8) : 'unknown';
3050
+ }
3051
+
2682
3052
  async function commitComponentsCommand(flags) {
2683
3053
  const cwd = process.cwd();
2684
3054
  ensureLocalState(cwd);
@@ -2862,7 +3232,26 @@ async function testCommand(flags) {
2862
3232
  throw new Error('Missing --test <id-or-name>');
2863
3233
  }
2864
3234
 
2865
- const names = flags.names ? String(flags.names).split(',').map((s) => s.trim()).filter(Boolean) : [];
3235
+ // Test case names are English prose, so COMMAS occur in them naturally. A comma-delimited selector
3236
+ // therefore split a legitimate name in half, matched nothing, and (before the server learned to
3237
+ // report unmatched selectors) reported a clean run of zero cases. `|` is the delimiter now; the
3238
+ // comma still works when no `|` is present, so existing invocations keep behaving as before.
3239
+ // `--names` may also be repeated, which needs no delimiter at all.
3240
+ const rawNames = flags.names === undefined || flags.names === null
3241
+ ? []
3242
+ : (Array.isArray(flags.names) ? flags.names : [flags.names]);
3243
+ const repeated = rawNames.length > 1;
3244
+ const names = rawNames
3245
+ .flatMap((value) => {
3246
+ const text = String(value);
3247
+ if (text.includes('|')) return text.split('|');
3248
+ // Comma-splitting is the legacy behaviour, kept so existing invocations still work. It is applied
3249
+ // ONLY to a single --names value: repeating the flag is already unambiguous, so splitting there
3250
+ // would take a deliberate literal name back apart.
3251
+ return repeated ? [text] : text.split(',');
3252
+ })
3253
+ .map((s) => s.trim())
3254
+ .filter(Boolean);
2866
3255
 
2867
3256
  const api = buildAxios(baseUrl, session.token);
2868
3257
  // --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
@@ -2918,6 +3307,23 @@ async function testCommand(flags) {
2918
3307
  }
2919
3308
 
2920
3309
  console.log('Final status:', JSON.stringify(status, null, 2));
3310
+
3311
+ // A selector that matched no case is a mis-specified run, not a passing one. Say so in the terminal
3312
+ // and exit non-zero, or "0 passed, 0 failed" reads exactly like a suite where everything passed.
3313
+ const unmatched = (status.result && status.result.unmatchedTestNames) || [];
3314
+ if (unmatched.length) {
3315
+ console.error('');
3316
+ console.error('No test case matched: ' + unmatched.map((n) => '"' + n + '"').join(', '));
3317
+ const declared = (status.result && status.result.declaredTestNames) || [];
3318
+ if (declared.length) {
3319
+ console.error('Cases declared by this suite:');
3320
+ declared.forEach((n) => console.error(' - ' + n));
3321
+ }
3322
+ console.error('--names is delimited by "|" (a comma still works when no "|" is present), so a case');
3323
+ console.error('name containing a comma must be passed with "|" or by repeating --names.');
3324
+ process.exitCode = 1;
3325
+ }
3326
+
2921
3327
  if (status.status !== 'completed') {
2922
3328
  process.exitCode = 1;
2923
3329
  } else if (status.result && status.result.failed > 0) {
@@ -2945,13 +3351,18 @@ async function tokenCommand(flags) {
2945
3351
  // Embeddable resolves that account's branch variants rather than the owner's trunk.
2946
3352
  // --variant-branch explicitly probes a committed variant branch before any edge subscribes.
2947
3353
  const variantBranch = flags['variant-branch'];
3354
+ // Send the path so the server can also mint the EMBEDDABLE-SCOPED token key. `tokenKey` opens a
3355
+ // tokenized URL in a browser; `embedTokenKey` lets the `<script>` embed loader resolve the component
3356
+ // from persisted token context because the loader request sends no path.
3357
+ const requestedPath = flags.path || flags['embeddable-path'];
2948
3358
  const data = await loggedPost(api, cwd, '/cli/token', {
2949
3359
  token: session.token,
2950
3360
  accountId,
2951
3361
  asAccountId: flags['as-account'] || flags['as-account-id'],
2952
3362
  variantBranch,
2953
3363
  branchName,
2954
- dataMode
3364
+ dataMode,
3365
+ path: requestedPath
2955
3366
  }).then((r) => r.data);
2956
3367
 
2957
3368
  if (!data.success) {
@@ -2959,7 +3370,7 @@ async function tokenCommand(flags) {
2959
3370
  }
2960
3371
 
2961
3372
  const base = baseUrl.replace(/\/$/, '');
2962
- const embeddablePath = flags.path || flags['embeddable-path'];
3373
+ const embeddablePath = requestedPath;
2963
3374
  let embeddableUrl = embeddablePath
2964
3375
  ? (base + '/s/' + data.tokenKey + '/' + String(embeddablePath).replace(/^\/+/, ''))
2965
3376
  : null;
@@ -2980,6 +3391,26 @@ async function tokenCommand(flags) {
2980
3391
  embeddableUrl
2981
3392
  };
2982
3393
 
3394
+ // What the token will RESOLVE AS. `accountId` says which account it executes as; on a hierarchy the
3395
+ // decisive facts are which component branch applies, whose components those are, and which storage
3396
+ // namespace the page reads — none of which are inferable from the id. Present only when the server
3397
+ // could resolve it.
3398
+ if (data.resolution) {
3399
+ output.resolution = data.resolution;
3400
+ }
3401
+
3402
+ // Present only when the path resolved to an Embeddable. `embedTokenKey` is what a host site pastes
3403
+ // into the loader snippet; the render shape is echoed because it decides what that host receives.
3404
+ if (data.embedTokenKey) {
3405
+ output.embeddableId = data.embeddableId;
3406
+ output.embeddableName = data.embeddableName;
3407
+ output.injectionType = data.injectionType;
3408
+ output.renderMode = data.renderMode;
3409
+ output.headMode = data.headMode;
3410
+ output.embedTokenKey = data.embedTokenKey;
3411
+ output.embedSnippet = data.embedSnippet;
3412
+ }
3413
+
2983
3414
  printSessionResolutionWarning(sessionContext);
2984
3415
  console.log(JSON.stringify(output, null, 2));
2985
3416
  }
@@ -5634,6 +6065,23 @@ function autoUpdateIfNeeded(originalArgv, options = {}) {
5634
6065
  }
5635
6066
 
5636
6067
  function printComponentsHelp(subcommand) {
6068
+ if (subcommand === 'promotion' || subcommand === 'promote') {
6069
+ console.log('Usage: remits-cli components promotion [<branch>] [--branch NAME] [--json] [--no-fail]');
6070
+ console.log('');
6071
+ console.log('Reports where a variant branch sits in the promotion loop and what to do next. It performs');
6072
+ console.log('no git operation and writes nothing. Run it from either checkout — it resolves the branch');
6073
+ console.log('OWNER, and it reads the REMOTE, which is what the platform actually syncs from.');
6074
+ console.log('');
6075
+ console.log('Phases:');
6076
+ console.log(' converged branch == trunk. Nothing to promote.');
6077
+ console.log(' ready branch is ahead of trunk and current with it -> promote.');
6078
+ console.log(' diverged branch is behind trunk. Merge trunk IN before reading any plan.');
6079
+ console.log(' awaiting-merge-back trunk absorbed this branch and moved on. Steps 4-5 are owed, and');
6080
+ console.log(' until they run the subscribers resolve the PRE-promotion code.');
6081
+ console.log('');
6082
+ console.log('Exits non-zero when there are blockers, so it can gate a runbook. --no-fail just looks.');
6083
+ return;
6084
+ }
5637
6085
  if (subcommand === 'sync') {
5638
6086
  console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--json]');
5639
6087
  console.log('');
@@ -5644,6 +6092,8 @@ function printComponentsHelp(subcommand) {
5644
6092
  console.log('');
5645
6093
  console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
5646
6094
  console.log(' --changed-only dry-run first; fail unless every planned write is a component this checkout changed');
6095
+ console.log(' --changed-since <ref> pair with --changed-only after committing: the changed set becomes');
6096
+ console.log(' the components touched between <ref> and HEAD, plus uncommitted edits');
5647
6097
  console.log(' --names-only dry-run first; print only "type id name" lines and write nothing');
5648
6098
  console.log(' --fail-on-removed dry-run first; fail if the plan removes/tombstones anything');
5649
6099
  console.log(' --fail-on-errors dry-run first; fail if the server reported any per-component sync error');
@@ -5679,7 +6129,7 @@ function printComponentsHelp(subcommand) {
5679
6129
  console.log('components commit does not support --dry-run.');
5680
6130
  return;
5681
6131
  }
5682
- console.log('Usage: remits-cli components <stage|status|clear|sync|commit|branches|branch>');
6132
+ console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
5683
6133
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5684
6134
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5685
6135
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
@@ -5698,13 +6148,13 @@ function printComponentsHelp(subcommand) {
5698
6148
  console.log(' remits-cli components branch <name> [--json] # overridden/added/removed + drift');
5699
6149
  console.log(' remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]');
5700
6150
  console.log(' remits-cli components branch <name> --subscribers [--json]');
5701
- console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]');
6151
+ console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge]');
5702
6152
  console.log(' remits-cli components branch <name> --unsubscribe <accountId>');
5703
6153
  console.log(' remits-cli components branch <name> --retire [--force]');
5704
6154
  }
5705
6155
 
5706
6156
  function printTestHelp() {
5707
- console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
6157
+ console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
5708
6158
  console.log('');
5709
6159
  console.log('Runs a Test component against the staged/variant world for this checkout.');
5710
6160
  console.log('Examples:');
@@ -5712,7 +6162,8 @@ function printTestHelp() {
5712
6162
  console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
5713
6163
  console.log('');
5714
6164
  console.log('Notes:');
5715
- console.log(' --names is comma-delimited, so avoid commas in individual test case names.');
6165
+ console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
6166
+ console.log(' A selector that matches no case fails the run rather than reporting 0 passed / 0 failed.');
5716
6167
  console.log(' --as-account changes the execution account so subscriber branch edges apply.');
5717
6168
  console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
5718
6169
  console.log(' to force production/subscription semantics from a variant checkout.');
@@ -5742,7 +6193,8 @@ function printTokenHelp() {
5742
6193
  console.log('Usage: remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
5743
6194
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
5744
6195
  console.log('');
5745
- console.log('Mints a branch-aware browser URL for embeddable verification.');
6196
+ console.log('Mints a branch-aware tokenKey and browser URL for embeddable verification.');
6197
+ console.log('When --path resolves to an Embeddable, output also includes embedTokenKey and embedSnippet for host-site embeds.');
5746
6198
  console.log('Inspect decodes a persisted Remits token and prints token metadata, recognized routing fields, safety/dataMode evidence, and full context.');
5747
6199
  }
5748
6200
 
@@ -5806,15 +6258,17 @@ async function main() {
5806
6258
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5807
6259
  console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
5808
6260
  console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
6261
+ console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
5809
6262
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
5810
6263
  console.log(' remits-cli components branch <name> [--diff <componentId> --component-type <kind>] [--subscribers] [--json]');
5811
- console.log(' remits-cli components branch <name> --subscribe <accountId> # make an account resolve this branch');
6264
+ console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
5812
6265
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
5813
6266
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
5814
- console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME]');
6267
+ console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME]');
5815
6268
  console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
5816
6269
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
5817
6270
  console.log('');
6271
+ console.log(' token output uses tokenKey for browser URLs and embedTokenKey/embedSnippet for host-site embeds.');
5818
6272
  console.log(' --as-account <ID> verifies AS a descendant subscriber account, so its relationship edge');
5819
6273
  console.log(' selects the component branch and the run resolves exactly what production will.');
5820
6274
  console.log(' --variant-branch <NAME> explicitly probes a committed variant branch (e.g. before any');
@@ -5880,6 +6334,11 @@ async function main() {
5880
6334
  return;
5881
6335
  }
5882
6336
 
6337
+ if (command === 'components' && (subcommand === 'promotion' || subcommand === 'promote')) {
6338
+ await promotionComponentsCommand(args);
6339
+ return;
6340
+ }
6341
+
5883
6342
  if (command === 'components' && (subcommand === 'branches' || subcommand === 'branch')) {
5884
6343
  await branchesComponentsCommand(args);
5885
6344
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.103",
3
+ "version": "0.1.106",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -168,11 +168,10 @@ For access questions — "who can see this client account?", "why does this user
168
168
 
169
169
  **Test-data flags are first-class safety evidence.** `Account.testAccount` and `User.testUser` are set by
170
170
  the `account(...)` / `user(...)` factories when the execution data lane is `test` (which is how
171
- `mcp_account_user_admin` creates); prod-data creates leave them false. Two limits worth knowing: a record
172
- built some other way (`new Account(...).save()` inside a component) is only flagged during an **automated
173
- Test** in the test lane, so `testAccount:false` on something you created in test mode means it is a real
174
- account; and updating an existing real account/user in test mode does not convert it into test data,
175
- though its Firestore extension-field writes still go to the test lane. `Object.testMode` /
171
+ `mcp_account_user_admin` creates); direct inserts such as `new Account(...).save()` are also flagged by the
172
+ domain `beforeInsert` hook in the test lane. Prod-data creates leave them false. Updating an existing real
173
+ account/user in test mode does not convert it into test data, though its Firestore extension-field writes
174
+ still go to the test lane. `Object.testMode` /
176
175
  `Event.testMode` / `Alert.testMode` (and `testMode` inside test-lane Audit documents) identify lifecycle
177
176
  rows in the test data lane. Agent-facing surfaces expose these fields:
178
177
 
@@ -340,6 +339,14 @@ file is catastrophic.
340
339
  - Local branch is committed and pushed; sync will read the intended remote commit.
341
340
  - Local filenames and live inventory (`mcp_account_view`) **agree on id and name for every component**: no live
342
341
  component appears locally under a different id, and no expected component is missing a repo file.
342
+ > **Do not run this comparison against `account-info.json` alone — it will report false orphans.** That
343
+ > file omits `auxiliary: true` components by design, so every auxiliary component looks like a repo file
344
+ > with no DB row, i.e. exactly the "a trunk sync will CREATE a duplicate" signal this check exists to
345
+ > catch. It also omits README- and AGENT-purpose Prompts, which live at the repo root and as `.md`
346
+ > sidecars in `components/agents/` rather than in `components/prompts/`, so they look like DB rows with
347
+ > no repo file — the "will be DELETED" signal. Both are benign. Before treating a flagged component as
348
+ > drift, confirm against the live row: `mcp_component_view`, or
349
+ > `mcp_run_action controlAction:"describe"` for an Action. A component that answers is not an orphan.
343
350
  - You can state the expected create/update/delete set. **If any delete or renumber is unexpected, stop.**
344
351
 
345
352
  ### If something looks wrong — stop, don't paper over
@@ -896,7 +903,16 @@ Tests run on the platform against your staged snapshot. They stream results in r
896
903
 
897
904
  Important test-runner constraints:
898
905
  - `remits-cli test run` now defaults to `test` dataMode unless you explicitly pass `--data-mode prod`.
899
- - Do not put commas in individual `test("...")` names. The CLI `--names` filter is comma-delimited, so comma-bearing test names cannot be targeted cleanly as a single selected case.
906
+ - **`--names` is delimited by `|`, and may be repeated.** A comma still splits a single `--names` value
907
+ (legacy behaviour), which is why a case name containing a comma used to be cut in half and match
908
+ nothing. Prefer `|` or repetition whenever a name might contain punctuation:
909
+ ```bash
910
+ remits-cli test run --test 13 --names "a case, with a comma|another case"
911
+ remits-cli test run --test 13 --names "a case, with a comma" --names "another case"
912
+ ```
913
+ - **A selector that matches no case FAILS the run.** It used to report `0 passed, 0 failed`,
914
+ `completed`, and exit 0 — indistinguishable from a suite where everything passed. The run now names
915
+ the unmatched selectors and lists the cases the suite actually declared, and exits non-zero.
900
916
 
901
917
  If no relevant Test component exists yet, consider creating one. Test components live in `components/tests/` and follow the same component structure. They provide permanent regression protection — every test you write today saves debugging time tomorrow.
902
918
 
@@ -930,6 +946,25 @@ playwright-cli eval "() => document.querySelector('.total-amount').textContent"
930
946
 
931
947
  The `testMode` metadata confirms you're testing against staged changes, not production.
932
948
 
949
+ **Two different token keys come back, for two different jobs.** When `--path` resolves to an
950
+ Embeddable, the response carries an `embedTokenKey` and a paste-ready `embedSnippet` alongside the usual
951
+ `tokenKey` / `embeddableUrl`:
952
+
953
+ | Field | Use it for |
954
+ |---|---|
955
+ | `tokenKey` / `embeddableUrl` | Opening the page in a browser (Playwright, or clicking the link) |
956
+ | `embedTokenKey` / `embedSnippet` | The `<script>` embed loader — verifying the page as a HOST SITE embeds it |
957
+
958
+ They are not interchangeable. The loader's request carries **no path**, so it resolves the component
959
+ purely from the embeddable-scoped token key's persisted context. The browser `tokenKey` names the account
960
+ preview URL; `embedTokenKey` names the host-loader credential. The response also echoes `injectionType` /
961
+ `renderMode` / `headMode`, which decide what a host actually receives
962
+ (`guides/components/embeddable-components.md`).
963
+
964
+ **This works for a `new_` component that has never been synced.** The embed token key carries the
965
+ component NAME as well as its id, so a staged, id-less Embeddable is loader-addressable — you do not
966
+ have to sync it, or borrow another component's id, just to verify a host embed.
967
+
933
968
  **Option C — Use investigation tools** (for backend/data changes):
934
969
 
935
970
  For changes to Readers, Actions, or Rules that process data rather than display UI, verify by examining the data they produce:
@@ -1179,6 +1214,12 @@ TestMode still uses the committed DB source. Staging remains a dev/verification
1179
1214
  branch/user scope. This is the normal edit/test loop.
1180
1215
  - `remits-cli components status` shows which branch/variant world the checkout resolves, plus staged entries,
1181
1216
  staged fields, aliases, hashes, and TTLs. Use `--json` or `--verbose` for the full staged-entry payload.
1217
+ - **A full `stage` makes the `.meta.yml` AUTHORITATIVE.** Staging layers a payload over what is already
1218
+ staged, which is what lets `mcp_component_edit` write a single field without blanking the others. But a
1219
+ `remits-cli components stage` sends the whole sidecar, so a key you DELETE from a sidecar is removed from
1220
+ the staged entry rather than lingering — "restore the file and re-stage" restores the staged state, which
1221
+ is the only mental model that is safe to have. Content fields still layer (they come from separate files),
1222
+ so a partial stage is unaffected.
1182
1223
  - `remits-cli components clear` drops staged entries when you intentionally want to fall back to committed DB
1183
1224
  source. An empty staging scope is clean state, not a failure.
1184
1225
  - `remits-cli components sync` syncs the DB from the pushed git remote and then clears staged entries for the
@@ -1352,6 +1393,63 @@ remits-cli token --path embeddable/index/50 --as-account 101 # subscriber -> v
1352
1393
  remits-cli token --path embeddable/index/50 # owner -> trunk
1353
1394
  ```
1354
1395
 
1396
+ Each answer carries a **`resolution`** block for the account the token executes as — `role`,
1397
+ `componentBranch` and its owner, `resolvedDatabaseName`, `resolvedDomainName`, `scopeAccountId`, and a
1398
+ one-sentence `summary`. Read it before opening the URL: `accountId` alone does not say whether a branch
1399
+ overlay applies or which storage namespace the page will read, and those are exactly what
1400
+ `--as-account` is being used to change.
1401
+
1402
+ Separate branch fields, because these questions answer differently and can disagree:
1403
+
1404
+ | Field | Answers |
1405
+ |---|---|
1406
+ | `componentBranch` | what **this token** will resolve |
1407
+ | `componentBranchSource` | `probe` (an explicit `--variant-branch`), `subscription` (the account's edge), or `trunk` |
1408
+ | `subscribedComponentBranch` | what the **account graph** says, independent of this token |
1409
+ | `componentBranchAnchored` | whether **this resolution actually reached** that subscription |
1410
+
1411
+ A single `componentBranch` field would have reported `trunk` under `--variant-branch X`, for a token that
1412
+ resolves `X`.
1413
+
1414
+ **Subscribing to a branch and resolving through it are different facts.** An account subscribes on an
1415
+ edge; a *request* resolves through that edge only when something named the path (`--as-account`, the
1416
+ edge's own host, an explicit `--variant-branch`). An account with no `parentId` and several upward links
1417
+ resolves trunk by design — the resolver refuses to guess a path. So this is a normal, explainable state:
1418
+
1419
+ ```
1420
+ "componentBranch": null, // this token resolves TRUNK
1421
+ "componentBranchSource": "trunk",
1422
+ "subscribedComponentBranch": "forked", // ...but the account does subscribe
1423
+ "componentBranchAnchored": false // ...and nothing anchored this request to it
1424
+ ```
1425
+
1426
+ Reading `componentBranch` alone there tells you the account is on trunk, which is true — and leads you to
1427
+ conclude it has no branch, which is false. If several edges carry branches, none is picked for you:
1428
+ `subscribedComponentBranch` is `null` and `subscribedComponentBranches` lists them.
1429
+
1430
+ Under a probe, `componentOwnerAccountId` is the **nearest account on the token's resolution path that owns
1431
+ variant rows for X** — not an edge lookup, which answers `null` for the ordinary case of a variant
1432
+ committed before anything subscribes to it. `null` means no account on that path owns rows for X, and what
1433
+ that implies depends on the account, so read `summary`:
1434
+
1435
+ - **on trunk** — the page resolves what it would without the probe; the branch is not committed yet.
1436
+ - **resolving through a subscription** — the probe **suppresses** it. `variantBranch` outranks the
1437
+ subscription before it is consulted, so the page resolves **trunk**, not the overlays that account
1438
+ normally gets. Drop `--variant-branch` to see the subscription.
1439
+ - **subscribed but not anchored** — it was already resolving trunk before you probed. Dropping
1440
+ `--variant-branch` will *not* by itself show you the branch; name the path as well.
1441
+
1442
+ The page itself then states the same facts. Its hidden `remits-session-info` line — which appears in an
1443
+ accessibility snapshot with no script — carries `Account` (what it runs as), `Addressed Account` (what
1444
+ the URL/token named), `Data Lane`, `Component Branch`, `Variant Applied`, and `Scope Account`.
1445
+
1446
+ **`Component Branch` is the branch SELECTED; `Variant Applied` is what actually overlaid.** They are two
1447
+ fields because a branch can be selected and overlay nothing: `--variant-branch missing_branch` reports
1448
+ `Component Branch: missing_branch` while the page renders trunk components. So read `Variant Applied` —
1449
+ the `ComponentVariant` row id this page's component resolved through, or `none` — when the question is
1450
+ "did my variant apply?". That is a far sharper signal than inspecting the rendered markup for a style you
1451
+ expected.
1452
+
1355
1453
  If `components status` shows staged entries that you cannot safely clear, isolate verification in an
1356
1454
  unused staging namespace instead of deleting someone else's cache:
1357
1455
 
@@ -1383,10 +1481,41 @@ for you, and merging a branch promotes its **deletions** as hard deletes. Do not
1383
1481
  `remits-cli components sync --summary --timeout-ms 300000`; the platform may need longer than the default
1384
1482
  60 seconds to create rows, push rename/meta commits, and regenerate account metadata.
1385
1483
 
1484
+ ### Where am I in the promotion loop?
1485
+
1486
+ ```bash
1487
+ remits-cli components promotion # the branch you are standing on
1488
+ remits-cli components promotion --branch forked --json
1489
+ ```
1490
+
1491
+ **Run this before every promotion step and after every one.** It reports only — no git, no writes — and it
1492
+ reads the remote (which is what the platform syncs from, not your working tree). It works from either
1493
+ checkout, because it resolves the branch's owner itself. It exits non-zero while blockers remain, so treat
1494
+ that as "do not proceed".
1495
+
1496
+ | Phase | Meaning | Next |
1497
+ |---|---|---|
1498
+ | `converged` | branch == trunk | nothing to do; this is also what a **finished** promotion looks like |
1499
+ | `ready` | ahead of trunk, current with it | promote |
1500
+ | `diverged` | behind trunk | `git merge <trunk>` into the branch, re-sync, re-read the plan |
1501
+ | `awaiting-merge-back` | fully contained in trunk, trunk has moved on | **steps 4–5 are owed** — merge trunk back and re-sync |
1502
+
1503
+ Two things it tells you that nothing else does:
1504
+
1505
+ - **Which commit the stored counts describe, on BOTH axes.** An overlay is stored only while a file
1506
+ differs from trunk, so the stored set is a statement about a (branch, trunk) pair and either side moving
1507
+ makes it stale: `[STALE — does not match the branch HEAD]` (the branch was pushed since) or
1508
+ `[STALE — matches the branch HEAD, but trunk has moved since]`. The second is the easy one to miss —
1509
+ measured live, a branch at its own synced HEAD reported 2 stored overlays against a real plan of 21.
1510
+ - **That a promotion is unfinished.** `awaiting-merge-back` is what a promotion that *looked* successful
1511
+ leaves behind: trunk is correct and its tests pass, while the subscriber silently keeps resolving its
1512
+ pre-promotion overlays — including for any fix made afterwards. **A trunk sync succeeding is step 2 of 5,
1513
+ not completion.**
1514
+
1386
1515
  ### Subscribing, unsubscribing, retiring
1387
1516
 
1388
1517
  ```bash
1389
- remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]
1518
+ remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge]
1390
1519
  remits-cli components branch <name> --unsubscribe <accountId>
1391
1520
  remits-cli components branch <name> --retire [--force]
1392
1521
  ```
@@ -1406,6 +1535,11 @@ Branch administration commands act **as the account you are running from**, whic
1406
1535
  Without it the CLI picks the edge to the owner whose branch you are managing, then falls back to the
1407
1536
  account's primary edge — which may not be the one you intended. `--subscribers` prints
1408
1537
  `via primary|membership edge -> parent N` so you can confirm.
1538
+ - **Primary-edge subscriptions are structural.** If the selected edge is the account's `parentId` edge,
1539
+ subscribing it makes that account and descendants resolve the branch by default even though `parentId`
1540
+ still points at the same parent. The server refuses this write unless you pass
1541
+ `--confirm-primary-edge`; run `--dry-run` first and prefer a membership edge for fork/pilot
1542
+ subscriptions.
1409
1543
  - **Retiring is explicit.** Deleting the *git* branch does **not** remove its overlays; subscribers would
1410
1544
  keep resolving a branch that no longer exists. `--retire` refuses while subscribers remain unless you
1411
1545
  pass `--force`.
@@ -1438,7 +1572,13 @@ The analogous hazard is different, and you must still respect it:
1438
1572
  - **Deleting a file means "remove the component", not "stop overriding it."** To withdraw an override,
1439
1573
  make the file identical to trunk again — the sync then removes the variant row.
1440
1574
  - **Keep the branch rebased.** A branch physically carries every file, so anything trunk added *after* the
1441
- branch was cut looks like a deliberate deletion. A small stale gap tombstones silently.
1575
+ branch was cut is absent from it. The sync now asks git which of those absences are real deletions
1576
+ (comparing against the merge base) and refuses to tombstone the rest, reporting them as
1577
+ `skipped: absent from the branch but never deleted on it`. Treat any such entry as "merge trunk in" —
1578
+ if an older sync already stored one of those absences as a tombstone, a non-dry-run sync prunes it and a
1579
+ dry run reports `wouldPrune: true`. The classification falls back to a conservative ratio guard when
1580
+ GitHub's compare is unavailable or its file list comes back truncated, which the sync output says
1581
+ explicitly.
1442
1582
  - **Use `components sync --dry-run` before risky variant syncs.** It reports `overridden`, `added`,
1443
1583
  `removed`, `unchanged`, `skipped`, and `errors` without writing rows, caching the sync SHA, or clearing
1444
1584
  staging. Existing overlay ids appear as `variantId`; an `unchanged` row with `pruned:true` means the
@@ -1494,6 +1634,15 @@ a confirm-gated override when the removal guard refuses. Preview there is the sa
1494
1634
  - A test/tool response reports `testComponentSource` as `staged` | `variant` | `db`, so you can see which
1495
1635
  layer the run resolved without reading logs.
1496
1636
 
1637
+ > **A populated staging cache makes a variant look broken.** Anything carrying a CLI `TestMode` — a
1638
+ > `remits-cli token` URL, `/s/<tokenKey>/...`, an `X-Auth-Token` request, a script-loader embed — resolves
1639
+ > the STAGED layer, which outranks the variant. So with components staged under the same branch/user, a
1640
+ > tokenized page reports `Component Branch: <branch>` but `Variant Applied: none` and renders trunk, while
1641
+ > an anonymous `?account_id=` request to the same page reports the variant. That is the documented
1642
+ > precedence (staged -> variant -> trunk) working correctly, and it reads exactly like "tokens break
1643
+ > variant resolution". **Run `remits-cli components clear --all` before verifying variant resolution
1644
+ > through any tokenized entry point**, or check `remits-cli components status` first.
1645
+
1497
1646
  ## Production Support Workflow
1498
1647
 
1499
1648
  Switch to prod mode for investigations:
@@ -2681,16 +2830,16 @@ remits-cli data-mode [set test|prod]
2681
2830
  remits-cli components stage [--branch <name>] [--data-mode test|prod] [--json|--verbose]
2682
2831
  remits-cli components status [--branch <name>] [--component-type <type>] [--component-id <id>] [--json|--verbose]
2683
2832
  remits-cli components clear [--branch <name>] [--component-type <type>] [--component-id <id>] [--all] [--json|--verbose] # id alone scopes to one component when unambiguous (ids are type-local; add --component-type if the same id is staged in multiple families); no filter clears the whole branch scope; --all forces the full wipe
2684
- remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
2833
+ remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--changed-only [--changed-since <ref>]] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
2685
2834
  remits-cli components commit [--message "msg"] [--data-mode test|prod] [--force-tombstones]
2686
2835
  remits-cli components branches [--json] # branches carrying committed variants, with counts + drift
2687
2836
  remits-cli components branch <name> [--json] # one branch: overridden / added / removed, drift flags, subscribers
2688
2837
  remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]
2689
2838
  remits-cli components branch <name> --subscribers [--json]
2690
- remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] # make an account resolve this branch
2839
+ remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
2691
2840
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
2692
2841
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
2693
- remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a,b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2842
+ remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2694
2843
  remits-cli token [--path <embeddablePathOrId>] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2695
2844
  remits-cli token inspect --token <token|tokenKey|URL> # inspect token metadata, safety/dataMode evidence, and full context
2696
2845
  remits-cli tools [--branch <name>] [--data-mode test|prod] [--variant-branch <name|none>]
@@ -2700,7 +2849,8 @@ remits-cli tool status --call-id <callId> [--data-mode test|prod]
2700
2849
 
2701
2850
  For tests specifically:
2702
2851
  - If `--data-mode` is omitted, `remits-cli test run` uses `test`.
2703
- - `--names` is comma-delimited, so keep individual test names comma-free.
2852
+ - `--names` is `|`-delimited (a comma still splits a single value) and may be repeated; an unmatched
2853
+ selector fails the run instead of reporting zero cases as success.
2704
2854
  - `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
2705
2855
  (*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
2706
2856
  (*"what does branch Y look like?"*), and `--variant-branch none` forces production/subscription semantics.
@@ -2718,10 +2868,16 @@ For tests specifically:
2718
2868
  - **Fail-closed sync gates.** On non-trunk variant branches these flags now force a server dry-run first,
2719
2869
  evaluate that plan before any overlay row is written, and only then run the mutating sync when the plan
2720
2870
  passes. On trunk, there is no safe dry-run plan, so do not treat these as scoped commit controls:
2721
- - `--changed-only` — fail unless every planned write is a component **this checkout actually edited**.
2871
+ - `--changed-only` — fail unless every planned write is a component **this checkout actually changed**.
2722
2872
  This is the strongest guard against a sync that quietly rewrites components you never touched. It
2723
2873
  also fails when the checkout is not a git working tree, because "git could not answer" must never
2724
2874
  be read as "nothing changed".
2875
+ > **Pair it with `--changed-since <ref>` after you have committed.** On its own `--changed-only`
2876
+ > reads UNCOMMITTED edits, and the documented flow commits and pushes *before* syncing (sync reads
2877
+ > the pushed remote, so it cannot see uncommitted work at all) — so the changed set is empty at
2878
+ > exactly the moment the gate runs, and it refuses the whole plan. `--changed-since origin/main`
2879
+ > (or the commit you branched from) makes the changed set the components your commits touched.
2880
+ > The refusal message says this when it detects the empty-set case.
2725
2881
  - `--fail-on-removed` — fail if the plan removes or tombstones anything.
2726
2882
  - `--expected-removed <type:id>` — whitelist the removals you intend (repeatable, or comma-delimited,
2727
2883
  e.g. `--expected-removed action:5,reader:9`). It **implies** `--fail-on-removed`, so any removal you
@@ -2779,6 +2935,8 @@ For tests specifically:
2779
2935
  | A component vanished for one account after a variant-branch sync | Its file is missing from that branch, so the sync created a **tombstone** that hides it from subscribers. Restore the file on the branch and re-sync. Trunk is unaffected. |
2780
2936
  | A variant Test suite fails wholesale, asserting trunk where you expect a variant | You are almost certainly running it from a **variant checkout**: `variantBranch` outranks every subscription, so the suite's own fixture accounts resolve YOUR branch. Re-run from trunk or with `--variant-branch none` before treating it as a regression. |
2781
2937
  | `components sync` on a branch says "No changes detected" but trunk has moved | Re-run it; a trunk sync now invalidates the branch's cached verdict. If it still skips, the branch genuinely matches trunk - check `components branch <name>` for what is actually stored. |
2938
+ | A sync summary reports `skipped: true` with `NO COMPONENTS WERE SYNCED` | The branch head matches the cached sync SHA, so the branch was never re-read — this is NOT an empty plan. It matters when something else changed the overlays at that same SHA: an ordinary re-sync then answers "nothing to do" forever. `--dry-run` always re-reads the branch and shows the real plan; `--force-tombstones` re-reads and applies it. |
2939
+ | A branch sync stores overlays for components you never edited on the branch | **Trunk moved.** Sparseness compares the branch against CURRENT trunk, so editing a component on trunk without merging trunk into the branch turns it into a branch overlay on the next branch sync. The branch did not change. Merge trunk in, push, re-sync — the overlays prune. Check `components promotion` for the phase. |
2782
2940
  | After promoting a `new_*` component to trunk, the branch still shows it as `added` | You have not re-synced the branch since the trunk sync. Merge trunk into the branch and `components sync`: the file adopts the promoted id and, if unchanged, removes its own overlay. If the branch carries BOTH `new_Foo.*` and `<id>_Foo.*`, the id file wins and the `new_` one is reported `skipped: superseded` - delete it. |
2783
2941
  | After promoting a standalone Prompt, the branch still shows `OVERRIDDEN prompt:<id>` with only `purpose` changed | The trunk Prompt row does not match repo metadata. Ensure the Prompt sidecar has `description` and `purpose: CUSTOM`, run a platform build that imports Prompt `purpose`, re-sync trunk, merge back, and re-sync the branch. |
2784
2942
  | A branch preview reports a `removed` component nobody deleted | Check whether that component has a file on **trunk**. A DB row with no trunk file is missing from every branch, so it reads as a removal everywhere (and the trunk sync tries to hard-delete it each run). Repair trunk, not the branch. |