@velaro/cli 1.4.54 → 1.4.55

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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * velaro outbound-activity - Unified Outbound Activity (Klaviyo-parity Gap 6)
3
+ *
4
+ * The one feed across ALL THREE of this product's independent email-send tracking systems:
5
+ * transactional (EmailDeliveryStatus, velaro-messaging), sequence/drip (EmailSend), and
6
+ * broadcast (BroadcastRecipient). Until this existed you had to check three separate screens
7
+ * to answer "what did we send this customer".
8
+ *
9
+ * velaro outbound-activity list Site-wide feed, newest first
10
+ * velaro outbound-activity contact <email> Everything sent to ONE address
11
+ *
12
+ * Site scoping is enforced SERVER-side from the authenticated session — there is deliberately
13
+ * no --site flag here.
14
+ */
15
+
16
+ import { request } from '../api.js';
17
+
18
+ const GRN = '\x1b[32m';
19
+ const RED = '\x1b[31m';
20
+ const YEL = '\x1b[33m';
21
+ const DIM = '\x1b[2m';
22
+ const BOLD = '\x1b[1m';
23
+ const RST = '\x1b[0m';
24
+
25
+ const SOURCE_LABEL = { Transactional: 'Transactional', Campaign: 'Sequence', Broadcast: 'Broadcast' };
26
+
27
+ function statusColor(status) {
28
+ const s = String(status || '').toLowerCase();
29
+ if (s === 'sent') return GRN;
30
+ if (s === 'failed') return RED;
31
+ if (s === 'pending') return YEL;
32
+ return DIM;
33
+ }
34
+
35
+ function buildQuery(argv) {
36
+ const qs = new URLSearchParams();
37
+ qs.set('take', String(argv.take ?? 25));
38
+ qs.set('skip', String(argv.skip ?? 0));
39
+ if (argv.source) qs.set('source', argv.source);
40
+ if (argv.status) qs.set('status', argv.status);
41
+ if (argv.search) qs.set('search', argv.search);
42
+ return qs.toString();
43
+ }
44
+
45
+ function render(data, heading) {
46
+ const items = data?.items ?? [];
47
+ const degraded = data?.degradedSources ?? [];
48
+
49
+ console.log(`\n${BOLD}${heading}${RST}\n`);
50
+
51
+ // A partial feed is never printed as if it were complete.
52
+ if (degraded.length) {
53
+ console.log(`${YEL} ! Incomplete: ${degraded.map(s => SOURCE_LABEL[s] ?? s).join(', ')} sends could not be loaded.${RST}\n`);
54
+ }
55
+
56
+ if (!items.length) {
57
+ console.log(` ${DIM}No outbound activity matched.${RST}\n`);
58
+ return;
59
+ }
60
+
61
+ for (const i of items) {
62
+ const when = i.sentAt ? new Date(i.sentAt).toISOString() : `${DIM}not sent${RST}`;
63
+ const src = SOURCE_LABEL[i.source] ?? i.source;
64
+ console.log(` ${statusColor(i.status)}${String(i.status).padEnd(13)}${RST} ${String(src).padEnd(14)} ${when}`);
65
+ console.log(` to ${i.toEmail ?? '(none)'} ${DIM}${i.subject ?? ''}${RST}`);
66
+ const meta = [];
67
+ if (i.campaignOrBroadcastName) meta.push(`campaign: ${i.campaignOrBroadcastName}`);
68
+ if (i.tag) meta.push(`tag: ${i.tag}`);
69
+ if (i.channel && i.channel !== 'email') meta.push(`channel: ${i.channel}`);
70
+ if (i.source !== 'Transactional') meta.push(`opens: ${i.openCount}, clicks: ${i.clickCount}`);
71
+ if (i.resendableId != null) meta.push('resendable');
72
+ if (meta.length) console.log(` ${DIM}${meta.join(' | ')}${RST}`);
73
+ console.log(` ${DIM}id ${i.id}${RST}`);
74
+ }
75
+
76
+ const approx = data?.totalCountIsApproximate ? 'about ' : '';
77
+ console.log(`\n ${DIM}${items.length} shown of ${approx}${data?.totalCount ?? items.length} total.${RST}\n`);
78
+ }
79
+
80
+ async function listHandler(argv) {
81
+ const data = await request('GET', `/Marketing/OutboundActivity?${buildQuery(argv)}`);
82
+ render(data, 'Outbound activity');
83
+ }
84
+
85
+ async function contactHandler(argv) {
86
+ const email = String(argv.email || '').trim();
87
+ // The scoping identifier is required, never optional — an omitted email must never silently
88
+ // widen this into a site-wide listing.
89
+ if (!email) {
90
+ console.error('A contact email address is required. Try: velaro outbound-activity contact bob@example.com');
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ const data = await request('GET', `/Marketing/Contacts/${encodeURIComponent(email)}/OutboundActivity?${buildQuery(argv)}`);
95
+ render(data, `Outbound activity for ${email}`);
96
+ }
97
+
98
+ const sharedOptions = yy => yy
99
+ .option('take', { describe: 'Page size (1-100)', type: 'number', default: 25 })
100
+ .option('skip', { describe: 'Rows to skip', type: 'number', default: 0 })
101
+ .option('source', { describe: 'Transactional | Campaign | Broadcast', type: 'string' })
102
+ .option('status', { describe: 'Sent | Pending | Failed | Unsubscribed | Skipped', type: 'string' });
103
+
104
+ export const outboundActivityCommand = {
105
+ command: 'outbound-activity <subcommand>',
106
+ describe: 'Unified outbound email activity across transactional, sequence and broadcast sends',
107
+ builder: y => y
108
+ .command({
109
+ command: 'list',
110
+ describe: 'Site-wide unified outbound feed, newest first',
111
+ builder: yy => sharedOptions(yy)
112
+ .option('search', { describe: 'Match recipient or subject', type: 'string' }),
113
+ handler: listHandler,
114
+ })
115
+ .command({
116
+ command: 'contact <email>',
117
+ describe: 'Every email sent to ONE address, across all three send systems',
118
+ builder: yy => sharedOptions(yy)
119
+ .positional('email', { describe: 'Exact recipient email address', type: 'string' }),
120
+ handler: contactHandler,
121
+ })
122
+ .demandCommand(1, 'Specify a subcommand. Try: velaro outbound-activity --help'),
123
+ handler: () => {},
124
+ };
@@ -1,7 +1,8 @@
1
1
  // velaro subscription — apply/diff SubscriptionPackage packages on a site (superadmin only)
2
2
  // Wraps SuperAdminController.ApplyPackageToSiteAsync / GetPackageDiffAsync in velaro-messaging,
3
3
  // the same reflection-copy service the SuperAdmin browser UI uses -- never hand-toggles flags.
4
- import { messagingPost, messagingGet } from '../api.js';
4
+ import { messagingPost, messagingGet, messagingPut } from '../api.js';
5
+ import { planChanges, parseFlagList, diffRows, verifyOnlyIntendedChanged } from '../site-flags.js';
5
6
 
6
7
  async function applyPackage(args) {
7
8
  if (!args.site || !args.package) {
@@ -27,6 +28,56 @@ async function packageDiff(args) {
27
28
  console.log(JSON.stringify(result, null, 2));
28
29
  }
29
30
 
31
+ // Flip Enable* entitlement flags on exactly ONE site. Reads, prints the diff, PUTs only the
32
+ // changed flags, re-reads, prints before/after and the rollback snapshot id. The server
33
+ // snapshots before every PUT (SuperAdminController.UpdateSubscriptionAsync).
34
+ async function setFlags(args) {
35
+ const site = args.site;
36
+ if (!Number.isInteger(site) || site <= 0) {
37
+ console.error('set-flags takes exactly one --site <id> per call.');
38
+ process.exit(1);
39
+ }
40
+ const enable = parseFlagList(args.enable);
41
+ const disable = parseFlagList(args.disable);
42
+ const before = await messagingGet(`SuperAdmin/subscription/${site}`);
43
+ const plan = planChanges(before, enable, disable);
44
+ if (!plan.ok) { plan.errors.forEach(e => console.error(`x ${e}`)); process.exit(1); }
45
+
46
+ console.log(`\nSite ${site}: intended changes`);
47
+ for (const c of plan.changes) console.log(` ${c.key}: ${c.before} -> ${c.after}${c.before === c.after ? ' (already set, no-op)' : ''}`);
48
+ const real = plan.changes.filter(c => c.before !== c.after);
49
+ if (real.length === 0) { console.log('\nNothing to change.'); return; }
50
+ if (args['dry-run']) { console.log('\nDry run: no changes made.'); return; }
51
+ if (!args.yes) { console.log('\nAdd --yes to apply (or --dry-run to preview only). No changes made.'); return; }
52
+
53
+ const body = {};
54
+ for (const c of real) body[c.flag] = c.after;
55
+ const result = await messagingPut(`SuperAdmin/subscription/${site}`, body);
56
+ if (result && result.success === false) { console.error(`x Server rejected the update: ${result.error}`); process.exit(1); }
57
+
58
+ const after = await messagingGet(`SuperAdmin/subscription/${site}`);
59
+ console.log('\nAfter (re-read from server):');
60
+ for (const d of diffRows(before, after)) console.log(` ${d.key}: ${JSON.stringify(d.before)} -> ${JSON.stringify(d.after)}`);
61
+
62
+ const history = await messagingGet(`SuperAdmin/subscription/${site}/history`);
63
+ const snap = Array.isArray(history) ? (history.find(h => /before-update/i.test(h.reason ?? h.Reason ?? '')) ?? history[0]) : null;
64
+ const snapId = snap ? (snap.id ?? snap.Id) : null;
65
+ console.log(snapId != null
66
+ ? `\nRollback snapshot id: ${snapId} (POST SuperAdmin/subscription/${site}/rollback/${snapId})`
67
+ : '\nWarning: no snapshot found in history; check SuperAdmin history before relying on rollback.');
68
+
69
+ if (args.verify) {
70
+ const v = verifyOnlyIntendedChanged(before, after, real);
71
+ if (!v.ok) {
72
+ console.error('\nx VERIFY FAILED');
73
+ v.missing.forEach(m => console.error(` not applied: ${m.key}`));
74
+ v.unexpected.forEach(u => console.error(` unexpected change: ${u.key} ${JSON.stringify(u.before)} -> ${JSON.stringify(u.after)}`));
75
+ process.exit(2);
76
+ }
77
+ console.log('\nVerify OK: only the requested flags changed.');
78
+ }
79
+ }
80
+
30
81
  export function subscriptionCommand(yargs) {
31
82
  return yargs
32
83
  .command('apply-package', 'Apply a SubscriptionPackage to a site (superadmin only) -- correct reflection-copy path, never hand-toggles flags', y => y
@@ -39,5 +90,13 @@ export function subscriptionCommand(yargs) {
39
90
  .command('package-diff', 'Show what would change if the site\'s current package were re-applied (read-only)', y => y
40
91
  .option('site', { type: 'number', demandOption: true, desc: 'Target site ID' })
41
92
  , a => packageDiff(a))
42
- .demandCommand(1, 'Specify a subcommand: apply-package | package-diff');
93
+ .command('set-flags', 'Flip Enable* entitlement flags on ONE site (superadmin only). Allowlisted flags only: never billing, package or limit fields', y => y
94
+ .option('site', { type: 'number', demandOption: true, desc: 'Exactly one target site ID' })
95
+ .option('enable', { type: 'string', desc: 'Comma-separated Enable* flags to turn ON, e.g. EnableAgentDashboard,EnableCustomDashboards' })
96
+ .option('disable', { type: 'string', desc: 'Comma-separated Enable* flags to turn OFF' })
97
+ .option('dry-run', { type: 'boolean', desc: 'Print the intended diff and stop' })
98
+ .option('yes', { type: 'boolean', desc: 'Apply. Without it (or with --dry-run) nothing is written' })
99
+ .option('verify', { type: 'boolean', desc: 'After applying, confirm ONLY the requested flags changed (exit 2 otherwise)' })
100
+ , a => setFlags(a))
101
+ .demandCommand(1, 'Specify a subcommand: apply-package | package-diff | set-flags');
43
102
  }
@@ -91,8 +91,26 @@ async function agentLookup(args) {
91
91
  console.log(JSON.stringify(result, null, 2));
92
92
  }
93
93
 
94
+ async function listDeployments(args) {
95
+ const result = await request('GET', `SupportTools/sites/${args.site}/deployments`);
96
+ if (args.json) {
97
+ console.log(JSON.stringify(result, null, 2));
98
+ return;
99
+ }
100
+ console.log(`Site ${result.siteId}: ${result.deployments.length} active widget deployment(s)`);
101
+ for (const d of result.deployments) {
102
+ console.log(` ${d.deploymentId || '—'} ${d.displayName || '(unnamed)'} team ${d.teamId} database ID ${d.id}`);
103
+ console.log(` surveys: pre-chat ${d.preChatSurveyId ?? '—'}, post-chat ${d.postChatSurveyId ?? '—'}, unavailable ${d.unavailableSurveyId ?? '—'}`);
104
+ }
105
+ console.log('Use the embed key as test-frame.html?siteId=<site>&deploymentId=<embed-key>.');
106
+ }
107
+
94
108
  export function supportCommand(yargs) {
95
109
  return yargs
110
+ .command('deployments', 'List a site’s widget embed keys and survey assignments (Velaro staff, read-only)', y => y
111
+ .option('site', { type: 'number', demandOption: true, desc: 'Target site ID' })
112
+ .option('json', { type: 'boolean', default: false, desc: 'Print the complete JSON response' })
113
+ , a => listDeployments(a))
96
114
  .command('impersonate', 'Start a 15-minute impersonation session for any site (SuperAdmin only, fully audit-logged to the customer\'s own activity log and a management email)', y => y
97
115
  .option('site', { type: 'number', demandOption: true, desc: 'Target site ID to impersonate' })
98
116
  , a => impersonate(a))
@@ -124,5 +142,5 @@ export function supportCommand(yargs) {
124
142
  .option('channel', { type: 'string', desc: 'Conversation source, e.g. Web, TwilioSms (default Web)' })
125
143
  .option('overrideIndexes', { type: 'string', desc: 'Comma-separated index names to override the config\'s own IndexName for this query' })
126
144
  , a => chatQuery(a))
127
- .demandCommand(1, 'Specify a subcommand: impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email');
145
+ .demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email');
128
146
  }
@@ -101,6 +101,54 @@ export const teamsCommand = {
101
101
  else { console.error(`Send failed: ${result?.message || 'unknown error'}`); process.exit(1); }
102
102
  }
103
103
  )
104
- .demandCommand(1, 'Specify a subcommand: notify, notify-card, create, list'),
104
+ .command(
105
+ 'push-settings',
106
+ 'Show or change which alerts the Velaro Bot pushes into Teams (per-alert switches + daily cap). Only flags you pass are changed.',
107
+ (y) =>
108
+ y
109
+ .option('new-chat', { type: 'boolean', describe: 'New chat alerts on/off' })
110
+ .option('missed', { type: 'boolean', describe: 'Missed chat alerts on/off' })
111
+ .option('ticket-created', { type: 'boolean', describe: 'Ticket created alerts on/off' })
112
+ .option('ticket-assigned', { type: 'boolean', describe: 'Ticket assigned alerts on/off' })
113
+ .option('daily-digest', { type: 'boolean', describe: 'Daily digest on/off' })
114
+ .option('daily-cap', { type: 'number', describe: 'Max alerts per day for this account' })
115
+ .option('reset-daily-cap', { type: 'boolean', describe: 'Clear the account cap and use the platform default' }),
116
+ async (argv) => {
117
+ const body = {};
118
+ if (argv.newChat !== undefined) body.pushNewChat = argv.newChat;
119
+ if (argv.missed !== undefined) body.pushMissed = argv.missed;
120
+ if (argv.ticketCreated !== undefined) body.pushTicketCreated = argv.ticketCreated;
121
+ if (argv.ticketAssigned !== undefined) body.pushTicketAssigned = argv.ticketAssigned;
122
+ if (argv.dailyDigest !== undefined) body.pushDailyDigest = argv.dailyDigest;
123
+ if (argv.dailyCap !== undefined) body.dailyPushCap = argv.dailyCap;
124
+ if (argv.resetDailyCap) body.resetDailyPushCap = true;
125
+ const result = Object.keys(body).length
126
+ ? await request('PUT', '/TeamsBot/PushSettings', body)
127
+ : await request('GET', '/TeamsBot/PushSettings');
128
+ console.log(JSON.stringify(result, null, 2));
129
+ }
130
+ )
131
+ .command(
132
+ 'channel-config',
133
+ 'Show or change Teams Channel (customer chat) settings: enabled, welcome message, team routing. Only flags you pass are changed.',
134
+ (y) =>
135
+ y
136
+ .option('enabled', { type: 'boolean', describe: 'Turn Teams Channel on/off' })
137
+ .option('welcome-message', { type: 'string', describe: 'Sent when a Teams user opens a new conversation. Use {{user.name}} for their name.' })
138
+ .option('team-id', { type: 'number', describe: 'Route new conversations to this team id' })
139
+ .option('clear-team-id', { type: 'boolean', describe: 'Clear the team override and use the site default team' }),
140
+ async (argv) => {
141
+ const body = {};
142
+ if (argv.enabled !== undefined) body.enabled = argv.enabled;
143
+ if (argv.welcomeMessage !== undefined) body.welcomeMessage = argv.welcomeMessage;
144
+ if (argv.teamId !== undefined) body.teamId = argv.teamId;
145
+ if (argv.clearTeamId) body.clearTeamId = true;
146
+ const result = Object.keys(body).length
147
+ ? await request('PUT', '/TeamsBot/ChannelConfig', body)
148
+ : await request('GET', '/TeamsBot/ChannelConfig');
149
+ console.log(JSON.stringify(result, null, 2));
150
+ }
151
+ )
152
+ .demandCommand(1, 'Specify a subcommand: notify, notify-card, create, list, push-settings, channel-config'),
105
153
  handler: () => {},
106
154
  };
package/lib/config.js CHANGED
@@ -41,10 +41,28 @@ function migrate(raw) {
41
41
  };
42
42
  }
43
43
 
44
+ // adminApiBase/messagingApiBase are never a per-session value -- always re-derived from the
45
+ // canonical ENVS table on every read, for every env key present on disk, regardless of what's
46
+ // actually stored there. This is the single point that heals every caller, not just ones that
47
+ // route through setEnvCredentials()/getCredentials() -- several command files (ops, logs,
48
+ // status, diagnostics, ai-models) read `cfg.envs[env]` directly. Without this, a URL that got
49
+ // persisted wrong once (e.g. `staging`'s adminApiBase set to `prod`'s canonical Admin API URL,
50
+ // the real incident that prompted this) would keep silently sending every "staging" call to
51
+ // production forever, since refreshVelaroToken() round-trips whatever it was given straight
52
+ // back out with no chance to correct it.
53
+ function normalizeApiBases(cfg) {
54
+ for (const env of Object.keys(cfg.envs || {})) {
55
+ if (!ENVS[env]) continue; // unknown env key, leave untouched
56
+ cfg.envs[env].adminApiBase = ENVS[env].adminApiBase;
57
+ cfg.envs[env].messagingApiBase = ENVS[env].messagingApiBase;
58
+ }
59
+ return cfg;
60
+ }
61
+
44
62
  export function readConfig() {
45
63
  try {
46
64
  const raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
47
- return migrate(raw);
65
+ return normalizeApiBases(migrate(raw));
48
66
  } catch {
49
67
  return { activeEnv: 'prod', envs: {} };
50
68
  }
@@ -75,7 +93,11 @@ export function setEnvCredentials(env, creds) {
75
93
  if (!ENVS[env]) throw new Error(`Unknown environment "${env}". Use: prod, staging`);
76
94
  const cfg = readConfig();
77
95
  cfg.envs = cfg.envs || {};
78
- cfg.envs[env] = { ...ENVS[env], ...creds };
96
+ // ENVS[env] spread last and always wins for adminApiBase/messagingApiBase -- belt-and-braces
97
+ // alongside normalizeApiBases() above, which is what actually heals a config that's already
98
+ // on disk. There is no longer a legitimate caller that needs to persist a custom
99
+ // adminApiBase (that's VELARO_ADMIN_API_STAGING/_PROD's job, which ENVS itself reads).
100
+ cfg.envs[env] = { ...creds, ...ENVS[env] };
79
101
  cfg.activeEnv = cfg.activeEnv || env;
80
102
  writeConfig(cfg);
81
103
  }
@@ -0,0 +1,94 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ // Regression coverage for the bug where a wrong adminApiBase/messagingApiBase persisted under
8
+ // an env key would self-perpetuate forever: refreshVelaroToken() round-trips the entire
9
+ // credentials object it was given (including adminApiBase) straight back out, and the old
10
+ // setEnvCredentials(env, creds) implementation did `{ ...ENVS[env], ...creds }` -- creds'
11
+ // (possibly stale/wrong) infra URLs always won over the canonical ENVS table on every write,
12
+ // so a corrupted value never healed. Real incident: a machine's ~/.velaro/config.json had
13
+ // `staging` pointed at prod's Admin API host, so every "staging" CLI call silently hit
14
+ // production (a real but misleading 404 for a route not yet deployed to prod, not the CLI
15
+ // bug it actually was).
16
+ //
17
+ // HOME is redirected to a scratch dir so this never touches the real ~/.velaro/config.json.
18
+ const scratchHome = mkdtempSync(join(tmpdir(), 'velaro-cli-config-test-'));
19
+ process.env.HOME = scratchHome;
20
+ process.env.USERPROFILE = scratchHome; // os.homedir() on Windows reads this first
21
+
22
+ const configModule = await import('./config.js');
23
+ const { setEnvCredentials, getEnvCredentials, readConfig, ENVS } = configModule;
24
+ const CONFIG_FILE = join(scratchHome, '.velaro', 'config.json');
25
+
26
+ test('setEnvCredentials always re-derives adminApiBase/messagingApiBase from ENVS, even if creds carries a wrong value', () => {
27
+ setEnvCredentials('staging', {
28
+ velaroToken: 'tok',
29
+ siteId: 1032,
30
+ userName: 'alex@velaro.com',
31
+ // Simulates a corrupted/carried-forward value -- must never survive the write.
32
+ adminApiBase: ENVS.prod.adminApiBase,
33
+ messagingApiBase: ENVS.prod.messagingApiBase,
34
+ });
35
+
36
+ const creds = getEnvCredentials('staging');
37
+ assert.equal(creds.adminApiBase, ENVS.staging.adminApiBase);
38
+ assert.equal(creds.messagingApiBase, ENVS.staging.messagingApiBase);
39
+ // Mutable per-session fields still come through untouched.
40
+ assert.equal(creds.velaroToken, 'tok');
41
+ assert.equal(creds.siteId, 1032);
42
+ });
43
+
44
+ test('setEnvCredentials keeps prod and staging URLs distinct from each other', () => {
45
+ setEnvCredentials('prod', { velaroToken: 'prod-tok' });
46
+ setEnvCredentials('staging', { velaroToken: 'staging-tok' });
47
+
48
+ assert.equal(getEnvCredentials('prod').adminApiBase, ENVS.prod.adminApiBase);
49
+ assert.equal(getEnvCredentials('staging').adminApiBase, ENVS.staging.adminApiBase);
50
+ assert.notEqual(getEnvCredentials('prod').adminApiBase, getEnvCredentials('staging').adminApiBase);
51
+ });
52
+
53
+ test('readConfig heals a config.json that is ALREADY poisoned on disk, not just fresh writes -- the actual real-world scenario', () => {
54
+ // Every command file that reads `cfg.envs[env]` directly (ops, logs, status, diagnostics,
55
+ // ai-models -- none of which route through setEnvCredentials) needs this, not just the
56
+ // getCredentials()/refresh path.
57
+ mkdirSync(join(scratchHome, '.velaro'), { recursive: true });
58
+ writeFileSync(CONFIG_FILE, JSON.stringify({
59
+ activeEnv: 'staging',
60
+ envs: {
61
+ staging: {
62
+ adminApiBase: ENVS.prod.adminApiBase, // poisoned: staging pointed at prod
63
+ messagingApiBase: ENVS.prod.messagingApiBase,
64
+ velaroToken: 'stale-tok',
65
+ siteId: 1032,
66
+ },
67
+ },
68
+ }));
69
+
70
+ const cfg = readConfig();
71
+ assert.equal(cfg.envs.staging.adminApiBase, ENVS.staging.adminApiBase);
72
+ assert.equal(cfg.envs.staging.messagingApiBase, ENVS.staging.messagingApiBase);
73
+ // Untouched, unrelated fields still read back correctly.
74
+ assert.equal(cfg.envs.staging.velaroToken, 'stale-tok');
75
+ assert.equal(getEnvCredentials('staging').adminApiBase, ENVS.staging.adminApiBase);
76
+ });
77
+
78
+ test('readConfig leaves an unrecognized env key untouched (forward-compat, does not throw)', () => {
79
+ writeFileSync(CONFIG_FILE, JSON.stringify({
80
+ activeEnv: 'staging',
81
+ envs: { futureEnv: { adminApiBase: 'https://something-not-yet-known.example.com', velaroToken: 'x' } },
82
+ }));
83
+
84
+ const cfg = readConfig();
85
+ assert.equal(cfg.envs.futureEnv.adminApiBase, 'https://something-not-yet-known.example.com');
86
+ });
87
+
88
+ test('setEnvCredentials rejects an unknown environment name', () => {
89
+ assert.throws(() => setEnvCredentials('nope', {}), /Unknown environment/);
90
+ });
91
+
92
+ test.after(() => {
93
+ rmSync(scratchHome, { recursive: true, force: true });
94
+ });