@velaro/cli 1.4.97 → 1.4.99

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/bin/velaro.js CHANGED
@@ -10,10 +10,12 @@ import { botCommand } from '../lib/commands/bot.js';
10
10
  import { indexCommand } from '../lib/commands/index.js';
11
11
  import { ingestCommand } from '../lib/commands/ingest.js';
12
12
  import { indexMoveCommand } from '../lib/commands/index-move.js';
13
+ import { safetyCommand } from '../lib/commands/safety.js';
13
14
  import { knowledgeAdvisorCommand } from '../lib/commands/knowledge-advisor.js';
14
15
  import { productCommand } from '../lib/commands/product.js';
15
16
  import { mcpKeyCommand } from '../lib/commands/mcp-key.js';
16
17
  import { opsCommand } from '../lib/commands/ops.js';
18
+ import { deployStateCommand } from '../lib/commands/deploy-state.js';
17
19
  import { checkCommand } from '../lib/commands/check.js';
18
20
  import { logsCommand } from '../lib/commands/logs.js';
19
21
  import { aiModelsCommand } from '../lib/commands/ai-models.js';
@@ -24,6 +26,7 @@ import { statusCommand } from '../lib/commands/status.js';
24
26
  import { activityCommand } from '../lib/commands/activity.js';
25
27
  import { ruleCommand } from '../lib/commands/rule.js';
26
28
  import { teamCommand } from '../lib/commands/team.js';
29
+ import { dynamicsCommand } from '../lib/commands/dynamics.js';
27
30
  import { workflowCommand } from '../lib/commands/workflow.js';
28
31
  import { aiConfigCommand } from '../lib/commands/ai-config.js';
29
32
  import { deploymentCommand } from '../lib/commands/deployment.js';
@@ -37,6 +40,7 @@ import { calendlyCommand } from '../lib/commands/calendly.js';
37
40
  import { copilotStudioCommand } from '../lib/commands/copilotstudio.js';
38
41
  import { acuityCommand } from '../lib/commands/acuity.js';
39
42
  import { avatarCommand } from '../lib/commands/avatar.js';
43
+ import { imageAnalysisCommand } from '../lib/commands/image-analysis.js';
40
44
  import { integrationCommand } from '../lib/commands/integration.js';
41
45
  import { appointmentsCommand } from '../lib/commands/appointments.js';
42
46
  import { bookingCommand } from '../lib/commands/booking.js';
@@ -109,6 +113,7 @@ import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
109
113
  import { recruitingCommand } from '../lib/commands/recruiting.js';
110
114
  import { surveyCommand } from '../lib/commands/survey.js';
111
115
  import { bundleCommand } from '../lib/commands/bundle.js';
116
+ import { chatWindowCommand } from '../lib/commands/chat-window.js';
112
117
  import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
113
118
  import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
114
119
  import { scraperCommand } from '../lib/commands/scraper.js';
@@ -141,10 +146,12 @@ await yargs(hideBin(process.argv))
141
146
  .command(indexCommand)
142
147
  .command(ingestCommand)
143
148
  .command(indexMoveCommand)
149
+ .command(safetyCommand)
144
150
  .command(knowledgeAdvisorCommand)
145
151
  .command(productCommand)
146
152
  .command(mcpKeyCommand)
147
153
  .command(opsCommand)
154
+ .command(deployStateCommand)
148
155
  .command(checkCommand)
149
156
  .command(logsCommand)
150
157
  .command(aiModelsCommand)
@@ -155,6 +162,7 @@ await yargs(hideBin(process.argv))
155
162
  .command(activityCommand)
156
163
  .command(ruleCommand)
157
164
  .command(teamCommand)
165
+ .command(dynamicsCommand)
158
166
  .command(workflowCommand)
159
167
  .command(aiConfigCommand)
160
168
  .command(deploymentCommand)
@@ -168,6 +176,7 @@ await yargs(hideBin(process.argv))
168
176
  .command(copilotStudioCommand)
169
177
  .command(acuityCommand)
170
178
  .command(avatarCommand)
179
+ .command(imageAnalysisCommand)
171
180
  .command(integrationCommand)
172
181
  .command(appointmentsCommand)
173
182
  .command(bookingCommand)
@@ -242,6 +251,7 @@ await yargs(hideBin(process.argv))
242
251
  .command(recruitingCommand)
243
252
  .command(surveyCommand)
244
253
  .command(bundleCommand)
254
+ .command(chatWindowCommand)
245
255
  .command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
246
256
  .command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
247
257
  .command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
@@ -3,22 +3,25 @@ import { runCommand } from '../run.js';
3
3
 
4
4
  const print = (data) => console.log(JSON.stringify(data, null, 2));
5
5
 
6
- // velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit> (REST: messaging /CannedLearning/*)
6
+ // velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit|teams> (REST: messaging /CannedLearning/*)
7
7
  export const cannedLearningCommand = {
8
8
  command: 'canned-learning <action> [id]',
9
9
  describe: 'AI canned message learning: list/generate suggestions, approve/reject/rollback (human review)',
10
10
  builder: (y) => y
11
- .positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit'] })
11
+ .positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit', 'teams'] })
12
12
  .positional('id', { type: 'number' })
13
+ .option('team-id', { type: 'number', describe: 'list/edits: only this team; generate: run for this team (team managers must pass one they manage)' })
13
14
  .option('state', { type: 'string', default: 'pending_review', describe: 'pending_review|approved|rejected|rolled_back|all' })
14
15
  .option('text', { type: 'string', describe: 'approve: edited replacement text (defaults to the AI proposal)' })
15
16
  .option('notes', { type: 'string', describe: 'rollback: reason' })
16
17
  .option('final-text', { type: 'string', describe: 'record-edit: the text the agent actually sent (redacted server-side)' }),
17
18
  handler: runCommand(async (a) => {
18
19
  const base = '/CannedLearning';
19
- if (a.action === 'list') return print(await messagingGet(`${base}/suggestions?state=${encodeURIComponent(a.state)}`));
20
- if (a.action === 'generate') return print(await messagingPost(`${base}/generate`, {}));
21
- if (a.action === 'edits') return print(await messagingGet(`${base}/edit-events`));
20
+ const teamQs = a.teamId != null ? `&teamId=${encodeURIComponent(a.teamId)}` : '';
21
+ if (a.action === 'list') return print(await messagingGet(`${base}/suggestions?state=${encodeURIComponent(a.state)}${teamQs}`));
22
+ if (a.action === 'generate') return print(await messagingPost(`${base}/generate`, { teamId: a.teamId ?? null }));
23
+ if (a.action === 'edits') return print(await messagingGet(`${base}/edit-events${a.teamId != null ? `?teamId=${encodeURIComponent(a.teamId)}` : ''}`));
24
+ if (a.action === 'teams') return print(await messagingGet(`${base}/teams`));
22
25
  if (a.action === 'record-edit') {
23
26
  if (a.id == null || !a.finalText) throw new Error('record-edit requires a canned response id and --final-text');
24
27
  return print(await messagingPost(`${base}/edit-events`, { cannedResponseId: a.id, finalText: a.finalText }));
@@ -4,14 +4,16 @@ import { runCommand } from '../run.js';
4
4
  const print = (data) => console.log(JSON.stringify(data, null, 2));
5
5
  const list = (v) => (v == null ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean));
6
6
 
7
- // velaro chat-topics <show|set|template|merge|rename|retire|group|group-suggestions|suggestions|accept|dismiss|
7
+ // velaro chat-topics <show|settings|set|template|merge|rename|retire|group|group-suggestions|suggestions|accept|dismiss|
8
8
  // reclassify-estimate|reclassify|analysis-estimate|analysis|proposal>
9
9
  // REST: messaging /ChatIntelligenceTopics/* (site comes from the CLI session, never an argument)
10
10
  export const chatIntelligenceTopicsCommand = {
11
11
  command: 'chat-topics <action>',
12
12
  describe: 'Chat Intelligence topics: edit the list, merge/rename/group (no AI cost), suggested topics, optional reclassify and analysis',
13
13
  builder: (y) => y
14
- .positional('action', { choices: ['show', 'set', 'template', 'merge', 'rename', 'retire', 'group', 'group-suggestions', 'suggestions', 'accept', 'dismiss', 'reclassify-estimate', 'reclassify', 'analysis-estimate', 'analysis', 'proposal'] })
14
+ .positional('action', { choices: ['show', 'settings', 'set', 'template', 'merge', 'rename', 'retire', 'group', 'group-suggestions', 'suggestions', 'accept', 'dismiss', 'reclassify-estimate', 'reclassify', 'analysis-estimate', 'analysis', 'proposal'] })
15
+ .option('multi-topic', { type: 'boolean', describe: 'settings: turn multi-topic tracking on (--multi-topic) or off (--no-multi-topic); omit to just show' })
16
+ .option('re-eval', { type: 'number', describe: 'settings: re-check after N new visitor messages (1-50)' })
15
17
  .option('topics', { type: 'string', describe: 'set: comma-separated topic list' })
16
18
  .option('industry', { type: 'string', describe: 'template: insurance|healthcare|retail|ecommerce|financial|saas|government|manufacturing' })
17
19
  .option('from', { type: 'string', describe: 'merge/rename/retire/group: comma-separated topics' })
@@ -29,6 +31,9 @@ export const chatIntelligenceTopicsCommand = {
29
31
  const ops = (op) => messagingPost(`${base}/mapping`, { ops: [{ op, from: list(a.from), to: a.to ?? null, group: a.group ?? null }] });
30
32
  switch (a.action) {
31
33
  case 'show': return print(await messagingGet(base));
34
+ case 'settings':
35
+ if (a.multiTopic == null && a.reEval == null) return print(await messagingGet(`${base}/settings`));
36
+ return print(await messagingPut(`${base}/settings`, { themeMultiTopicEnabled: a.multiTopic ?? null, themeReEvalEveryNMessages: a.reEval ?? null }));
32
37
  case 'set': return print(await messagingPut(base, { topics: list(a.topics) }));
33
38
  case 'template': return print(await messagingPost(`${base}/template`, { industry: a.industry }));
34
39
  case 'merge': case 'rename': case 'retire': case 'group': return print(await ops(a.action));
@@ -0,0 +1,47 @@
1
+ // velaro chat-window — per-page launcher label + opening greeting (ChatWindow/* on velaro-messaging).
2
+ // Site isolation is enforced server-side (ClientControllerBase SiteId), never passed by the client.
3
+ // To show different copy on different pages: create one chat window per page type, set its
4
+ // label/greeting here, then pick it with a Select Chat Window workflow node.
5
+ import { messagingGet, messagingPut } from '../api.js';
6
+ import { runCommand } from '../run.js';
7
+
8
+ export const chatWindowCommand = {
9
+ command: 'chat-window <subcommand>',
10
+ describe: 'Chat window launcher label and greeting — list, set-copy',
11
+ builder: (yargs) =>
12
+ yargs
13
+ .command({
14
+ command: 'list',
15
+ describe: 'List chat windows with launcher label and greeting',
16
+ handler: runCommand(async () => {
17
+ const rows = (await messagingGet('/ChatWindow/list')) ?? [];
18
+ if (!rows.length) { console.log('No chat windows found.'); return; }
19
+ for (const w of rows) {
20
+ console.log(`${w.id} ${w.name ?? '(unnamed)'} [${w.languageConfig?.language ?? '?'}]`);
21
+ console.log(` launcherText: ${w.styleConfig?.launcherText ?? '(none)'}`);
22
+ console.log(` greeting: ${w.languageConfig?.greeting ?? '(none)'}`);
23
+ }
24
+ console.log(`\n${rows.length} chat window(s)`);
25
+ }),
26
+ })
27
+ .command({
28
+ command: 'set-copy <id>',
29
+ describe: 'Set launcher label and/or greeting on one chat window (empty string clears)',
30
+ builder: (y) => y
31
+ .positional('id', { type: 'number', describe: 'Chat window ID' })
32
+ .option('launcher-text', { type: 'string', describe: 'Launcher label, max 60 chars' })
33
+ .option('greeting', { type: 'string', describe: 'Opening greeting, max 256 chars' }),
34
+ handler: runCommand(async (argv) => {
35
+ const body = {};
36
+ if (argv.launcherText !== undefined) body.launcherText = argv.launcherText;
37
+ if (argv.greeting !== undefined) body.greeting = argv.greeting;
38
+ if (!Object.keys(body).length) throw new Error('Pass --launcher-text and/or --greeting.');
39
+ const r = await messagingPut(`/ChatWindow/${argv.id}/launcher-copy`, body);
40
+ console.log(`Updated chat window ${r.id}.`);
41
+ console.log(` launcherText: ${r.launcherText ?? '(none)'}`);
42
+ console.log(` greeting: ${r.greeting ?? '(none)'}`);
43
+ }),
44
+ })
45
+ .demandCommand(1, 'Specify a subcommand: list, set-copy'),
46
+ handler: () => {},
47
+ };
@@ -26,6 +26,7 @@ const DB_SECRETS = {
26
26
  staging: { vault: 'velaro-eastus-stg-kv', secret: 'KnowledgeBaseConnectionString-Staging' },
27
27
  },
28
28
  messaging: {
29
+ staging: { vault: 'velaro-eastus-stg-kv', secret: 'MessagingConnectionString-Staging' },
29
30
  prod: { vault: 'velaro-eastus-prd-kv', secret: 'MessagingReadOnlyConnectionString' },
30
31
  },
31
32
  };
@@ -0,0 +1,26 @@
1
+ import { collectAll, render, PRODUCTS } from '../deploy-state.js';
2
+
3
+ // velaro deploy-state: what is on the staging environment, on staging slot 1 of production and in
4
+ // production for Admin and Messaging, what is merged to dev but not in production, which gate is
5
+ // waiting and the merge-freeze file. Uses only `gh` (logged in) and public /Version endpoints.
6
+ export const deployStateCommand = {
7
+ command: 'deploy-state',
8
+ describe: 'Where each push landed: staging environment, staging slot 1 of production, production (Velaro staff, needs gh)',
9
+ builder: (yargs) => yargs
10
+ .option('product', { type: 'string', choices: ['admin', 'messaging'], describe: 'Only one product (default both)' })
11
+ .option('json', { type: 'boolean', describe: 'Print the raw state as JSON' })
12
+ .option('compact', { type: 'boolean', describe: 'One line per place' }),
13
+ handler: async (argv) => {
14
+ try {
15
+ const products = argv.product
16
+ ? [Object.keys(PRODUCTS).find((n) => n.toLowerCase() === argv.product)]
17
+ : undefined;
18
+ const state = await collectAll({ products });
19
+ console.log(argv.json ? JSON.stringify(state, null, 2) : render(state, { compact: !!argv.compact }));
20
+ } catch (e) {
21
+ console.error(`deploy-state failed: ${e.message}`);
22
+ console.error('This command needs the GitHub CLI (gh) installed and logged in with access to velaro/velaro-admin and velaro/velaro-messaging. Run: gh auth login');
23
+ process.exit(1);
24
+ }
25
+ },
26
+ };
@@ -0,0 +1,44 @@
1
+ import { request } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // ── velaro dynamics team-overrides ────────────────────────────────────────────
5
+ // Per-team (group) override of Dynamics field mappings + activity type.
6
+ // Effective value = team override ?? the connection's own setting ("apply to all groups" default).
7
+ // Site is resolved server-side from the caller's credentials, never passed by the client.
8
+
9
+ const base = (config) => `/Dynamics/Configs/${config}/team-overrides`;
10
+
11
+ export const dynamicsCommand = {
12
+ command: 'dynamics <subcommand>',
13
+ describe: 'Dynamics 365 integration settings',
14
+ builder: yargs => yargs
15
+ .command({
16
+ command: 'team-overrides <action>',
17
+ describe: 'Per-team overrides of field mappings / activity type (get | set | reset)',
18
+ builder: y => y
19
+ .positional('action', { choices: ['get', 'set', 'reset'] })
20
+ .option('config', { type: 'number', demandOption: true, describe: 'Dynamics config id (velaro dynamics config list in the admin UI)' })
21
+ .option('team', { type: 'number', describe: 'Team id (required for set/reset; see: velaro team list)' })
22
+ .option('activity', { type: 'string', describe: 'Activity logical name (e.g. phonecall, task). Empty string = inherit the default.' })
23
+ .option('mappings', { type: 'string', describe: 'customFieldMappingsJson (JSON array). Empty string = inherit the default.' }),
24
+ handler: runCommand(async argv => {
25
+ if (argv.action === 'get') {
26
+ const data = await request('GET', base(argv.config));
27
+ console.log(JSON.stringify(data, null, 2));
28
+ return;
29
+ }
30
+ if (!argv.team) throw new Error('--team is required for set/reset');
31
+ if (argv.action === 'reset') {
32
+ console.log(JSON.stringify(await request('DELETE', `${base(argv.config)}/${argv.team}`), null, 2));
33
+ return;
34
+ }
35
+ const body = {};
36
+ if (argv.activity !== undefined) body.activityLogicalName = argv.activity;
37
+ if (argv.mappings !== undefined) body.customFieldMappingsJson = argv.mappings;
38
+ if (!Object.keys(body).length) throw new Error('Pass --activity and/or --mappings (omitted fields are left unchanged).');
39
+ console.log(JSON.stringify(await request('PUT', `${base(argv.config)}/${argv.team}`, body), null, 2));
40
+ }),
41
+ })
42
+ .demandCommand(1, 'Specify a subcommand: team-overrides'),
43
+ handler: () => {},
44
+ };
@@ -0,0 +1,27 @@
1
+ import { request } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // velaro image-analysis limits --site N
5
+ // Shows the Image Analysis (analyze_image) add-on cap and usage for any site (Velaro admin only).
6
+ // To change the cap: velaro support set-limit --site N --key MaxImageAnalysesPerMonth --value 500
7
+ export const imageAnalysisCommand = {
8
+ command: 'image-analysis <subcommand>',
9
+ describe: 'Image Analysis add-on limits and usage (Velaro admin only)',
10
+ builder: yargs => yargs
11
+ .command({
12
+ command: 'limits',
13
+ describe: 'Show monthly cap, usage this month and per-chat cap for a site',
14
+ builder: y => y.option('site', { type: 'number', demandOption: true, describe: 'Site ID' }),
15
+ handler: runCommand(async argv => {
16
+ const d = await request('GET', `/ImageAnalysisLimits/superadmin?siteId=${argv.site}`);
17
+ const ent = d.entitled === null ? 'unknown' : d.entitled ? 'yes' : 'no';
18
+ console.log(`\nSite ${argv.site}: Image Analysis`);
19
+ console.log(` Entitled: ${ent}`);
20
+ console.log(` Monthly cap: ${d.monthlyCap} (effective ${d.effectiveMonthlyCap}, hard cap, no automatic overage)`);
21
+ console.log(` Used this month: ${d.usedThisMonth === null ? 'unknown' : d.usedThisMonth}`);
22
+ console.log(` Per-chat cap: ${d.perConversationCap}`);
23
+ console.log(` Extra block size: ${d.blockSize}\n`);
24
+ }),
25
+ })
26
+ .demandCommand(1),
27
+ };
@@ -1,6 +1,7 @@
1
1
  import { describeRawMeta, fetchRetry429 } from '../log-search.js';
2
2
  import { get, del, post, getCredentials } from '../api.js';
3
3
  import { readConfig, ENVS } from '../config.js';
4
+ import { collectAll, render as renderDeployState } from '../deploy-state.js';
4
5
 
5
6
  // ── helpers ──────────────────────────────────────────────────────────────────
6
7
 
@@ -154,6 +155,17 @@ export const opsCommand = {
154
155
  }
155
156
  }
156
157
 
158
+ // ── 1b. Where each push landed: staging environment, staging slot 1 of production, production ──
159
+ // Same data as `velaro deploy-state` (needs gh). Never blocks the rest of ops if gh is missing.
160
+ console.log('\n\x1b[1m── Where each push landed ──────────────────────────────────\x1b[0m');
161
+ try {
162
+ const state = await collectAll();
163
+ console.log(renderDeployState(state, { compact: true }).split('\n').map(l => ' ' + l).join('\n'));
164
+ console.log(' Full detail: velaro deploy-state');
165
+ } catch (e) {
166
+ console.log(` ${WARN} Deploy state unavailable (${e.message}). Needs gh logged in; run velaro deploy-state.`);
167
+ }
168
+
157
169
  // ── 2. API health (admin + messaging, both envs) ──────────────────────────
158
170
  const prodMsgBase = cfg.envs?.prod?.messagingApiBase ?? ENVS.prod.messagingApiBase;
159
171
  const stagingMsgBase = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
@@ -108,9 +108,10 @@ export const qualityCommand = {
108
108
  export const chatIntelligenceThemesCommand = {
109
109
  command: 'chat-intelligence',
110
110
  describe: 'Chat Intelligence themes, optionally per website/team',
111
- builder: (y) => scope(y.option('from', { type: 'string' }).option('to', { type: 'string' }).option('group-by', { type: 'string', default: 'month' })),
111
+ builder: (y) => scope(y.option('from', { type: 'string' }).option('to', { type: 'string' }).option('group-by', { type: 'string', default: 'month' }).option('include-secondary', { type: 'boolean', default: false, describe: 'Also show secondary topics (needs the site multi-topic setting on)' })),
112
112
  handler: runCommand(async (a) => {
113
113
  const qs = new URLSearchParams({ groupBy: a.groupBy });
114
+ if (a.includeSecondary) qs.set('includeSecondary', 'true');
114
115
  if (a.from) qs.set('startDate', a.from);
115
116
  if (a.to) qs.set('endDate', a.to);
116
117
  if (a.deployment != null) qs.set('deploymentId', String(a.deployment));
@@ -0,0 +1,111 @@
1
+ // velaro safety — see safety events, acknowledge them, and manage alert settings. Wraps velaro-messaging
2
+ // SafetyEventsController. The site always comes from your login, never from an argument. Same routes the
3
+ // admin "Safety and alerts" page uses. Needs the EnableTroubleshootingKnowledge entitlement.
4
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
5
+ import { runCommand } from '../run.js';
6
+
7
+ const enc = encodeURIComponent;
8
+
9
+ const out = (argv, data, printer) => {
10
+ if (argv.json) console.log(JSON.stringify(data, null, 2));
11
+ else printer(data);
12
+ };
13
+
14
+ const list = (v) => (v === undefined ? undefined : [].concat(v).flatMap((x) => String(x).split(',')).map((s) => s.trim()).filter(Boolean));
15
+
16
+ function printEvents(r) {
17
+ const items = r.items ?? [];
18
+ if (!items.length) { console.log('No safety events.'); return; }
19
+ for (const e of items) {
20
+ console.log(`[${e.id}] ${e.createdUtc} ${e.type} (${e.channel}) ${e.status}`);
21
+ if (e.reason) console.log(` Why: ${e.reason}`);
22
+ if (e.excerpt) console.log(` "${e.excerpt}"`);
23
+ }
24
+ console.log(`\n${items.length} of ${r.total ?? items.length} shown.`);
25
+ }
26
+
27
+ function printSummary(s) {
28
+ console.log('Totals:');
29
+ for (const [k, v] of Object.entries(s.totals ?? {})) console.log(` ${k}: ${v}`);
30
+ console.log('\nBy day:');
31
+ for (const d of s.days ?? []) console.log(` ${d.date} ${JSON.stringify(d.counts)}`);
32
+ }
33
+
34
+ function printSettings(s) {
35
+ console.log(`Alerts: ${s.enabled ? 'on' : 'off'}`);
36
+ console.log(`Email recipients: ${(s.emailRecipients ?? []).join(', ') || '(none)'}`);
37
+ console.log(`SMS recipients: ${(s.smsRecipients ?? []).join(', ') || '(none)'}`);
38
+ console.log(`Alert right away: ${(s.immediateTypes ?? []).join(', ') || '(none)'}`);
39
+ console.log(`Summary email: ${s.digest}`);
40
+ console.log(`Count alert: ${s.threshold?.count ?? 0} events in ${s.threshold?.windowMinutes ?? 60} minutes`);
41
+ }
42
+
43
+ export const safetyCommand = {
44
+ command: 'safety',
45
+ describe: 'Safety events and alerts: list events, acknowledge, alert settings, test alert, ask Velaro for help',
46
+ builder: (yargs) => yargs
47
+ .command('events', 'List safety events', (y) => y
48
+ .option('from', { type: 'string', describe: 'ISO start' })
49
+ .option('to', { type: 'string', describe: 'ISO end' })
50
+ .option('type', { type: 'string' })
51
+ .option('status', { type: 'string', choices: ['open', 'acknowledged'] })
52
+ .option('take', { type: 'number', default: 25 })
53
+ .option('skip', { type: 'number', default: 0 })
54
+ .option('json', { type: 'boolean' }),
55
+ runCommand(async (a) => {
56
+ const q = new URLSearchParams();
57
+ for (const k of ['from', 'to', 'type', 'status']) if (a[k]) q.set(k, a[k]);
58
+ q.set('take', String(a.take));
59
+ q.set('skip', String(a.skip));
60
+ out(a, await messagingGet(`/SafetyEvents?${q}`), printEvents);
61
+ }))
62
+ .command('summary', 'Counts by type and by day', (y) => y
63
+ .option('days', { type: 'number', default: 7 })
64
+ .option('json', { type: 'boolean' }),
65
+ runCommand(async (a) => out(a, await messagingGet(`/SafetyEvents/summary?days=${a.days}`), printSummary)))
66
+ .command('ack', 'Mark an event as reviewed', (y) => y.option('id', { type: 'string', demandOption: true }),
67
+ runCommand(async (a) => {
68
+ await messagingPost(`/SafetyEvents/${enc(a.id)}/acknowledge`);
69
+ console.log(`Event ${a.id} marked as reviewed.`);
70
+ }))
71
+ .command('settings <action>', 'Read or change alert settings (get | set)', (y) => y
72
+ .positional('action', { choices: ['get', 'set'] })
73
+ .option('enabled', { type: 'boolean' })
74
+ .option('email', { type: 'string', describe: 'Email recipients, comma separated (replaces the list)' })
75
+ .option('sms', { type: 'string', describe: 'SMS recipients in +15551234567 form, comma separated (replaces the list)' })
76
+ .option('immediate', { type: 'string', describe: 'Event types that alert right away, comma separated (replaces the list)' })
77
+ .option('digest', { type: 'string', choices: ['off', 'daily', 'weekly'] })
78
+ .option('threshold-count', { type: 'number', describe: 'Alert at this many events inside the window. 0 turns it off.' })
79
+ .option('threshold-window', { type: 'number', describe: 'Window in minutes, 1 to 10080' })
80
+ .option('json', { type: 'boolean' }),
81
+ runCommand(async (a) => {
82
+ const cur = (await messagingGet('/SafetyEvents/settings')) ?? {};
83
+ if (a.action === 'get') return out(a, cur, printSettings);
84
+ const next = { ...cur };
85
+ if (a.enabled !== undefined) next.enabled = a.enabled;
86
+ if (a.email !== undefined) next.emailRecipients = list(a.email);
87
+ if (a.sms !== undefined) next.smsRecipients = list(a.sms);
88
+ if (a.immediate !== undefined) next.immediateTypes = list(a.immediate);
89
+ if (a.digest !== undefined) next.digest = a.digest;
90
+ if (a['threshold-count'] !== undefined || a['threshold-window'] !== undefined) {
91
+ next.threshold = {
92
+ count: a['threshold-count'] ?? cur.threshold?.count ?? 0,
93
+ windowMinutes: a['threshold-window'] ?? cur.threshold?.windowMinutes ?? 60,
94
+ };
95
+ }
96
+ out(a, await messagingPut('/SafetyEvents/settings', next), (s) => { console.log('Saved.\n'); printSettings(s); });
97
+ }))
98
+ .command('test', 'Send a test alert to the configured recipients', (y) => y,
99
+ runCommand(async () => {
100
+ await messagingPost('/SafetyEvents/settings/test');
101
+ console.log('Test alert sent. Check the inboxes and phones on the recipient list.');
102
+ }))
103
+ .command('need-help', 'Tell the Velaro team the bot is going off the rails', (y) => y
104
+ .option('message', { type: 'string', demandOption: true, describe: 'What is happening' }),
105
+ runCommand(async (a) => {
106
+ await messagingPost('/SafetyEvents/need-help', { message: a.message });
107
+ console.log('Sent. The Velaro team has been alerted.');
108
+ }))
109
+ .demandCommand(1, 'Specify a subcommand: events, summary, ack, settings, test, need-help'),
110
+ handler: () => {},
111
+ };
@@ -0,0 +1,408 @@
1
+ // What is on the staging environment, on staging slot 1 of production, and in production, for Admin
2
+ // and Messaging. Needs only `gh` (already logged in) and public /Version endpoints, no other
3
+ // credentials. Used by `velaro deploy-state`, scripts/deploy-state.mjs, `velaro ops` and the staff MCP
4
+ // tool deploy_state. Docs: docs/architecture/deploy-state.md.
5
+ //
6
+ // The three landing places (docs/architecture/alert-voice.md): every dev push lands on the staging
7
+ // environment (admin-staging, messaging-staging); Gate 1 lands the build on staging slot 1 of
8
+ // production (healthy, NOT taking customer traffic); Gate 2 swaps it live in production. All three
9
+ // are steps of ONE deploy workflow run, so each place is read from that run's job and step results.
10
+ import { execFile } from 'node:child_process';
11
+ import { promisify } from 'node:util';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ export const PRODUCTS = {
19
+ Admin: {
20
+ repo: 'velaro/velaro-admin',
21
+ stagingEnv: 'admin-staging',
22
+ versionUrls: {
23
+ staging: 'https://velaro-admin-staging.azurewebsites.net/Version',
24
+ slot1: 'https://api-admin-us-east-green.velaro.com/Version',
25
+ prod: 'https://api-admin-us-east.velaro.com/Version',
26
+ },
27
+ },
28
+ Messaging: {
29
+ repo: 'velaro/velaro-messaging',
30
+ stagingEnv: 'messaging-staging',
31
+ versionUrls: {
32
+ staging: 'https://velaro-messaging-api-staging.azurewebsites.net/Version',
33
+ slot1: 'https://api-messaging-us-east-green.velaro.com/Version',
34
+ prod: 'https://api-messaging-us-east.velaro.com/Version',
35
+ },
36
+ },
37
+ };
38
+
39
+ export const PLACE_NAMES = {
40
+ staging: 'the staging environment',
41
+ slot1: 'staging slot 1 of production',
42
+ prod: 'production',
43
+ };
44
+
45
+ // Job and step names of deploy-staging.yml, which differ a little between the two repos.
46
+ const JOB = {
47
+ staging: /^build-and-deploy-staging$|^Deploy to Staging Environment$/,
48
+ gate1Wait: /^\[Gate 1\] Await Approval$/,
49
+ gate1: /^\[Gate 1\] Deploy to Production Slot$/,
50
+ gate2: /^\[Gate 2\] Deploy to Production Environment$/,
51
+ unitGate: /^Gate: Unit Tests must be green/,
52
+ };
53
+ const STEP = {
54
+ gate1Landed: /^Warm up and verify green slot/,
55
+ gate2Landed: /^Swap green slot to production/,
56
+ };
57
+
58
+ // ── time ─────────────────────────────────────────────────────────────────────────────────────────
59
+ export function pt(iso) {
60
+ if (!iso) return 'unknown';
61
+ const d = new Date(iso);
62
+ if (Number.isNaN(d.getTime())) return 'unknown';
63
+ const f = new Intl.DateTimeFormat('en-US', {
64
+ timeZone: 'America/Los_Angeles', month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit',
65
+ }).format(d);
66
+ return `${f.replace(',', '')} PT`;
67
+ }
68
+
69
+ // ── gh access (injectable for tests) ───────────────────────────────────────────────────────────────
70
+ export async function ghApi(apiPath) {
71
+ const { stdout } = await execFileAsync('gh', ['api', apiPath], { maxBuffer: 64 * 1024 * 1024, timeout: 60000 });
72
+ return JSON.parse(stdout);
73
+ }
74
+
75
+ async function pool(items, size, fn) {
76
+ const out = new Array(items.length);
77
+ let i = 0;
78
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
79
+ while (i < items.length) {
80
+ const n = i++;
81
+ out[n] = await fn(items[n], n);
82
+ }
83
+ }));
84
+ return out;
85
+ }
86
+
87
+ // ── job classification ──────────────────────────────────────────────────────────────────────────
88
+ export function jobState(job) {
89
+ if (!job) return null;
90
+ if (job.status !== 'completed') {
91
+ if (job.status === 'waiting') return 'waiting';
92
+ if (job.status === 'in_progress') return 'running';
93
+ return 'queued';
94
+ }
95
+ return job.conclusion || 'unknown';
96
+ }
97
+
98
+ const find = (jobs, re) => jobs.find((j) => re.test(j.name));
99
+ const stepOk = (job, re) => !!job && (job.steps || []).some((s) => re.test(s.name) && s.conclusion === 'success');
100
+
101
+ // What one workflow run did at each of the three places.
102
+ export function classifyRun(jobs) {
103
+ const stg = find(jobs, JOB.staging);
104
+ const g1 = find(jobs, JOB.gate1);
105
+ const g1Wait = find(jobs, JOB.gate1Wait);
106
+ const g2 = find(jobs, JOB.gate2);
107
+ const unitGate = find(jobs, JOB.unitGate);
108
+ return {
109
+ staging: { state: jobState(stg), landed: jobState(stg) === 'success', at: stg?.completed_at || null },
110
+ slot1: {
111
+ state: jobState(g1),
112
+ // a run whose slot deploy passed its health warm-up IS on the slot even if a later step failed
113
+ landed: jobState(g1) === 'success' || stepOk(g1, STEP.gate1Landed),
114
+ at: g1?.completed_at || null,
115
+ waiting: jobState(g1) === 'waiting' || jobState(g1Wait) === 'waiting',
116
+ blockedByUnitTests: jobState(unitGate) === 'failure' || jobState(unitGate) === 'cancelled',
117
+ unitGateState: jobState(unitGate),
118
+ },
119
+ prod: {
120
+ state: jobState(g2),
121
+ // the swap step landing means customers are on it, even when a later verify step failed
122
+ landed: jobState(g2) === 'success' || stepOk(g2, STEP.gate2Landed),
123
+ at: g2?.completed_at || null,
124
+ waiting: jobState(g2) === 'waiting',
125
+ },
126
+ };
127
+ }
128
+
129
+ // ── PR resolution ────────────────────────────────────────────────────────────────────────────────
130
+ const PR_FROM_MESSAGE = [/^Merge pull request #(\d+)\b/, /\(#(\d+)\)\s*$/];
131
+ export function prNumberFromSubject(subject) {
132
+ for (const re of PR_FROM_MESSAGE) {
133
+ const m = (subject || '').match(re);
134
+ if (m) return Number(m[1]);
135
+ }
136
+ return null;
137
+ }
138
+
139
+ export function makePrResolver(repo, api) {
140
+ const cache = new Map();
141
+ return async function resolvePr(n) {
142
+ if (!cache.has(n)) {
143
+ cache.set(n, api(`repos/${repo}/pulls/${n}`).then(
144
+ (p) => ({ number: n, title: p.title, mergedAt: p.merged_at }),
145
+ () => ({ number: n, title: `PR ${n}`, mergedAt: null }),
146
+ ));
147
+ }
148
+ return cache.get(n);
149
+ };
150
+ }
151
+
152
+ // What `git log --first-parent head` would show among the given commits, newest first. A PR merged
153
+ // with a merge commit brings in its whole branch of commits; only the merge commit itself is a
154
+ // change to dev, so follow first parents from the head and ignore the branch commits.
155
+ export function firstParentChain(commits, headSha) {
156
+ const bySha = new Map(commits.map((c) => [c.sha, c]));
157
+ const chain = [];
158
+ let cur = bySha.get(headSha) || commits[commits.length - 1];
159
+ const guard = new Set();
160
+ while (cur && !guard.has(cur.sha)) {
161
+ guard.add(cur.sha);
162
+ chain.push(cur);
163
+ cur = bySha.get(cur.parents?.[0]?.sha);
164
+ }
165
+ return chain;
166
+ }
167
+
168
+ // PRs (and direct pushes) in base..head on the dev line, newest first. base null => just head.
169
+ export async function changesBetween(repo, base, head, api, resolvePr) {
170
+ if (!head) return null;
171
+ if (base === head) return [];
172
+ let commits;
173
+ try {
174
+ if (base) {
175
+ // compare lists oldest first, 100 per page, so read the LAST pages (the newest commits) first;
176
+ // up to 6 pages, i.e. the newest 600 commits, which is where the head is.
177
+ const first = await api(`repos/${repo}/compare/${base}...${head}?per_page=100&page=1`);
178
+ const total = first.total_commits ?? (first.commits || []).length;
179
+ const lastPage = Math.max(Math.ceil(total / 100), 1);
180
+ const wanted = Array.from({ length: Math.min(lastPage, 6) }, (_, k) => lastPage - k);
181
+ const all = [];
182
+ const pagesData = await Promise.all(wanted.map((pg) => pg === 1 ? Promise.resolve(first.commits || [])
183
+ : api(`repos/${repo}/compare/${base}...${head}?per_page=100&page=${pg}`).then((r) => r.commits || [])));
184
+ pagesData.reverse().forEach((r) => all.push(...r));
185
+ if (!all.some((c) => c.sha === head)) return null; // head not in what was read: say unknown, never a quiet partial list
186
+ commits = firstParentChain(all, head);
187
+ } else {
188
+ const c = await api(`repos/${repo}/commits/${head}`);
189
+ commits = [c];
190
+ }
191
+ } catch {
192
+ return null;
193
+ }
194
+ const seen = new Set();
195
+ const items = [];
196
+ for (const c of commits) {
197
+ const subject = (c.commit?.message || '').split('\n')[0];
198
+ const n = prNumberFromSubject(subject);
199
+ if (n) {
200
+ if (seen.has(n)) continue;
201
+ seen.add(n);
202
+ items.push({ pr: n, subject, date: c.commit?.committer?.date || c.commit?.author?.date });
203
+ } else if (!/^Merge branch /i.test(subject)) {
204
+ items.push({ pr: null, subject, date: c.commit?.committer?.date || c.commit?.author?.date });
205
+ }
206
+ }
207
+ return pool(items, 6, async (it) => {
208
+ if (!it.pr) return { number: null, title: it.subject, mergedAt: it.date };
209
+ const p = await resolvePr(it.pr);
210
+ return { number: p.number, title: p.title, mergedAt: p.mergedAt || it.date };
211
+ });
212
+ }
213
+
214
+ // ── collection ───────────────────────────────────────────────────────────────────────────────────────
215
+ export function readFreeze(file = path.join(os.homedir(), 'DEV-MERGE-FREEZE.txt')) {
216
+ try {
217
+ return fs.readFileSync(file, 'utf8').trim() || null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ async function probeVersion(url, fetchFn) {
224
+ try {
225
+ const res = await fetchFn(url, { signal: AbortSignal.timeout(6000) });
226
+ if (!res.ok) return { ok: false, status: res.status };
227
+ const j = await res.json();
228
+ return { ok: true, version: j.version, deployedAt: j.deployedAt };
229
+ } catch (e) {
230
+ return { ok: false, err: e.message };
231
+ }
232
+ }
233
+
234
+ export async function collectProduct(name, { api = ghApi, fetchFn = fetch, maxRuns = 60 } = {}) {
235
+ const { repo, versionUrls } = PRODUCTS[name];
236
+ const resolvePr = makePrResolver(repo, api);
237
+
238
+ // Newest first. Three pages so a production deploy that is a few days old is still found.
239
+ const runs = [];
240
+ for (let page = 1; page <= 3; page++) {
241
+ const r = await api(`repos/${repo}/actions/workflows/deploy-staging.yml/runs?branch=dev&per_page=100&page=${page}`);
242
+ runs.push(...(r.workflow_runs || []));
243
+ if ((r.workflow_runs || []).length < 100) break;
244
+ }
245
+ const dev = await api(`repos/${repo}/commits/dev`);
246
+ const unit = await api(`repos/${repo}/actions/workflows/ci-tests.yml/runs?branch=dev&per_page=10`)
247
+ .then((r) => r.workflow_runs || [], () => []);
248
+
249
+ // Walk newest to oldest, loading each run's jobs, until all three places are known.
250
+ const found = { staging: null, slot1: null, prod: null };
251
+ const pending = [];
252
+ const BATCH = 6;
253
+ const blockedRuns = [];
254
+ const activeRuns = [];
255
+ for (let i = 0; i < runs.length && Object.values(found).some((v) => !v); i += BATCH) {
256
+ // Past the newest maxRuns runs only production is still being looked for, and a cancelled run
257
+ // never reaches Gate 2, so skip those to keep the GitHub calls down.
258
+ const batch = runs.slice(i, i + BATCH).filter((r) => i < maxRuns || r.conclusion !== 'cancelled');
259
+ const jobSets = await pool(batch, BATCH, (run) =>
260
+ api(`repos/${repo}/actions/runs/${run.id}/jobs?per_page=100`).then((r) => r.jobs || [], () => null));
261
+ batch.forEach((run, k) => {
262
+ const jobs = jobSets[k];
263
+ if (!jobs) return;
264
+ const c = classifyRun(jobs);
265
+ const base = { runId: run.id, sha: run.head_sha, event: run.event, createdAt: run.created_at, runStatus: run.status, runConclusion: run.conclusion };
266
+ for (const place of ['staging', 'slot1', 'prod']) {
267
+ if (!found[place] && c[place].landed) found[place] = { ...base, at: c[place].at, jobState: c[place].state };
268
+ }
269
+ if (run.status !== 'completed') {
270
+ activeRuns.push({ ...base, staging: c.staging.state, slot1: c.slot1.state, prod: c.prod.state });
271
+ if (c.prod.waiting) pending.push({ gate: 'Gate 2 waiting for approval', ...base });
272
+ else if (c.slot1.waiting) pending.push({ gate: 'Gate 1 waiting for approval', ...base });
273
+ }
274
+ if (c.slot1.blockedByUnitTests && run.conclusion !== 'cancelled' && blockedRuns.length < 1) {
275
+ blockedRuns.push({ gate: 'Gate 1 blocked, the unit tests are not green for this build', ...base });
276
+ }
277
+ });
278
+ }
279
+ // A build that never reached slot 1 because its unit tests are red only matters while it is newer
280
+ // than what already landed on slot 1 or in production.
281
+ for (const b of blockedRuns) {
282
+ const newest = [found.slot1?.at, found.prod?.at].filter(Boolean).sort().pop();
283
+ if (!newest || new Date(b.createdAt) > new Date(newest)) pending.push(b);
284
+ }
285
+
286
+ // After a Gate 2 swap, slot 1 holds the PREVIOUS production build, not the one that went live.
287
+ let slot1Note = null;
288
+ if (found.slot1 && found.prod && found.prod.runId === found.slot1.runId) {
289
+ slot1Note = 'swapped into production by Gate 2; slot 1 now holds the previous production build';
290
+ } else if (found.slot1 && found.prod && new Date(found.prod.at) > new Date(found.slot1.at)) {
291
+ slot1Note = 'a later Gate 2 swap put another build in production; slot 1 holds the build that was live before that swap';
292
+ }
293
+
294
+ const prodSha = found.prod?.sha || null;
295
+ const slotSha = slot1Note ? null : found.slot1?.sha || null;
296
+ const stgSha = found.staging?.sha || null;
297
+ const devSha = dev.sha;
298
+ // Each place lists the PRs it adds over the next more-live place.
299
+ const [prodTip, slotAdds, stgAdds, devAdds, notInProd] = await Promise.all([
300
+ changesBetween(repo, null, prodSha, api, resolvePr),
301
+ changesBetween(repo, prodSha, slotSha, api, resolvePr),
302
+ changesBetween(repo, slotSha || prodSha, stgSha, api, resolvePr),
303
+ changesBetween(repo, stgSha || slotSha || prodSha, devSha, api, resolvePr),
304
+ changesBetween(repo, prodSha, devSha, api, resolvePr),
305
+ ]);
306
+
307
+ const [vStg, vSlot, vProd] = await Promise.all(
308
+ ['staging', 'slot1', 'prod'].map((k) => probeVersion(versionUrls[k], fetchFn)));
309
+
310
+ return {
311
+ product: name,
312
+ repo,
313
+ devTip: { sha: devSha, subject: (dev.commit?.message || '').split('\n')[0], date: dev.commit?.committer?.date },
314
+ places: {
315
+ staging: { ...found.staging, adds: stgAdds, version: vStg },
316
+ slot1: { ...found.slot1, adds: slotAdds, note: slot1Note, version: vSlot },
317
+ prod: { ...found.prod, adds: prodTip, version: vProd },
318
+ },
319
+ devAdds,
320
+ notInProduction: notInProd,
321
+ pendingGates: pending,
322
+ activeRuns,
323
+ unitTestsOnDev: unit
324
+ .filter((r) => r.status !== 'completed')
325
+ .map((r) => ({ runId: r.id, status: r.status, sha: r.head_sha, createdAt: r.created_at })),
326
+ latestUnitTests: unit.find((r) => r.status === 'completed')
327
+ ? { conclusion: unit.find((r) => r.status === 'completed').conclusion, createdAt: unit.find((r) => r.status === 'completed').created_at }
328
+ : null,
329
+ };
330
+ }
331
+
332
+ export async function collectAll(opts = {}) {
333
+ const names = opts.products || Object.keys(PRODUCTS);
334
+ const states = await Promise.all(names.map((n) => collectProduct(n, opts)));
335
+ return { generatedAt: new Date().toISOString(), freeze: readFreeze(opts.freezeFile), products: states };
336
+ }
337
+
338
+ // ── rendering ───────────────────────────────────────────────────────────────────────────────────────
339
+ function titles(adds, max = 3) {
340
+ if (adds == null) return 'unknown (could not compare)';
341
+ if (!adds.length) return 'nothing newer than the place below';
342
+ const shown = adds.slice(0, max).map((a) => (a.number ? `${a.title} (PR ${a.number})` : a.title));
343
+ return shown.join('; ') + (adds.length > max ? `; and ${adds.length - max} more` : '');
344
+ }
345
+
346
+ function runLine(p) {
347
+ if (!p || !p.runId) return 'no landed deploy found in the last 60 dev runs';
348
+ const how = p.runConclusion || p.runStatus;
349
+ return `deploy run ${p.runId}, this place ${p.jobState}, whole run ${how}, landed ${pt(p.at)}`;
350
+ }
351
+
352
+ function verLine(v) {
353
+ if (!v) return '';
354
+ if (v.ok) return `app reports version ${v.version}, deployed ${pt(v.deployedAt)}`;
355
+ return `app version unreachable (${v.err || 'HTTP ' + v.status})`;
356
+ }
357
+
358
+ export function render(state, { compact = false } = {}) {
359
+ const out = [];
360
+ out.push(`Deploy state at ${pt(state.generatedAt)}`);
361
+ out.push(state.freeze ? `Merge freeze is ON: ${state.freeze}` : 'Merge freeze: none (no ~/DEV-MERGE-FREEZE.txt)');
362
+ for (const p of state.products) {
363
+ const env = PRODUCTS[p.product].stagingEnv;
364
+ out.push('');
365
+ out.push('='.repeat(78));
366
+ out.push(`${p.product.toUpperCase()} dev tip: ${p.devTip.subject}, ${pt(p.devTip.date)}`);
367
+ out.push('='.repeat(78));
368
+ const rows = [
369
+ [`${PLACE_NAMES.staging}, ${env}`, p.places.staging],
370
+ [PLACE_NAMES.slot1, p.places.slot1],
371
+ [PLACE_NAMES.prod, p.places.prod],
372
+ ];
373
+ for (const [label, pl] of rows) {
374
+ out.push(`${label.toUpperCase()}`);
375
+ if (!pl.runId) { out.push(' ' + runLine(pl)); continue; }
376
+ if (pl.note) out.push(` Note: ${pl.note}`);
377
+ if (pl.note) { /* slot 1 holds the previous production build, nothing new to list */ }
378
+ else if (!compact) out.push(` ${label === PLACE_NAMES.prod ? 'Newest change' : 'Adds'}: ${titles(pl.adds, label === PLACE_NAMES.prod ? 1 : 3)}`);
379
+ else out.push(` Tip: ${titles(pl.adds, 1)}`);
380
+ const first = pl.adds && pl.adds[0];
381
+ if (first && !compact) out.push(` Merged: ${pt(first.mergedAt)}`);
382
+ out.push(` ${runLine(pl)}`);
383
+ if (!compact && pl.version) out.push(` ${verLine(pl.version)}`);
384
+ }
385
+ if (p.devAdds && p.devAdds.length) {
386
+ out.push(`MERGED TO DEV, NOT YET ON THE STAGING ENVIRONMENT: ${titles(p.devAdds, 5)}`);
387
+ }
388
+ out.push('');
389
+ const nip = p.notInProduction;
390
+ if (nip == null) out.push('Merged to dev but not yet in production: unknown (no production deploy found)');
391
+ else if (!nip.length) out.push('Merged to dev but not yet in production: nothing, production has everything on dev');
392
+ else {
393
+ out.push(`Merged to dev but not yet in production: ${nip.length}`);
394
+ for (const a of nip.slice(0, compact ? 8 : 40)) out.push(` ${pt(a.mergedAt).padEnd(16)} ${a.title}${a.number ? ` (PR ${a.number})` : ''}`);
395
+ if (nip.length > (compact ? 8 : 40)) out.push(` and ${nip.length - (compact ? 8 : 40)} more`);
396
+ }
397
+ if (p.pendingGates.length) {
398
+ for (const g of p.pendingGates) out.push(`PENDING: ${g.gate}, deploy run ${g.runId}, started ${pt(g.createdAt)}`);
399
+ } else out.push('Pending gates: none waiting');
400
+ for (const a of p.activeRuns || []) {
401
+ out.push(`DEPLOY RUN IN PROGRESS: run ${a.runId}, started ${pt(a.createdAt)}, staging environment ${a.staging || 'not started'}, staging slot 1 ${a.slot1 || 'not started'}, production ${a.prod || 'not started'}`);
402
+ }
403
+ if (p.unitTestsOnDev.length) {
404
+ for (const u of p.unitTestsOnDev) out.push(`UNIT TESTS ${u.status.toUpperCase()} on dev: run ${u.runId}, started ${pt(u.createdAt)}. Do not merge to dev.`);
405
+ } else out.push(`Unit Tests on dev: none in progress${p.latestUnitTests ? `, last finished standalone run ${p.latestUnitTests.conclusion} at ${pt(p.latestUnitTests.createdAt)}` : ''}`);
406
+ }
407
+ return out.join('\n');
408
+ }
@@ -0,0 +1,133 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ classifyRun, firstParentChain, prNumberFromSubject, pt, jobState, collectProduct, render,
5
+ } from './deploy-state.js';
6
+
7
+ const job = (name, status, conclusion, steps = [], completed_at = '2026-10-12T20:00:00Z') =>
8
+ ({ name, status, conclusion, steps, completed_at });
9
+
10
+ test('prNumberFromSubject reads both merge-commit and squash styles', () => {
11
+ assert.equal(prNumberFromSubject('Merge pull request #2400 from velaro/fix/red-tests-1012'), 2400);
12
+ assert.equal(prNumberFromSubject('Let admins renew an expiring secret (#1474)'), 1474);
13
+ assert.equal(prNumberFromSubject('docs(handoff): final PR states'), null);
14
+ });
15
+
16
+ test('pt formats Pacific, not UTC', () => {
17
+ // 2026-10-13T01:00Z is 6:00 PM on 10/12 in Pacific daylight time
18
+ assert.equal(pt('2026-10-13T01:00:00Z'), '10/12 6:00 PM PT');
19
+ assert.equal(pt(null), 'unknown');
20
+ });
21
+
22
+ test('classifyRun: staging landed, Gate 1 waiting', () => {
23
+ const c = classifyRun([
24
+ job('build-and-deploy-staging', 'completed', 'success'),
25
+ job('[Gate 1] Await Approval', 'waiting', null),
26
+ job('[Gate 1] Deploy to Production Slot', 'queued', null),
27
+ job('[Gate 2] Deploy to Production Environment', 'queued', null),
28
+ ]);
29
+ assert.equal(c.staging.landed, true);
30
+ assert.equal(c.slot1.landed, false);
31
+ assert.equal(c.slot1.waiting, true);
32
+ assert.equal(c.prod.landed, false);
33
+ });
34
+
35
+ test('classifyRun: Gate 1 blocked by red unit tests is not landed', () => {
36
+ const c = classifyRun([
37
+ job('Deploy to Staging Environment', 'completed', 'success'),
38
+ job('Gate: Unit Tests must be green for this commit before Gate 1 can approve', 'completed', 'failure'),
39
+ job('[Gate 1] Deploy to Production Slot', 'completed', 'skipped'),
40
+ ]);
41
+ assert.equal(c.staging.landed, true);
42
+ assert.equal(c.slot1.landed, false);
43
+ assert.equal(c.slot1.blockedByUnitTests, true);
44
+ });
45
+
46
+ test('classifyRun: a swap step that landed counts as live even if the job failed after it', () => {
47
+ const c = classifyRun([
48
+ job('[Gate 2] Deploy to Production Environment', 'completed', 'failure',
49
+ [{ name: 'Swap green slot to production', conclusion: 'success' }, { name: 'Verify', conclusion: 'failure' }]),
50
+ ]);
51
+ assert.equal(c.prod.landed, true);
52
+ });
53
+
54
+ test('classifyRun: Gate 2 waiting is pending, not landed', () => {
55
+ const c = classifyRun([job('[Gate 2] Deploy to Production Environment', 'waiting', null)]);
56
+ assert.equal(c.prod.waiting, true);
57
+ assert.equal(c.prod.landed, false);
58
+ assert.equal(jobState(job('x', 'in_progress', null)), 'running');
59
+ });
60
+
61
+ test('firstParentChain ignores commits that only live on a merged PR branch', () => {
62
+ const commits = [
63
+ { sha: 'b1', parents: [{ sha: 'base' }] }, // branch commit
64
+ { sha: 'm1', parents: [{ sha: 'base' }, { sha: 'b1' }] }, // merge commit of the PR, on dev
65
+ { sha: 'm2', parents: [{ sha: 'm1' }] }, // direct push to dev
66
+ ];
67
+ assert.deepEqual(firstParentChain(commits, 'm2').map((c) => c.sha), ['m2', 'm1']);
68
+ });
69
+
70
+ // A fake gh api for one product, to prove production, slot 1 and the staging environment are read
71
+ // from the right jobs, and that the merged-but-not-in-production list is what is past production.
72
+ function fakeApi() {
73
+ const runs = [
74
+ { id: 3, head_sha: 'sC', event: 'push', created_at: '2026-10-12T22:00:00Z', status: 'in_progress', conclusion: null },
75
+ { id: 2, head_sha: 'sB', event: 'push', created_at: '2026-10-12T18:00:00Z', status: 'completed', conclusion: 'success' },
76
+ { id: 1, head_sha: 'sA', event: 'push', created_at: '2026-10-11T18:00:00Z', status: 'completed', conclusion: 'success' },
77
+ ];
78
+ const jobs = {
79
+ 3: [job('build-and-deploy-staging', 'completed', 'success', [], '2026-10-12T22:30:00Z'),
80
+ job('[Gate 1] Await Approval', 'waiting', null)],
81
+ 2: [job('build-and-deploy-staging', 'completed', 'success', [], '2026-10-12T18:30:00Z'),
82
+ job('[Gate 1] Deploy to Production Slot', 'completed', 'success', [], '2026-10-12T19:00:00Z'),
83
+ job('[Gate 2] Deploy to Production Environment', 'completed', 'skipped')],
84
+ 1: [job('build-and-deploy-staging', 'completed', 'success', [], '2026-10-11T18:30:00Z'),
85
+ job('[Gate 1] Deploy to Production Slot', 'completed', 'success', [], '2026-10-11T19:00:00Z'),
86
+ job('[Gate 2] Deploy to Production Environment', 'completed', 'success', [], '2026-10-11T20:00:00Z')],
87
+ };
88
+ const commit = (sha, msg, parent, date) => ({ sha, parents: parent ? [{ sha: parent }] : [], commit: { message: msg, committer: { date } } });
89
+ const log = {
90
+ sA: commit('sA', 'Prod change (#10)', null, '2026-10-11T17:00:00Z'),
91
+ sB: commit('sB', 'Slot change (#11)', 'sA', '2026-10-12T17:00:00Z'),
92
+ sC: commit('sC', 'Staging change (#12)', 'sB', '2026-10-12T21:00:00Z'),
93
+ };
94
+ return async (p) => {
95
+ let m;
96
+ if ((m = p.match(/workflows\/deploy-staging\.yml\/runs/))) return { workflow_runs: runs };
97
+ if (p.includes('workflows/ci-tests.yml/runs')) return { workflow_runs: [] };
98
+ if ((m = p.match(/actions\/runs\/(\d+)\/jobs/))) return { jobs: jobs[m[1]] };
99
+ if (p.endsWith('/commits/dev')) return log.sC;
100
+ if ((m = p.match(/compare\/(\w+)\.\.\.(\w+)/))) {
101
+ const order = ['sA', 'sB', 'sC'];
102
+ const [a, b] = [order.indexOf(m[1]), order.indexOf(m[2])];
103
+ const commits = order.slice(a + 1, b + 1).map((s) => log[s]);
104
+ return { total_commits: commits.length, commits };
105
+ }
106
+ if ((m = p.match(/commits\/(\w+)$/))) return log[m[1]];
107
+ if ((m = p.match(/pulls\/(\d+)$/))) return { title: `PR title ${m[1]}`, merged_at: `2026-10-12T0${m[1] % 10}:00:00Z` };
108
+ throw new Error('unexpected ' + p);
109
+ };
110
+ }
111
+
112
+ test('collectProduct reads each place from its own gate', async () => {
113
+ const state = await collectProduct('Admin', { api: fakeApi(), fetchFn: async () => ({ ok: false, status: 503 }) });
114
+ assert.equal(state.places.prod.sha, 'sA');
115
+ assert.equal(state.places.slot1.sha, 'sB');
116
+ assert.equal(state.places.staging.sha, 'sC');
117
+ assert.deepEqual(state.notInProduction.map((x) => x.number), [12, 11]);
118
+ assert.equal(state.pendingGates.length, 1);
119
+ assert.match(state.pendingGates[0].gate, /Gate 1 waiting/);
120
+ assert.equal(state.activeRuns.length, 1);
121
+ });
122
+
123
+ test('render names the three places in the exact words and never says prod-green', async () => {
124
+ const state = await collectProduct('Admin', { api: fakeApi(), fetchFn: async () => ({ ok: false, status: 503 }) });
125
+ const text = render({ generatedAt: '2026-10-13T01:00:00Z', freeze: 'FROZEN for candidate abc', products: [state] });
126
+ assert.match(text, /THE STAGING ENVIRONMENT, ADMIN-STAGING/);
127
+ assert.match(text, /STAGING SLOT 1 OF PRODUCTION/);
128
+ assert.match(text, /\nPRODUCTION\n/);
129
+ assert.match(text, /Merged to dev but not yet in production: 2/);
130
+ assert.match(text, /Merge freeze is ON: FROZEN for candidate abc/);
131
+ assert.match(text, /PENDING: Gate 1 waiting for approval, deploy run 3/);
132
+ assert.doesNotMatch(text, /prod-green/i);
133
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.97",
3
+ "version": "1.4.99",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {