@remits/remits-cli 0.1.103 → 0.1.104

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
@@ -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);
@@ -2342,19 +2350,97 @@ async function syncComponentsCommand(flags) {
2342
2350
  }
2343
2351
  if (namesOnly) {
2344
2352
  printSyncNames(response);
2353
+ printPromotionSignals(response);
2345
2354
  failOnSyncGate(gate);
2346
2355
  return response;
2347
2356
  }
2348
2357
  if (flagEnabled(flags.summary)) {
2349
2358
  console.log('Components sync summary:', JSON.stringify(summary, null, 2));
2359
+ printPromotionSignals(response);
2350
2360
  failOnSyncGate(gate);
2351
2361
  return response;
2352
2362
  }
2353
2363
  console.log('Components sync:', JSON.stringify(response, null, 2));
2364
+ printPromotionSignals(response);
2354
2365
  failOnSyncGate(gate);
2355
2366
  return response;
2356
2367
  }
2357
2368
 
2369
+ // A sync is a step in a longer loop, and the steps after it are the ones that get skipped — so print what
2370
+ // the server just observed about this branch's relationship to trunk, not only what it wrote.
2371
+ function printPromotionSignals(response) {
2372
+ const sync = (response && response.sync) || {};
2373
+
2374
+ const cmp = sync.branchComparison;
2375
+ if (cmp && cmp.comparable) {
2376
+ console.log('');
2377
+ console.log('Branch vs trunk "' + cmp.trunkBranch + '": ' + cmp.aheadBy + ' ahead, ' + cmp.behindBy +
2378
+ ' behind [phase ' + cmp.phase + ']');
2379
+ if (cmp.phase === 'diverged') {
2380
+ console.log(' This branch has not merged trunk in. Its overlays assert its OLD copy of everything');
2381
+ console.log(' trunk has changed since, and its subscribers are running that copy right now.');
2382
+ console.log(' Fix: git merge ' + cmp.trunkBranch + ' into this branch, push, then sync again.');
2383
+ } else if (cmp.phase === 'awaiting-merge-back') {
2384
+ console.log(' Every commit here is already in trunk and trunk has moved on — a promotion merge-back');
2385
+ console.log(' is outstanding. Until it happens the subscribers stay pinned to the pre-promotion copy.');
2386
+ console.log(' Fix: git merge ' + cmp.trunkBranch + ' into this branch, push, then sync again.');
2387
+ }
2388
+ if (cmp.deletionEvidence !== 'complete') {
2389
+ console.log(' NOTE: deletion evidence is ' + cmp.deletionEvidence + ', so removals fall back to the');
2390
+ console.log(' conservative ratio guard rather than git\'s own answer.');
2391
+ }
2392
+ } else if (cmp) {
2393
+ console.log('');
2394
+ console.log('Branch vs trunk: UNAVAILABLE' + (cmp.message ? ' (' + cmp.message + ')' : ''));
2395
+ }
2396
+
2397
+ const promotion = sync.promotion;
2398
+ if (promotion) {
2399
+ (promotion.tags || []).forEach((t) => {
2400
+ console.log('');
2401
+ console.log('Tagged promotion: ' + t.tag + ' -> ' + short(t.sha) + ' (' + t.ref + ')');
2402
+ });
2403
+ // Older servers sent bare branch names; newer ones send {branch, overlayCount}. Normalize, because the
2404
+ // count decides whether this is an alarm or a tidy-up and the difference must not be guessed.
2405
+ const pending = (promotion.pendingMergeBack || []).map((entry) =>
2406
+ (typeof entry === 'string' ? { branch: entry, overlayCount: -1 } : entry));
2407
+ if (pending.length) {
2408
+ const shadowing = pending.filter((p) => p.overlayCount > 0);
2409
+ const unknown = pending.filter((p) => !(p.overlayCount >= 0));
2410
+ console.log('');
2411
+ if (shadowing.length) {
2412
+ console.log('THE PROMOTION IS NOT FINISHED. Trunk is synced, but ' + shadowing.length +
2413
+ ' variant branch(es) still SHADOW it — their subscribers resolve the PRE-promotion code:');
2414
+ } else if (unknown.length) {
2415
+ // The overlay count could not be read, so say nothing about what is shadowed. "Nothing is
2416
+ // shadowed" would be a claim, and an unverifiable one is the failure this whole change is about.
2417
+ console.log('THE PROMOTION IS NOT FINISHED. Trunk is synced, but ' + pending.length +
2418
+ ' variant branch(es) are behind it and owe a merge-back:');
2419
+ } else {
2420
+ console.log('Merge-back outstanding on ' + pending.length + ' variant branch(es). Nothing is shadowed' +
2421
+ ' (no overlays stored), but the branch is behind trunk until you merge it back:');
2422
+ }
2423
+ pending.forEach((p) => {
2424
+ const detail = p.overlayCount > 0 ? ' (' + p.overlayCount + ' overlay(s) shadowing trunk)'
2425
+ : p.overlayCount === 0 ? ' (0 overlays — converged, just behind)' : '';
2426
+ console.log(' ' + p.branch + ':' + detail);
2427
+ console.log(' git checkout ' + p.branch + ' && git merge ' + (sync.branchName || 'main') + ' && git push');
2428
+ console.log(' remits-cli components sync --branch ' + p.branch);
2429
+ });
2430
+ console.log(' Verify with: remits-cli components promotion --branch <name>');
2431
+ }
2432
+ }
2433
+
2434
+ (sync.advisories || []).forEach((advisory) => {
2435
+ console.log('');
2436
+ console.log('ADVISORY [' + advisory.code + ']: ' + advisory.message);
2437
+ (advisory.findings || []).slice(0, 15).forEach((f) => {
2438
+ console.log(' ' + f.path + ':' + f.line + ' ' + f.call);
2439
+ });
2440
+ if (advisory.truncated) console.log(' ... (list truncated; re-run with --json for the rest)');
2441
+ });
2442
+ }
2443
+
2358
2444
  function syncPreflightRequested(flags) {
2359
2445
  return Boolean(
2360
2446
  flagEnabled(flags['names-only']) ||
@@ -2482,9 +2568,41 @@ function failOnSyncGate(gate) {
2482
2568
  function buildSyncSummary(response) {
2483
2569
  const sync = (response && response.sync) || {};
2484
2570
  const results = sync.syncResults || {};
2485
- const removed = Array.isArray(results.removed) ? results.removed : [];
2486
2571
  const errors = Array.isArray(results.errors) ? results.errors : [];
2487
2572
  const skipped = Array.isArray(results.skipped) ? results.skipped : [];
2573
+
2574
+ // A TRUNK sync and a VARIANT sync report in different vocabularies, and this summary only ever spoke
2575
+ // variant. So the single most consequential line of a promotion — "component X was CREATED on trunk as
2576
+ // id N" — printed as `added: 0`, and the runbook's definition of done ("components created, with their
2577
+ // new ids") could not be satisfied from the summary it tells you to read.
2578
+ if ((sync.mode || 'trunk') === 'trunk') {
2579
+ const created = Array.isArray(results.created) ? results.created : [];
2580
+ const updated = Array.isArray(results.updated) ? results.updated : [];
2581
+ const renamed = Array.isArray(results.renamed) ? results.renamed : [];
2582
+ const deleted = Array.isArray(results.deleted) ? results.deleted : [];
2583
+ const trunkWarnings = [];
2584
+ if (deleted.length) trunkWarnings.push(String(deleted.length) + ' live component(s) DELETED');
2585
+ if (errors.length) trunkWarnings.push(String(errors.length) + ' sync error' + (errors.length === 1 ? '' : 's') + ' present');
2586
+ return {
2587
+ success: response && response.success === true,
2588
+ accountId: response && response.accountId,
2589
+ branchName: sync.branchName || (response && response.branchName),
2590
+ mode: 'trunk',
2591
+ dryRun: false,
2592
+ // Ids are the part that cannot be recovered later: a promotion mints them once, and anything that
2593
+ // recorded the old identity will not line up if they are re-minted.
2594
+ created: created.map((e) => ({ type: e.type, name: e.name, id: e.newId != null ? e.newId : e.id })),
2595
+ updated: updated.length,
2596
+ renamed: renamed.length,
2597
+ deleted: deleted.map((e) => ({ type: e.type, name: e.name, id: e.id })),
2598
+ skipped: skipped.length,
2599
+ errors: errors.length,
2600
+ errorDetails: errors.map((entry) => ({ type: entry.type, id: entry.id, error: entry.error })),
2601
+ warnings: trunkWarnings
2602
+ };
2603
+ }
2604
+
2605
+ const removed = Array.isArray(results.removed) ? results.removed : [];
2488
2606
  const added = Array.isArray(results.added) ? results.added : [];
2489
2607
  const overridden = Array.isArray(results.overridden) ? results.overridden : [];
2490
2608
  const unchanged = Array.isArray(results.unchanged) ? results.unchanged : [];
@@ -2573,6 +2691,8 @@ async function branchesComponentsCommand(flags) {
2573
2691
  // Optional branch-scoped custom host set on the same edge as the subscription.
2574
2692
  // `--domain none` clears it; omitting the flag leaves any existing host untouched.
2575
2693
  domainName: flags.domain || flags['domain-name'],
2694
+ dryRun: flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun),
2695
+ confirmPrimaryEdge: flagEnabled(flags['confirm-primary-edge']) || flagEnabled(flags.confirmPrimaryEdge),
2576
2696
  force: flagEnabled(flags.force)
2577
2697
  }).then((r) => r.data);
2578
2698
 
@@ -2606,9 +2726,13 @@ function printBranchesSummary(response) {
2606
2726
  console.log(' ' + b.branch +
2607
2727
  ' (overridden ' + b.overridden + ', added ' + b.added + ', removed ' + b.removed +
2608
2728
  ', subscribers ' + b.subscribers + ')' + drift);
2729
+ // Name the commit these counts describe. Without it a three-day-old inventory reads exactly like a
2730
+ // current one, which is how a promotion gets reviewed against the wrong tree.
2731
+ console.log(' computed from ' + short(b.syncedSha) + (b.updated ? ' at ' + b.updated : ''));
2609
2732
  });
2610
2733
  console.log('');
2611
- console.log('Detail: remits-cli components branch <name>');
2734
+ console.log('Detail: remits-cli components branch <name>');
2735
+ console.log('Promotion: remits-cli components promotion --branch <name> # is that commit still the branch HEAD?');
2612
2736
  return;
2613
2737
  }
2614
2738
 
@@ -2666,6 +2790,12 @@ function printBranchesSummary(response) {
2666
2790
 
2667
2791
  if (response.mode === 'subscribe' || response.mode === 'unsubscribe' || response.mode === 'retire') {
2668
2792
  console.log(response.message);
2793
+ if (response.requiresConfirmation) {
2794
+ console.log('Confirmation required: re-run with --confirm-primary-edge if that structural edge should subscribe.');
2795
+ }
2796
+ if (response.edge) {
2797
+ console.log('Edge: ' + response.edge.edgeType + ' -> parent ' + response.edge.parentAccountId);
2798
+ }
2669
2799
  return;
2670
2800
  }
2671
2801
 
@@ -2679,6 +2809,167 @@ function printBranchesSummary(response) {
2679
2809
  });
2680
2810
  }
2681
2811
 
2812
+ // The promotion "lay of the land": where this branch sits relative to trunk, whether the platform's
2813
+ // stored overlays actually describe the branch's current HEAD, who is affected, and the ordered commands
2814
+ // for the next step. Every fact here was already obtainable one command at a time; the point is that a
2815
+ // promotion goes wrong by assembling a wrong picture out of individually true answers, so this assembles
2816
+ // the picture once, server-side, from the REMOTE (which is what the platform syncs from — not your
2817
+ // working tree).
2818
+ async function promotionComponentsCommand(flags) {
2819
+ const cwd = process.cwd();
2820
+ ensureLocalState(cwd);
2821
+ const sessionContext = resolvePromotionSessionContext(cwd, flags);
2822
+ const { session, accountId } = sessionContext;
2823
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2824
+ const api = buildAxios(baseUrl, session.token);
2825
+
2826
+ const positional = flags._ && flags._[2];
2827
+ const branchName = flags.branch || positional || currentBranch(cwd);
2828
+
2829
+ const response = await loggedPost(api, cwd, '/cli/branches', {
2830
+ token: session.token,
2831
+ accountId,
2832
+ mode: 'promotion',
2833
+ branchName
2834
+ }).then((r) => r.data);
2835
+
2836
+ if (!response.success) {
2837
+ throw new Error(response.message || 'Promotion status failed');
2838
+ }
2839
+
2840
+ if (flagEnabled(flags.json)) {
2841
+ console.log(JSON.stringify(response, null, 2));
2842
+ } else {
2843
+ printSessionResolutionWarning(sessionContext);
2844
+ printResolvedBaseUrl(baseUrl);
2845
+ // Say this out loud: the command is named for the operation it reports on, and an agent that reads
2846
+ // "promotion" as "promoted" would skip the git steps this output is telling it to run.
2847
+ console.log('This REPORTS promotion readiness. It performs no git operation and writes nothing.');
2848
+ if (Array.isArray(response.branches)) {
2849
+ response.branches.forEach((entry) => printPromotionStatus(entry, response));
2850
+ } else {
2851
+ printPromotionStatus(response, response);
2852
+ }
2853
+ }
2854
+
2855
+ // Fail closed by default. This command exists to be the gate an agent runs BEFORE touching git, and a
2856
+ // gate that always exits 0 is a suggestion. `--no-fail` is for a human who only wants to look.
2857
+ const blockers = collectPromotionBlockers(response);
2858
+ if (blockers.length && !flagEnabled(flags['no-fail'])) {
2859
+ throw new Error('promotion is blocked (' + blockers.length + '):\n ' +
2860
+ blockers.map((b) => b.code + ': ' + b.message).join('\n ') +
2861
+ '\nRe-run with --no-fail to inspect without failing.');
2862
+ }
2863
+ return response;
2864
+ }
2865
+
2866
+ // A promotion is normally driven from the BRANCH's checkout, and on a single-subscriber branch that
2867
+ // checkout's account-info.json describes the SUBSCRIBER — so the CLI resolves the subscriber's account id
2868
+ // and demands a session for it, which an owner-side agent has no reason to hold. Every other branch
2869
+ // command has the same shape and tells you to pass `--account-id <ownerId>`; this one should not need to
2870
+ // be told, because it is READ-ONLY and the server already resolves the branch owner itself.
2871
+ //
2872
+ // So: if there is no session for the local account, and the checkout names its component owner, retry as
2873
+ // the owner. Deliberately scoped to this command — `sync`/`commit` mutate, and there the account the CLI
2874
+ // acts as is a decision, not a detail.
2875
+ function resolvePromotionSessionContext(cwd, flags) {
2876
+ try {
2877
+ return resolveSessionContext(cwd, flags);
2878
+ } catch (err) {
2879
+ if (flags && flags['account-id']) throw err;
2880
+ let ownerId = null;
2881
+ try {
2882
+ const info = JSON.parse(fs.readFileSync(path.join(cwd, 'account-info.json'), 'utf8'));
2883
+ const resolution = (info && info.resolution) || {};
2884
+ const localId = Number(accountIdFromAccountInfo(info));
2885
+ const candidate = Number(resolution.componentOwnerAccountId);
2886
+ if (Number.isFinite(candidate) && candidate > 0 && candidate !== localId) ownerId = candidate;
2887
+ } catch (_) { /* not an account repo, or no owner recorded */ }
2888
+ if (!ownerId) throw err;
2889
+
2890
+ const ownerContext = resolveSessionContext(cwd, Object.assign({}, flags, { 'account-id': ownerId }));
2891
+ console.log('Note: no session for this checkout\'s account; reporting as its component OWNER, account ' +
2892
+ ownerId + '. Branch overlays belong to the owner, so this is the account that can answer.');
2893
+ return ownerContext;
2894
+ }
2895
+ }
2896
+
2897
+ function collectPromotionBlockers(response) {
2898
+ if (Array.isArray(response.branches)) {
2899
+ return response.branches.reduce((all, entry) => all.concat(entry.blockers || []), []);
2900
+ }
2901
+ return response.blockers || [];
2902
+ }
2903
+
2904
+ function printPromotionStatus(entry, envelope) {
2905
+ const git = entry.git || {};
2906
+ const platform = entry.platform || {};
2907
+ console.log('');
2908
+ console.log('Branch: ' + entry.branch + ' (trunk "' + (entry.trunkBranch || envelope.trunk) + '")');
2909
+ console.log('Owner: account ' + entry.ownerAccountId + (entry.ownerAccountName ? ' (' + entry.ownerAccountName + ')' : '') +
2910
+ ' repo ' + (entry.repository || '?'));
2911
+ console.log('Phase: ' + String(entry.phase || 'unknown').toUpperCase());
2912
+ if (entry.summary) console.log(' ' + entry.summary);
2913
+
2914
+ console.log('');
2915
+ console.log(' git (remote):');
2916
+ if (git.comparable) {
2917
+ console.log(' ahead of trunk: ' + git.aheadBy + ' commit(s) [work not yet promoted]');
2918
+ console.log(' behind trunk: ' + git.behindBy + ' commit(s) [trunk this branch has not merged in]');
2919
+ console.log(' merge base: ' + short(git.mergeBaseSha));
2920
+ console.log(' branch HEAD: ' + short(git.branchHeadSha));
2921
+ console.log(' trunk HEAD: ' + short(git.trunkHeadSha));
2922
+ const deletions = git.deliberateDeletions || [];
2923
+ console.log(' deliberate component deletions on this branch: ' + deletions.length +
2924
+ (git.deletionEvidence === 'complete' ? '' : ' (evidence ' + git.deletionEvidence + ')'));
2925
+ deletions.slice(0, 20).forEach((p) => console.log(' D ' + p));
2926
+ } else {
2927
+ console.log(' UNAVAILABLE' + (git.message ? ': ' + git.message : ''));
2928
+ }
2929
+
2930
+ console.log('');
2931
+ console.log(' platform (what subscribers resolve right now):');
2932
+ console.log(' overridden ' + platform.overridden + ', branch-only ' + platform.added +
2933
+ ', tombstoned ' + platform.removed + ', drifted ' + platform.drifted);
2934
+ // Freshness is decided server-side (BranchPromotion.freshnessOf) because sparseness compares BOTH sides
2935
+ // and there are two independent ways to go stale. Re-deriving that here from separate booleans would be
2936
+ // a second copy of one rule.
2937
+ const FRESHNESS_LABELS = {
2938
+ 'current': ' [current]',
2939
+ 'stale-branch-moved': ' [STALE — does not match the branch HEAD]',
2940
+ 'stale-trunk-moved': ' [STALE — matches the branch HEAD, but trunk has moved since]'
2941
+ };
2942
+ console.log(' overlays computed from: ' + short(platform.syncedSha) +
2943
+ (FRESHNESS_LABELS[platform.freshness] || ' [unknown]'));
2944
+ (platform.subscribers || []).forEach((s) => {
2945
+ console.log(' subscriber: account ' + s.accountId + (s.accountName ? ' (' + s.accountName + ')' : '') +
2946
+ (s.domainName ? ' [host ' + s.domainName + ']' : ''));
2947
+ });
2948
+
2949
+ const blockers = entry.blockers || [];
2950
+ if (blockers.length) {
2951
+ console.log('');
2952
+ console.log(' BLOCKERS (' + blockers.length + '):');
2953
+ blockers.forEach((b) => {
2954
+ console.log(' * ' + b.code);
2955
+ console.log(' ' + b.message);
2956
+ console.log(' fix: ' + b.fix);
2957
+ });
2958
+ }
2959
+
2960
+ const steps = entry.nextSteps || [];
2961
+ if (steps.length) {
2962
+ console.log('');
2963
+ console.log(' NEXT, IN ORDER:');
2964
+ steps.forEach((s, i) => console.log(' ' + (i + 1) + '. ' + s));
2965
+ }
2966
+ console.log('');
2967
+ }
2968
+
2969
+ function short(sha) {
2970
+ return sha ? String(sha).slice(0, 8) : 'unknown';
2971
+ }
2972
+
2682
2973
  async function commitComponentsCommand(flags) {
2683
2974
  const cwd = process.cwd();
2684
2975
  ensureLocalState(cwd);
@@ -2945,13 +3236,18 @@ async function tokenCommand(flags) {
2945
3236
  // Embeddable resolves that account's branch variants rather than the owner's trunk.
2946
3237
  // --variant-branch explicitly probes a committed variant branch before any edge subscribes.
2947
3238
  const variantBranch = flags['variant-branch'];
3239
+ // Send the path so the server can also mint the EMBEDDABLE-SCOPED token key. `tokenKey` opens a
3240
+ // tokenized URL in a browser; `embedTokenKey` lets the `<script>` embed loader resolve the component
3241
+ // from persisted token context because the loader request sends no path.
3242
+ const requestedPath = flags.path || flags['embeddable-path'];
2948
3243
  const data = await loggedPost(api, cwd, '/cli/token', {
2949
3244
  token: session.token,
2950
3245
  accountId,
2951
3246
  asAccountId: flags['as-account'] || flags['as-account-id'],
2952
3247
  variantBranch,
2953
3248
  branchName,
2954
- dataMode
3249
+ dataMode,
3250
+ path: requestedPath
2955
3251
  }).then((r) => r.data);
2956
3252
 
2957
3253
  if (!data.success) {
@@ -2959,7 +3255,7 @@ async function tokenCommand(flags) {
2959
3255
  }
2960
3256
 
2961
3257
  const base = baseUrl.replace(/\/$/, '');
2962
- const embeddablePath = flags.path || flags['embeddable-path'];
3258
+ const embeddablePath = requestedPath;
2963
3259
  let embeddableUrl = embeddablePath
2964
3260
  ? (base + '/s/' + data.tokenKey + '/' + String(embeddablePath).replace(/^\/+/, ''))
2965
3261
  : null;
@@ -2980,6 +3276,26 @@ async function tokenCommand(flags) {
2980
3276
  embeddableUrl
2981
3277
  };
2982
3278
 
3279
+ // What the token will RESOLVE AS. `accountId` says which account it executes as; on a hierarchy the
3280
+ // decisive facts are which component branch applies, whose components those are, and which storage
3281
+ // namespace the page reads — none of which are inferable from the id. Present only when the server
3282
+ // could resolve it.
3283
+ if (data.resolution) {
3284
+ output.resolution = data.resolution;
3285
+ }
3286
+
3287
+ // Present only when the path resolved to an Embeddable. `embedTokenKey` is what a host site pastes
3288
+ // into the loader snippet; the render shape is echoed because it decides what that host receives.
3289
+ if (data.embedTokenKey) {
3290
+ output.embeddableId = data.embeddableId;
3291
+ output.embeddableName = data.embeddableName;
3292
+ output.injectionType = data.injectionType;
3293
+ output.renderMode = data.renderMode;
3294
+ output.headMode = data.headMode;
3295
+ output.embedTokenKey = data.embedTokenKey;
3296
+ output.embedSnippet = data.embedSnippet;
3297
+ }
3298
+
2983
3299
  printSessionResolutionWarning(sessionContext);
2984
3300
  console.log(JSON.stringify(output, null, 2));
2985
3301
  }
@@ -5634,6 +5950,23 @@ function autoUpdateIfNeeded(originalArgv, options = {}) {
5634
5950
  }
5635
5951
 
5636
5952
  function printComponentsHelp(subcommand) {
5953
+ if (subcommand === 'promotion' || subcommand === 'promote') {
5954
+ console.log('Usage: remits-cli components promotion [<branch>] [--branch NAME] [--json] [--no-fail]');
5955
+ console.log('');
5956
+ console.log('Reports where a variant branch sits in the promotion loop and what to do next. It performs');
5957
+ console.log('no git operation and writes nothing. Run it from either checkout — it resolves the branch');
5958
+ console.log('OWNER, and it reads the REMOTE, which is what the platform actually syncs from.');
5959
+ console.log('');
5960
+ console.log('Phases:');
5961
+ console.log(' converged branch == trunk. Nothing to promote.');
5962
+ console.log(' ready branch is ahead of trunk and current with it -> promote.');
5963
+ console.log(' diverged branch is behind trunk. Merge trunk IN before reading any plan.');
5964
+ console.log(' awaiting-merge-back trunk absorbed this branch and moved on. Steps 4-5 are owed, and');
5965
+ console.log(' until they run the subscribers resolve the PRE-promotion code.');
5966
+ console.log('');
5967
+ console.log('Exits non-zero when there are blockers, so it can gate a runbook. --no-fail just looks.');
5968
+ return;
5969
+ }
5637
5970
  if (subcommand === 'sync') {
5638
5971
  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
5972
  console.log('');
@@ -5679,7 +6012,7 @@ function printComponentsHelp(subcommand) {
5679
6012
  console.log('components commit does not support --dry-run.');
5680
6013
  return;
5681
6014
  }
5682
- console.log('Usage: remits-cli components <stage|status|clear|sync|commit|branches|branch>');
6015
+ console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
5683
6016
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5684
6017
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5685
6018
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
@@ -5698,7 +6031,7 @@ function printComponentsHelp(subcommand) {
5698
6031
  console.log(' remits-cli components branch <name> [--json] # overridden/added/removed + drift');
5699
6032
  console.log(' remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]');
5700
6033
  console.log(' remits-cli components branch <name> --subscribers [--json]');
5701
- console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]');
6034
+ console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge]');
5702
6035
  console.log(' remits-cli components branch <name> --unsubscribe <accountId>');
5703
6036
  console.log(' remits-cli components branch <name> --retire [--force]');
5704
6037
  }
@@ -5742,7 +6075,8 @@ function printTokenHelp() {
5742
6075
  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
6076
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
5744
6077
  console.log('');
5745
- console.log('Mints a branch-aware browser URL for embeddable verification.');
6078
+ console.log('Mints a branch-aware tokenKey and browser URL for embeddable verification.');
6079
+ console.log('When --path resolves to an Embeddable, output also includes embedTokenKey and embedSnippet for host-site embeds.');
5746
6080
  console.log('Inspect decodes a persisted Remits token and prints token metadata, recognized routing fields, safety/dataMode evidence, and full context.');
5747
6081
  }
5748
6082
 
@@ -5806,15 +6140,17 @@ async function main() {
5806
6140
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5807
6141
  console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
5808
6142
  console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
6143
+ console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
5809
6144
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
5810
6145
  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');
6146
+ console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
5812
6147
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
5813
6148
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
5814
6149
  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]');
5815
6150
  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
6151
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
5817
6152
  console.log('');
6153
+ console.log(' token output uses tokenKey for browser URLs and embedTokenKey/embedSnippet for host-site embeds.');
5818
6154
  console.log(' --as-account <ID> verifies AS a descendant subscriber account, so its relationship edge');
5819
6155
  console.log(' selects the component branch and the run resolves exactly what production will.');
5820
6156
  console.log(' --variant-branch <NAME> explicitly probes a committed variant branch (e.g. before any');
@@ -5880,6 +6216,11 @@ async function main() {
5880
6216
  return;
5881
6217
  }
5882
6218
 
6219
+ if (command === 'components' && (subcommand === 'promotion' || subcommand === 'promote')) {
6220
+ await promotionComponentsCommand(args);
6221
+ return;
6222
+ }
6223
+
5883
6224
  if (command === 'components' && (subcommand === 'branches' || subcommand === 'branch')) {
5884
6225
  await branchesComponentsCommand(args);
5885
6226
  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.104",
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
 
@@ -930,6 +929,25 @@ playwright-cli eval "() => document.querySelector('.total-amount').textContent"
930
929
 
931
930
  The `testMode` metadata confirms you're testing against staged changes, not production.
932
931
 
932
+ **Two different token keys come back, for two different jobs.** When `--path` resolves to an
933
+ Embeddable, the response carries an `embedTokenKey` and a paste-ready `embedSnippet` alongside the usual
934
+ `tokenKey` / `embeddableUrl`:
935
+
936
+ | Field | Use it for |
937
+ |---|---|
938
+ | `tokenKey` / `embeddableUrl` | Opening the page in a browser (Playwright, or clicking the link) |
939
+ | `embedTokenKey` / `embedSnippet` | The `<script>` embed loader — verifying the page as a HOST SITE embeds it |
940
+
941
+ They are not interchangeable. The loader's request carries **no path**, so it resolves the component
942
+ purely from the embeddable-scoped token key's persisted context. The browser `tokenKey` names the account
943
+ preview URL; `embedTokenKey` names the host-loader credential. The response also echoes `injectionType` /
944
+ `renderMode` / `headMode`, which decide what a host actually receives
945
+ (`guides/components/embeddable-components.md`).
946
+
947
+ **This works for a `new_` component that has never been synced.** The embed token key carries the
948
+ component NAME as well as its id, so a staged, id-less Embeddable is loader-addressable — you do not
949
+ have to sync it, or borrow another component's id, just to verify a host embed.
950
+
933
951
  **Option C — Use investigation tools** (for backend/data changes):
934
952
 
935
953
  For changes to Readers, Actions, or Rules that process data rather than display UI, verify by examining the data they produce:
@@ -1179,6 +1197,12 @@ TestMode still uses the committed DB source. Staging remains a dev/verification
1179
1197
  branch/user scope. This is the normal edit/test loop.
1180
1198
  - `remits-cli components status` shows which branch/variant world the checkout resolves, plus staged entries,
1181
1199
  staged fields, aliases, hashes, and TTLs. Use `--json` or `--verbose` for the full staged-entry payload.
1200
+ - **A full `stage` makes the `.meta.yml` AUTHORITATIVE.** Staging layers a payload over what is already
1201
+ staged, which is what lets `mcp_component_edit` write a single field without blanking the others. But a
1202
+ `remits-cli components stage` sends the whole sidecar, so a key you DELETE from a sidecar is removed from
1203
+ the staged entry rather than lingering — "restore the file and re-stage" restores the staged state, which
1204
+ is the only mental model that is safe to have. Content fields still layer (they come from separate files),
1205
+ so a partial stage is unaffected.
1182
1206
  - `remits-cli components clear` drops staged entries when you intentionally want to fall back to committed DB
1183
1207
  source. An empty staging scope is clean state, not a failure.
1184
1208
  - `remits-cli components sync` syncs the DB from the pushed git remote and then clears staged entries for the
@@ -1352,6 +1376,63 @@ remits-cli token --path embeddable/index/50 --as-account 101 # subscriber -> v
1352
1376
  remits-cli token --path embeddable/index/50 # owner -> trunk
1353
1377
  ```
1354
1378
 
1379
+ Each answer carries a **`resolution`** block for the account the token executes as — `role`,
1380
+ `componentBranch` and its owner, `resolvedDatabaseName`, `resolvedDomainName`, `scopeAccountId`, and a
1381
+ one-sentence `summary`. Read it before opening the URL: `accountId` alone does not say whether a branch
1382
+ overlay applies or which storage namespace the page will read, and those are exactly what
1383
+ `--as-account` is being used to change.
1384
+
1385
+ Separate branch fields, because these questions answer differently and can disagree:
1386
+
1387
+ | Field | Answers |
1388
+ |---|---|
1389
+ | `componentBranch` | what **this token** will resolve |
1390
+ | `componentBranchSource` | `probe` (an explicit `--variant-branch`), `subscription` (the account's edge), or `trunk` |
1391
+ | `subscribedComponentBranch` | what the **account graph** says, independent of this token |
1392
+ | `componentBranchAnchored` | whether **this resolution actually reached** that subscription |
1393
+
1394
+ A single `componentBranch` field would have reported `trunk` under `--variant-branch X`, for a token that
1395
+ resolves `X`.
1396
+
1397
+ **Subscribing to a branch and resolving through it are different facts.** An account subscribes on an
1398
+ edge; a *request* resolves through that edge only when something named the path (`--as-account`, the
1399
+ edge's own host, an explicit `--variant-branch`). An account with no `parentId` and several upward links
1400
+ resolves trunk by design — the resolver refuses to guess a path. So this is a normal, explainable state:
1401
+
1402
+ ```
1403
+ "componentBranch": null, // this token resolves TRUNK
1404
+ "componentBranchSource": "trunk",
1405
+ "subscribedComponentBranch": "forked", // ...but the account does subscribe
1406
+ "componentBranchAnchored": false // ...and nothing anchored this request to it
1407
+ ```
1408
+
1409
+ Reading `componentBranch` alone there tells you the account is on trunk, which is true — and leads you to
1410
+ conclude it has no branch, which is false. If several edges carry branches, none is picked for you:
1411
+ `subscribedComponentBranch` is `null` and `subscribedComponentBranches` lists them.
1412
+
1413
+ Under a probe, `componentOwnerAccountId` is the **nearest account on the token's resolution path that owns
1414
+ variant rows for X** — not an edge lookup, which answers `null` for the ordinary case of a variant
1415
+ committed before anything subscribes to it. `null` means no account on that path owns rows for X, and what
1416
+ that implies depends on the account, so read `summary`:
1417
+
1418
+ - **on trunk** — the page resolves what it would without the probe; the branch is not committed yet.
1419
+ - **resolving through a subscription** — the probe **suppresses** it. `variantBranch` outranks the
1420
+ subscription before it is consulted, so the page resolves **trunk**, not the overlays that account
1421
+ normally gets. Drop `--variant-branch` to see the subscription.
1422
+ - **subscribed but not anchored** — it was already resolving trunk before you probed. Dropping
1423
+ `--variant-branch` will *not* by itself show you the branch; name the path as well.
1424
+
1425
+ The page itself then states the same facts. Its hidden `remits-session-info` line — which appears in an
1426
+ accessibility snapshot with no script — carries `Account` (what it runs as), `Addressed Account` (what
1427
+ the URL/token named), `Data Lane`, `Component Branch`, `Variant Applied`, and `Scope Account`.
1428
+
1429
+ **`Component Branch` is the branch SELECTED; `Variant Applied` is what actually overlaid.** They are two
1430
+ fields because a branch can be selected and overlay nothing: `--variant-branch missing_branch` reports
1431
+ `Component Branch: missing_branch` while the page renders trunk components. So read `Variant Applied` —
1432
+ the `ComponentVariant` row id this page's component resolved through, or `none` — when the question is
1433
+ "did my variant apply?". That is a far sharper signal than inspecting the rendered markup for a style you
1434
+ expected.
1435
+
1355
1436
  If `components status` shows staged entries that you cannot safely clear, isolate verification in an
1356
1437
  unused staging namespace instead of deleting someone else's cache:
1357
1438
 
@@ -1383,10 +1464,41 @@ for you, and merging a branch promotes its **deletions** as hard deletes. Do not
1383
1464
  `remits-cli components sync --summary --timeout-ms 300000`; the platform may need longer than the default
1384
1465
  60 seconds to create rows, push rename/meta commits, and regenerate account metadata.
1385
1466
 
1467
+ ### Where am I in the promotion loop?
1468
+
1469
+ ```bash
1470
+ remits-cli components promotion # the branch you are standing on
1471
+ remits-cli components promotion --branch forked --json
1472
+ ```
1473
+
1474
+ **Run this before every promotion step and after every one.** It reports only — no git, no writes — and it
1475
+ reads the remote (which is what the platform syncs from, not your working tree). It works from either
1476
+ checkout, because it resolves the branch's owner itself. It exits non-zero while blockers remain, so treat
1477
+ that as "do not proceed".
1478
+
1479
+ | Phase | Meaning | Next |
1480
+ |---|---|---|
1481
+ | `converged` | branch == trunk | nothing to do; this is also what a **finished** promotion looks like |
1482
+ | `ready` | ahead of trunk, current with it | promote |
1483
+ | `diverged` | behind trunk | `git merge <trunk>` into the branch, re-sync, re-read the plan |
1484
+ | `awaiting-merge-back` | fully contained in trunk, trunk has moved on | **steps 4–5 are owed** — merge trunk back and re-sync |
1485
+
1486
+ Two things it tells you that nothing else does:
1487
+
1488
+ - **Which commit the stored counts describe, on BOTH axes.** An overlay is stored only while a file
1489
+ differs from trunk, so the stored set is a statement about a (branch, trunk) pair and either side moving
1490
+ makes it stale: `[STALE — does not match the branch HEAD]` (the branch was pushed since) or
1491
+ `[STALE — matches the branch HEAD, but trunk has moved since]`. The second is the easy one to miss —
1492
+ measured live, a branch at its own synced HEAD reported 2 stored overlays against a real plan of 21.
1493
+ - **That a promotion is unfinished.** `awaiting-merge-back` is what a promotion that *looked* successful
1494
+ leaves behind: trunk is correct and its tests pass, while the subscriber silently keeps resolving its
1495
+ pre-promotion overlays — including for any fix made afterwards. **A trunk sync succeeding is step 2 of 5,
1496
+ not completion.**
1497
+
1386
1498
  ### Subscribing, unsubscribing, retiring
1387
1499
 
1388
1500
  ```bash
1389
- remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]
1501
+ remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge]
1390
1502
  remits-cli components branch <name> --unsubscribe <accountId>
1391
1503
  remits-cli components branch <name> --retire [--force]
1392
1504
  ```
@@ -1406,6 +1518,11 @@ Branch administration commands act **as the account you are running from**, whic
1406
1518
  Without it the CLI picks the edge to the owner whose branch you are managing, then falls back to the
1407
1519
  account's primary edge — which may not be the one you intended. `--subscribers` prints
1408
1520
  `via primary|membership edge -> parent N` so you can confirm.
1521
+ - **Primary-edge subscriptions are structural.** If the selected edge is the account's `parentId` edge,
1522
+ subscribing it makes that account and descendants resolve the branch by default even though `parentId`
1523
+ still points at the same parent. The server refuses this write unless you pass
1524
+ `--confirm-primary-edge`; run `--dry-run` first and prefer a membership edge for fork/pilot
1525
+ subscriptions.
1409
1526
  - **Retiring is explicit.** Deleting the *git* branch does **not** remove its overlays; subscribers would
1410
1527
  keep resolving a branch that no longer exists. `--retire` refuses while subscribers remain unless you
1411
1528
  pass `--force`.
@@ -1438,7 +1555,13 @@ The analogous hazard is different, and you must still respect it:
1438
1555
  - **Deleting a file means "remove the component", not "stop overriding it."** To withdraw an override,
1439
1556
  make the file identical to trunk again — the sync then removes the variant row.
1440
1557
  - **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.
1558
+ branch was cut is absent from it. The sync now asks git which of those absences are real deletions
1559
+ (comparing against the merge base) and refuses to tombstone the rest, reporting them as
1560
+ `skipped: absent from the branch but never deleted on it`. Treat any such entry as "merge trunk in" —
1561
+ if an older sync already stored one of those absences as a tombstone, a non-dry-run sync prunes it and a
1562
+ dry run reports `wouldPrune: true`. The classification falls back to a conservative ratio guard when
1563
+ GitHub's compare is unavailable or its file list comes back truncated, which the sync output says
1564
+ explicitly.
1442
1565
  - **Use `components sync --dry-run` before risky variant syncs.** It reports `overridden`, `added`,
1443
1566
  `removed`, `unchanged`, `skipped`, and `errors` without writing rows, caching the sync SHA, or clearing
1444
1567
  staging. Existing overlay ids appear as `variantId`; an `unchanged` row with `pruned:true` means the
@@ -2687,7 +2810,7 @@ remits-cli components branches [--json] # branche
2687
2810
  remits-cli components branch <name> [--json] # one branch: overridden / added / removed, drift flags, subscribers
2688
2811
  remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]
2689
2812
  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
2813
+ remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
2691
2814
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
2692
2815
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
2693
2816
  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>]