SLOPSHOPPER

og-sf-org-status

Target Salesforce org in the status line, a panel to switch orgs per project, and a write guard for protected and production orgs

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · og-sf-org-status
│ ┃ Salesforce orgs ✕ › fix the failing auth test and add an audit log call │ ┃ ━━ ☁ og-sf-org-status │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Read(src/auth.ts) │ ┃ Outside a Salesforce DX project (no ⎿ Read 6 lines │ ┃ sfdx-project.json above the directory). ⏺ Update(src/auth.ts) │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⎿ Added 2 lines, removed 1 line │ ┃ ━━━━━━━━━━━━ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /og-sf-org │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Salesforce orgs
━━ ☁ og-sf-org-status ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Outside a Salesforce DX project (no sfdx-project.json above the directory). ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
README

og-sf-org-status

The project's target Salesforce org, always in sight, and a panel to switch between the project's orgs.

What it does

Status line: SF 🔴 PROD · RevCloudDemo · SF 🟡 sandbox · globexqa · SF 🟢 scratch · rac-demo4 · SF 🔵 dev-ed · SF-DEV-ORG. Outside a Salesforce DX project (no sfdx-project.json above the cwd), nothing shows.

Warning band: when the target org is production, a ⚠ PRODUCTION band shows above the prompt, with Switch (opens the panel) and Hide (gone until the target changes).

Panel (/og-sf-org): shows the target org at the top (alias, kind, username, instance and where the target comes from), then lists the project's orgs with kind, username and the selected org's info (edition, API version, pod, connection status, expiration and org id). Where no panel can be drawn, the command prints the target org as text instead. A gradient bar above and below frames the panel, in Neocol colors that follow the target: aqua to purple when free, yellow to salmon when protected, salmon to red in production. Keys with the panel focused:

KeyAction
tMake target: sf config set target-org=<alias> in the project
oOpen in browser: sf org open -o <username>
iRefresh info
eEdit list: every authenticated org, grouped by domain, to check the project's ones
sSave list
pProtect / unprotect the org (on production: downgrade, by typing the alias)
rRestore production on a downgraded org

/og-sf-org <alias> switches the target directly, without opening the panel.

Protected org guard

Write commands Claude runs through the Bash tool, or the PowerShell tool on Windows, against a protected org stop for you to confirm. This does not apply to what you run in your own terminal.

Levels. The mark belongs to the org (username) and holds in every project.

LevelHow an org gets thereConfirmation
ProductionEvery org classified as production, by defaultAlert beep and a dialog under a red ⚠ PRODUCTION · ALIAS band. Only the alias typed under Other allows it; any other answer, or Esc, denies. Covers one command only.
Protected 🔒p in the panel, or automatic on a sandbox whose alias or username suffix names an environment: uat, qa, stg, staging, preprod, full, partial (auto)Dialog: Allow once, Allow for 15 min (that org only, this session; dropped if the target org changes) or Deny
FreeThe rest, or p on a protected oneNone

A demo, SDO or Trailhead Playground org shows as production. p downgrades it (asks for the typed alias) and it becomes protected, shown as PROD↓; r restores it. The status line shows 🔒 on protected orgs.

What counts as a write. sf project deploy start/quick/resume (not validate, nor --dry-run), project delete source, data create/update/delete/upsert/import/bulk, apex run (not apex run test), package install, org assign permset, org delete, api request rest with a method other than GET (--method/-X), api request graphql, and the sfdx force:* equivalents. Reads (query, retrieve, org display/open, logs) pass freely, and so does anonymous Apex that only queries and prints (no DML, no Database.insert and the like, no async or callout, no call into the org's own classes) and GraphQL without a mutation; the guard reads the code from --file/--body, or from the command line for a heredoc or a pipe, and a file it cannot read counts as a write. The org comes from -o/--target-org (or -u), case-insensitive; with no flag, it is the project's target org.

Indirect paths. The parser follows chained commands (&&, ;, |, $(...)) and bash -c '...', pwsh -Command, cmd /c, and reads PowerShell syntax (paths with \, & "...\sf.cmd", the backtick as escape and line continuation). A command that names the alias, username or URL of a protected org asks too when it may write: an HTTP client sending a body or a method other than GET (curl -X PATCH, curl -d, wget --post-data, HTTPie, Invoke-RestMethod -Method Post), or an interpreter running code (node, python and the like). Reads that name the org (cat, grep, jq, a curl GET) pass. Project scripts project scripts named deploy, push, release, data, seed, apex or migrate (npm run deploy:uat, ./scripts/deploy.sh) when the target org is protected. The system prompt lists the project's protected orgs and tells Claude not to work around the guard.

When nobody can answer (claude -p, SDK), the write is denied. If the guard fails while deciding on a write command, it denies (fails closed). MCP servers that write to orgs are out of scope.

Windows. Works the same, with one difference: Claude Code plays no sounds on Windows, so the production alert is visual only. The home folder comes from USERPROFILE when HOME is unset, and the mod runs the sf CLI through cmd /c (sf.cmd must be on the Claude process's PATH for the panel to show org info and switch the target; the status line and the guard read files and do not depend on it).

Where the project's orgs come from

  1. Saved list: kept in the mod's store (in your user), keyed by the project path. Nothing is written to the client's repo.
  2. Inferred: with no saved list, the panel shows the target org and the other authenticated orgs of the same username domain (@globex.com.globexdev and @globex.com.globexqa → globex.com). Generic scratch domains (example.com) are left out. Save list pins the result.

Resolving the target org

Follows the sf CLI's precedence: SF_TARGET_ORG (or SFDX_DEFAULTUSERNAME) in Claude's process, then the project's .sf/config.json, then the global ~/.sf/config.json. The legacy .sfdx/sfdx-config.json files are read too. Aliases come from ~/.sfdx/alias.json. The org kind comes from the auth file ~/.sfdx/<username>.json: it uses isScratch/isSandbox and, when they are missing, infers from the URL.

The status line refreshes at session start, after each sf/sfdx command of the Bash or PowerShell tool, and every 15 s (catching changes made in another terminal).

Demo, SDO and Trailhead Playground orgs show as PROD or dev-ed, because technically they are production orgs.

Source 5 files
hooks/register.tsx 768 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { OrgInfo, OrgRow, Override, Panel, TargetOrg } from '../types'
5import { barCells } from './gradient'
6import type { Cell, Palette } from './gradient'
7import { LOCK, OPERATION_LABEL, apexOnlyReads, findWrites, graphqlOnlyReads, mentions, otherCommands, protectionOf } from './guard'
8import type { Shell, Write } from './guard'
9import type { Protection } from './guard'
10import {
11  aliasesIn,
12  badgeOf,
13  baseName,
14  buildCatalog,
15  explain,
16  SOURCE_LABEL,
17  findProjectRoot,
18  inferProjectOrgs,
19  parse,
20  parseInfo,
21  resolveTargetOrg,
22  savedProjectOrgs,
23  statusText,
24} from './org'
25import type { AuthFile } from './org'
26
27const PLUGIN = 'og-sf-org-status'
28const COMMAND = 'og-sf-org'
29const PANE = 'sf-orgs'
30const POLL_MS = 15_000
31// sf/sfdx commands that can change the target org or its auth.
32const SF_COMMAND = /(^|[\s;&|(\\/])(sf|sfdx)(\.cmd|\.exe)?\s/i
33const ORG_QUERY = 'SELECT OrganizationType, InstanceName, TrialExpirationDate FROM Organization'
34const PROD_HEADER = '⚠ PRODUCTION'
35const PROTECTION_KEY = 'protection'
36const WINDOW_MS = 15 * 60_000
37const CATALOG_MS = 15_000
38const ONCE = 'Allow once'
39const FOR_A_WHILE = 'Allow for 15 min'
40const DENY = 'Deny'
41
42const current = atom({ plugin: 'og-sf-org-status', key: 'current' } as const, null)
43const panel = atom({ plugin: 'og-sf-org-status', key: 'panel' } as const, null)
44const selected = atom({ plugin: 'og-sf-org-status', key: 'selected' } as const, null)
45const editing = atom({ plugin: 'og-sf-org-status', key: 'editing' } as const, null)
46const info = atom({ plugin: 'og-sf-org-status', key: 'info' } as const, {})
47const message = atom({ plugin: 'og-sf-org-status', key: 'message' } as const, null)
48const bandDismissed = atom({ plugin: 'og-sf-org-status', key: 'bandDismissed' } as const, null)
49const protection = atom({ plugin: 'og-sf-org-status', key: 'protection' } as const, {})
50const prodAsk = atom({ plugin: 'og-sf-org-status', key: 'prodAsk' } as const, null)
51const downgrading = atom({ plugin: 'og-sf-org-status', key: 'downgrading' } as const, null)
52
53// The status text last shown, so an unchanged org draws nothing.
54let shown: string | undefined
55// The target the 15-minute authorizations were given under; a new target drops them.
56let windowTarget: string | undefined
57// Protected orgs authorized for a while in this session: username → until.
58const windows = new Map<string, number>()
59// Every authenticated org and every alias, read at most every 15 s.
60let orgs: { at: number; catalog: OrgRow[]; aliases: Record<string, string> } | undefined
61// The project's protected orgs, for the system prompt; undefined when none.
62let guardSection: string | undefined
63
64const projectKey = (root: string) => `project:${root}`
65const nameOf = (row: { alias?: string; username: string }) => row.alias ?? row.username
66const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
67
68async function readText($: EngineInterface, path: string) {
69  return $.fs.read(path).then(String, () => undefined)
70}
71
72/** The home folder: HOME, or USERPROFILE on Windows, where HOME is often unset. */
73async function homeOf($: EngineInterface) {
74  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
75}
76
77/**
78 * Runs the sf CLI. On Windows `sf` is `sf.cmd`, a batch file a process cannot
79 * start without a shell, so it goes through `cmd /c`.
80 */
81async function runSf($: EngineInterface, args: string[], init?: { cwd?: string; timeoutMs?: number }) {
82  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
83
84  return $.process.run(isWindows ? ['cmd.exe', '/d', '/s', '/c', 'sf', ...args] : ['sf', ...args], init)
85}
86
87async function where($: EngineInterface) {
88  const [cwd, home, sfTarget, legacyTarget] = await Promise.all([
89    $.session.cwd(),
90    homeOf($),
91    $.env.get('SF_TARGET_ORG'),
92    $.env.get('SFDX_DEFAULTUSERNAME'),
93  ])
94
95  return { cwd, home, envTarget: sfTarget ?? legacyTarget }
96}
97
98async function loadProtection($: EngineInterface) {
99  const stored = await $.store.get(PROTECTION_KEY)
100  const marks = stored !== null && typeof stored === 'object' ? (stored as Record<string, Override>) : {}
101  await update($, protection, () => marks)
102
103  return marks
104}
105
106async function setOverride($: EngineInterface, username: string, value: Override | undefined) {
107  const marks = { ...(await read($, protection)) }
108  if (value === undefined) delete marks[username]
109  else marks[username] = value
110  await $.store.set(PROTECTION_KEY, marks)
111  await update($, protection, () => marks)
112  windows.delete(username)
113  await refresh($)
114}
115
116async function refresh($: EngineInterface) {
117  const { cwd, home, envTarget } = await where($)
118  const org = home === undefined ? undefined : await resolveTargetOrg(path => readText($, path), { cwd, home, envTarget })
119  await update($, current, () => org ?? null)
120  if (org?.username !== windowTarget) {
121    windowTarget = org?.username
122    windows.clear()
123  }
124  const marks = await read($, protection)
125  const text = org && statusText(org, protectionOf(org, marks[org.username]))
126  if (text !== shown) {
127    shown = text
128    $.ui.status(text)
129  }
130
131  return org
132}
133
134async function loadCatalog($: EngineInterface, home: string): Promise<OrgRow[]> {
135  const dir = `${home}/.sfdx`
136  const entries = await $.fs.list(dir).catch(() => [])
137  const names = entries.map(entry => entry.name).filter(name => name.endsWith('.json') && name.includes('@'))
138  const auths = await Promise.all(names.map(async name => parse<AuthFile>(await readText($, `${dir}/${name}`)) ?? {}))
139  const aliases = aliasesIn(await readText($, `${dir}/alias.json`))
140  const catalog = buildCatalog(auths, aliases)
141  orgs = { at: Date.now(), catalog, aliases }
142
143  return catalog
144}
145
146/** The catalog and aliases, from the cache while it is fresh. */
147async function knownOrgs($: EngineInterface) {
148  if (orgs !== undefined && Date.now() - orgs.at < CATALOG_MS) return orgs
149  const home = await homeOf($)
150  if (home !== undefined) await loadCatalog($, home)
151
152  return orgs ?? { at: 0, catalog: [], aliases: {} }
153}
154
155/** The project's orgs: the saved list, else the target's domain mates. */
156async function projectOrgs($: EngineInterface, root: string, catalog: OrgRow[], org: TargetOrg | undefined) {
157  const saved = await $.store.get(projectKey(root))
158  const savedList = Array.isArray(saved) ? saved.filter((u): u is string => typeof u === 'string') : undefined
159
160  return {
161    orgs: savedList ? savedProjectOrgs(catalog, savedList) : inferProjectOrgs(catalog, org),
162    listSource: savedList ? ('saved' as const) : ('inferred' as const),
163  }
164}
165
166/** What the model is told about the project's protected orgs. */
167function sectionFor(rows: OrgRow[], marks: Record<string, Override>) {
168  const guarded = rows
169    .map(row => ({ row, guard: protectionOf(row, marks[row.username]) }))
170    .filter(({ guard }) => guard.level !== 'free')
171  if (guarded.length === 0) return undefined
172  const list = guarded
173    .map(({ row, guard }) => `${nameOf(row)} (${row.username}, ${guard.level === 'production' ? 'PRODUCTION' : 'protected'})`)
174    .join('; ')
175
176  return [
177    `Salesforce org guard (og-sf-org-status): these orgs of the project are protected: ${list}.`,
178    'Writes to them (deploy, DML, anonymous Apex, package installs, permission sets, deploy scripts) pause for the user to confirm; production asks the user to type the org alias.',
179    'Never work around the guard: do not write to these orgs another way (REST API with curl, a script, another tool or org flag). When the guard denies a command, stop and ask the user how to proceed.',
180  ].join(' ')
181}
182
183async function refreshGuardSection($: EngineInterface) {
184  const { cwd, home } = await where($)
185  const root = await findProjectRoot(path => readText($, path), cwd)
186  if (home === undefined || root === undefined) {
187    guardSection = undefined
188    return
189  }
190  const [{ catalog }, org, marks] = await Promise.all([knownOrgs($), read($, current), read($, protection)])
191  const { orgs: rows } = await projectOrgs($, root, catalog, org ?? undefined)
192  guardSection = sectionFor(rows, marks)
193}
194
195async function loadPanel($: EngineInterface) {
196  const { cwd, home } = await where($)
197  const root = await findProjectRoot(path => readText($, path), cwd)
198  if (home === undefined || root === undefined) {
199    await update($, panel, () => null)
200    return undefined
201  }
202  const [org, catalog] = await Promise.all([refresh($), loadCatalog($, home)])
203  const project = parse<{ name?: string }>(await readText($, `${root}/sfdx-project.json`))
204  const next: Panel = {
205    root,
206    name: project?.name ?? baseName(root),
207    ...(await projectOrgs($, root, catalog, org)),
208    catalog,
209  }
210  await update($, panel, () => next)
211  guardSection = sectionFor(next.orgs, await read($, protection))
212  const pick = (await read($, selected)) ?? org?.username ?? next.orgs[0]?.username ?? null
213  if (pick !== null) await select($, pick)
214
215  return next
216}
217
218async function openPanel($: EngineInterface) {
219  await update($, message, () => null)
220  await update($, editing, () => null)
221  const { isPlaced } = await $.ui.open({ id: PANE, title: 'Salesforce orgs', focus: true, closeOnEscape: true })
222  await loadPanel($)
223
224  return isPlaced
225}
226
227async function loadInfo($: EngineInterface, username: string) {
228  await update($, info, all => ({ ...all, [username]: { status: 'loading' as const } }))
229  const run = (args: string[]) =>
230    runSf($, args, { timeoutMs: 60_000 }).then(
231      ran => ran.stdout,
232      () => undefined,
233    )
234  const [display, query] = await Promise.all([
235    run(['org', 'display', '-o', username, '--json']),
236    run(['data', 'query', '-o', username, '-q', ORG_QUERY, '--json']),
237  ])
238  const found: OrgInfo =
239    display === undefined ? { status: 'error', message: 'sf CLI not found or not responding' } : parseInfo(display, query)
240  await update($, info, all => ({ ...all, [username]: found }))
241}
242
243async function select($: EngineInterface, username: string) {
244  await update($, selected, () => username)
245  const known = (await read($, info))[username]
246  if (known === undefined || known.status === 'error') await loadInfo($, username)
247}
248
249async function makeTarget($: EngineInterface, row: OrgRow) {
250  const shown = await read($, panel)
251  if (shown === null) return
252  await update($, message, () => `Setting ${nameOf(row)} as the target org…`)
253  const ran = await runSf($, ['config', 'set', `target-org=${nameOf(row)}`, '--json'], { cwd: shown.root })
254    .catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
255  if (ran.exitCode !== 0) {
256    const reason = parse<{ message?: string }>(ran.stdout)?.message ?? firstLine(ran.stderr)
257    await update($, message, () => `Failed: ${reason}`)
258    return
259  }
260  const org = await refresh($)
261  await update($, message, () =>
262    org?.source === 'env'
263      ? `Saved in the project, but SF_TARGET_ORG (${org.target}) still takes precedence.`
264      : `Target org now: ${nameOf(row)}`,
265  )
266}
267
268async function openInBrowser($: EngineInterface, row: OrgRow) {
269  await update($, message, () => `Opening ${nameOf(row)} in the browser…`)
270  const ran = await runSf($, ['org', 'open', '-o', row.username, '--json'], { timeoutMs: 60_000 })
271    .catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
272  await update($, message, () =>
273    ran.exitCode === 0
274      ? `${nameOf(row)} opened in the browser.`
275      : `Failed: ${parse<{ message?: string }>(ran.stdout)?.message ?? firstLine(ran.stderr)}`,
276  )
277}
278
279async function saveList($: EngineInterface, usernames: string[]) {
280  const shown = await read($, panel)
281  if (shown === null) return
282  await $.store.set(projectKey(shown.root), usernames)
283  await update($, editing, () => null)
284  await update($, message, () => `List saved: ${usernames.length} org(s) for ${shown.name}.`)
285  await loadPanel($)
286}
287
288async function switchTo($: EngineInterface, name: string) {
289  const loaded = await loadPanel($)
290  if (loaded === undefined) return 'Outside a Salesforce DX project.'
291  const row = loaded.catalog.find(one => one.alias === name || one.username === name)
292  if (row === undefined) return `No authenticated org with alias or username "${name}".`
293  await makeTarget($, row)
294
295  return (await read($, message)) ?? ''
296}
297
298const SHELL_TOOLS: Record<string, Shell> = { Bash: 'bash', PowerShell: 'powershell' }
299
300/** The command a shell tool call runs, and the shell it is written for. */
301const shellCall = (e: { tool: string }): { command: string; shell: Shell } | undefined => {
302  const shell = SHELL_TOOLS[e.tool]
303  const command = (e as { command?: unknown }).command
304  return shell !== undefined && typeof command === 'string' ? { command, shell } : undefined
305}
306
307/**
308 * Whether anonymous Apex or GraphQL only reads, from its file or the command
309 * line itself. A file that cannot be read counts as a write; a GraphQL `--body`
310 * that is no file is the query inline.
311 */
312async function onlyReads($: EngineInterface, write: Write, command: string): Promise<boolean> {
313  if (write.code === undefined) return false
314  const { language, file } = write.code
315  const path = file !== undefined && !/^([\\/]|[A-Za-z]:|~)/.test(file) ? `${await $.session.cwd()}/${file}` : file
316  const code =
317    path === undefined || file === '-' ? command : await $.fs.read(path).catch(() => (language === 'graphql' ? file : undefined))
318  if (code === undefined) return false
319
320  return language === 'apex' ? apexOnlyReads(code) : graphqlOnlyReads(code)
321}
322
323type Hit = { row: OrgRow; guard: Protection; operations: string[]; commands: string[] }
324
325/** The guarded orgs a shell command would write to, production first. */
326async function hitsOf($: EngineInterface, command: string, shell: Shell): Promise<Hit[]> {
327  const found = findWrites(command, shell)
328  const reads = await Promise.all(found.map(write => onlyReads($, write, command)))
329  const writes = found.filter((_, i) => !reads[i])
330  const others = otherCommands(command, shell)
331  if (writes.length === 0 && others.length === 0) return []
332  const [{ catalog, aliases }, marks] = await Promise.all([knownOrgs($), read($, protection)])
333  const target = writes.some(write => write.target === undefined) ? await refresh($) : undefined
334  const hits = new Map<string, Hit>()
335  const add = (row: OrgRow, operation: string, text: string) => {
336    const guard = protectionOf(row, marks[row.username])
337    if (guard.level === 'free') return
338    const hit = hits.get(row.username) ?? { row, guard, operations: [], commands: [] }
339    if (!hit.operations.includes(operation)) hit.operations.push(operation)
340    if (!hit.commands.includes(text)) hit.commands.push(text)
341    hits.set(row.username, hit)
342  }
343  const rowOf = (name: string): OrgRow | undefined => {
344    // An alias typed in another case (`-o qa` for `QA`) is still that org: better to ask than to miss it.
345    const alias = Object.keys(aliases).find(one => one === name) ?? Object.keys(aliases).find(one => one.toLowerCase() === name.toLowerCase())
346    const username = (alias !== undefined ? aliases[alias] : undefined) ?? name
347    return catalog.find(row => row.username.toLowerCase() === username.toLowerCase())
348  }
349
350  for (const write of writes) {
351    const row =
352      write.target !== undefined
353        ? rowOf(write.target)
354        : target && (rowOf(target.username) ?? { username: target.username, alias: target.alias, kind: target.kind })
355    if (row !== undefined) add(row, OPERATION_LABEL[write.operation], write.text)
356  }
357  for (const text of others) {
358    for (const row of catalog) if (mentions(text, row)) add(row, 'command naming the org', text)
359  }
360
361  return [...hits.values()].sort((a, b) => Number(b.guard.level === 'production') - Number(a.guard.level === 'production'))
362}
363
364const denial = (hit: Hit, why: string) =>
365  `Blocked by the Salesforce org guard: ${why} (${hit.operations.join(', ')} on ${nameOf(hit.row)}, ` +
366  `${hit.guard.level === 'production' ? 'PRODUCTION' : 'protected org'}). ` +
367  'Do not retry the command or work around the guard; ask the user how to proceed.'
368
369/** A protected org: the engine's question, once or for 15 minutes. */
370async function askProtected($: EngineInterface, hit: Hit): Promise<string | undefined> {
371  const until = windows.get(hit.row.username)
372  if (until !== undefined && until > Date.now()) return undefined
373  const question =
374    `${LOCK} ${nameOf(hit.row)} is a protected org. ${hit.operations.join(', ')}: ` +
375    `${hit.commands.join(' ; ')} — allow?`
376  const answer = await $.ui
377    .ask(question, { header: `${LOCK} Org`, options: [ONCE, FOR_A_WHILE, DENY] })
378    .catch(() => undefined)
379  if (answer === FOR_A_WHILE) windows.set(hit.row.username, Date.now() + WINDOW_MS)
380  if (answer === ONCE || answer === FOR_A_WHILE) return undefined
381
382  return denial(hit, answer === undefined ? 'nobody confirmed the write' : 'the user denied the write')
383}
384
385/**
386 * Production: a sound, and the engine's question under a red banner; only the
387 * alias typed under Other lets it through. The question is a `$` call, so the
388 * wait for the person never counts against the hook's 10-second budget.
389 */
390async function askProduction($: EngineInterface, hit: Hit): Promise<string | undefined> {
391  const name = nameOf(hit.row)
392  void $.audio.play({ asset: 'sounds/alert.wav' }).catch(() => undefined)
393  await update($, prodAsk, () => ({
394    name,
395    username: hit.row.username,
396    operation: hit.operations.join(', '),
397    command: hit.commands.join('\n'),
398  }))
399  const answer = await $.ui
400    .ask(`Type "${name}" under Other to allow ${hit.operations.join(', ')} in PRODUCTION. Allow?`, {
401      header: PROD_HEADER,
402      options: [DENY, 'Cancel'],
403    })
404    .catch(() => undefined)
405  await update($, prodAsk, () => null)
406  const typed = answer?.trim()
407
408  return typed === name || typed === hit.row.username
409    ? undefined
410    : denial(hit, answer === undefined ? 'nobody confirmed the write' : 'the user did not type the production alias')
411}
412
413/** Undefined to let the command run, else why it may not. */
414async function guard($: EngineInterface, command: string, shell: Shell): Promise<string | undefined> {
415  for (const hit of await hitsOf($, command, shell)) {
416    const refused = hit.guard.level === 'production' ? await askProduction($, hit) : await askProtected($, hit)
417    if (refused !== undefined) return refused
418  }
419
420  return undefined
421}
422
423async function downgrade($: EngineInterface, row: OrgRow, typed: string) {
424  await update($, downgrading, () => null)
425  if (typed.trim() !== nameOf(row) && typed.trim() !== row.username) {
426    await update($, message, () => `Failed: the typed name does not match; ${nameOf(row)} stays PRODUCTION.`)
427    return
428  }
429  await setOverride($, row.username, 'protected')
430  await update($, message, () => `${nameOf(row)} downgraded: now protected (r restores production).`)
431}
432
433function infoLine(found: OrgInfo | undefined) {
434  if (found === undefined || found.status === 'loading') return 'loading info…'
435  if (found.status === 'error') return `info unavailable: ${found.message}`
436
437  return [
438    found.edition,
439    found.apiVersion && `API ${found.apiVersion}`,
440    found.pod && `pod ${found.pod}`,
441    found.connectedStatus,
442    found.expires && `expires ${found.expires}`,
443    found.orgId,
444  ]
445    .filter(Boolean)
446    .join(' · ')
447}
448
449export const register: Register = on => {
450  shown = undefined
451
452  on('session.start', async ($, e, next) => {
453    const started = await next(e)
454    await $.command.register({
455      name: COMMAND,
456      description: "The project's Salesforce orgs panel (or /og-sf-org <alias> to switch)",
457      argumentHint: '[alias]',
458    })
459    await loadProtection($)
460    await refresh($)
461    await refreshGuardSection($)
462    // Catches changes made outside the session: another terminal, VS Code.
463    $.clock.every(POLL_MS, () => void refresh($))
464
465    return started
466  })
467
468  on('command.run', { command: COMMAND }, async ($, e) => {
469    const name = e.args.trim()
470    if (name !== '') return { text: await switchTo($, name) }
471    // The panel shows the target itself; the text is for where no panel is drawn.
472    const org = await refresh($)
473    return (await openPanel($)) ? {} : { text: explain(org) }
474  }).catch(() => ({ text: 'og-sf-org-status: could not read the sf CLI configuration.' }))
475
476  // Every shell tool: Bash, and PowerShell on Windows.
477  on('tool.call', async ($, e, next) => {
478    const call = shellCall(e)
479    if (call === undefined) return next(e)
480    // Fails closed: a guard that cannot decide on a write refuses it.
481    const refused = await guard($, call.command, call.shell).catch(error =>
482      findWrites(call.command, call.shell).length > 0 ? `Blocked: the Salesforce org guard failed (${String(error)}).` : undefined,
483    )
484    if (refused !== undefined) return { deny: refused }
485    const ran = await next(e)
486    if (SF_COMMAND.test(call.command)) {
487      await refresh($)
488      await refreshGuardSection($)
489    }
490
491    return ran
492  }).catch(($, e, next) => {
493    // Past the guard the command already ran: replay it. Before, a write is refused.
494    const call = shellCall(e)
495    return next.called || call === undefined || findWrites(call.command, call.shell).length === 0
496      ? next(e)
497      : { deny: 'Blocked: the Salesforce org guard failed.' }
498  })
499
500  on('prompt.compose', async ($, e, next) => {
501    const composed = await next(e)
502    if (guardSection === undefined) return composed
503
504    return {
505      sections: [...composed.sections, { id: `${PLUGIN}:guard`, text: guardSection, scope: 'session' as const }],
506    }
507  }).catch(($, e, next) => next(e))
508
509  // The production question, under a banner nobody reads past by accident.
510  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
511    const drawn = await next(e)
512    const [first] = e.props.questions as { header?: string }[]
513    const ask = first?.header === PROD_HEADER ? await read($, prodAsk) : null
514    if (ask === null) return drawn
515    const { Box, Text } = $.ui.resolve(e)
516
517    return (
518      <Box flexDirection="column">
519        <Box backgroundColor="error" paddingX={1} flexDirection="column">
520          <Text color="inverseText" bold>
521            {`⚠ ⚠ ⚠  PRODUCTION · ${ask.name.toUpperCase()}  ⚠ ⚠ ⚠`}
522          </Text>
523          <Text color="inverseText">Claude wants to run {ask.operation} on a PRODUCTION org.</Text>
524        </Box>
525        <Box flexDirection="column" marginY={1}>
526          <Text>
527            Org: <Text bold>{ask.name}</Text> <Text dimColor>{ask.username}</Text>
528          </Text>
529          <Text color="error">{ask.command}</Text>
530          <Text dimColor>Only the alias typed under Other allows it. Any other answer denies.</Text>
531        </Box>
532        {drawn}
533      </Box>
534    )
535  }).catch(($, e, next) => next(e))
536
537  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
538    const elements = $.ui.resolve(e)
539    const { Box, Text, Button } = elements
540    const Input = 'Input' in elements ? elements.Input : undefined
541    const [shownPanel, target, pick, checked, infos, note, marks, lowering] = await Promise.all([
542      read($, panel),
543      read($, current),
544      read($, selected),
545      read($, editing),
546      read($, info),
547      read($, message),
548      read($, protection),
549      read($, downgrading),
550    ])
551    const guardOf = (row: OrgRow) => protectionOf(row, marks[row.username])
552
553    // A gradient bar above and below marks where the mod's display starts and ends;
554    // its colors follow how guarded the target org is.
555    const palette: Palette = target === null ? 'free' : protectionOf(target, marks[target.username]).level
556    const width = Math.max(20, Math.min(e.props.bodyColumns, 120))
557    const bar = (cells: Cell[], key: string) => (
558      <Text key={key}>
559        {cells.map((cell, i) => (
560          <Text key={`${key}:${i}`} color={cell.color} bold={cell.isBold}>
561            {cell.char}
562          </Text>
563        ))}
564      </Text>
565    )
566    const frame = (body: unknown) => (
567      <Box flexDirection="column">
568        {bar(barCells(width, palette, '☁ og-sf-org-status'), 'top')}
569        {body}
570        {bar(barCells(width, palette).reverse(), 'bottom')}
571      </Box>
572    )
573
574    if (shownPanel === null) {
575      return frame(
576        <Box flexDirection="column">
577          <Text dimColor>Outside a Salesforce DX project (no sfdx-project.json above the directory).</Text>
578        </Box>
579      )
580    }
581
582    if (checked !== null) {
583      const groups = new Map<string, OrgRow[]>()
584      for (const row of shownPanel.catalog) {
585        const group = row.domain ?? 'other'
586        groups.set(group, [...(groups.get(group) ?? []), row])
587      }
588
589      return frame(
590        <Box flexDirection="column">
591          <Text bold>Orgs of project {shownPanel.name}: check the ones that belong to it</Text>
592          <Text dimColor>Tab moves · Enter checks/unchecks · s saves · c cancels</Text>
593          {[...groups].map(([group, rows]) => (
594            <Box key={`group:${group}`} flexDirection="column" marginTop={1}>
595              <Text dimColor>{group}</Text>
596              {rows.map(row => (
597                <Button
598                  key={`check:${row.username}`}
599                  plain
600                  onPress={() =>
601                    update($, editing, list =>
602                      list === null
603                        ? list
604                        : list.includes(row.username)
605                          ? list.filter(u => u !== row.username)
606                          : [...list, row.username],
607                    )
608                  }
609                >
610                  {checked.includes(row.username) ? '[x]' : '[ ]'} {badgeOf(row.kind, guardOf(row))} <Text bold>{nameOf(row)}</Text>{' '}
611                  <Text dimColor>{row.username}</Text>
612                </Button>
613              ))}
614            </Box>
615          ))}
616          <Box marginTop={1} gap={1}>
617            <Button key="save" label={`Save (${checked.length})`} hotkey="s" variant="primary" onPress={() => saveList($, checked)} />
618            <Button key="cancel" label="Cancel" hotkey="c" onPress={() => update($, editing, () => null)} />
619          </Box>
620        </Box>
621      )
622    }
623
624    const row = shownPanel.orgs.find(one => one.username === pick) ?? shownPanel.orgs[0]
625    const rowGuard = row && guardOf(row)
626
627    if (row && lowering === row.username) {
628      return frame(
629        <Box flexDirection="column">
630          <Text color="error" bold>
631            Downgrade {nameOf(row)}: it is classified as PRODUCTION.
632          </Text>
633          <Text>Only do this if it is not real production (demo, SDO, Trailhead Playground). It stays protected.</Text>
634          {Input && (
635            <Box marginTop={1}>
636              <Input
637                key="downgrade"
638                label={`Type ${nameOf(row)} to confirm: `}
639                placeholder={nameOf(row)}
640                submitLabel="downgrade"
641                autoFocus
642                onSubmit={(value: string) => downgrade($, row, value)}
643              />
644            </Box>
645          )}
646          <Box marginTop={1}>
647            <Button key="cancel-downgrade" label="Cancel" hotkey="c" onPress={() => update($, downgrading, () => null)} />
648          </Box>
649        </Box>
650      )
651    }
652
653    return frame(
654      <Box flexDirection="column">
655        <Text>
656          <Text bold>{shownPanel.name}</Text>{' '}
657          <Text dimColor>
658            {shownPanel.listSource === 'saved'
659              ? '· saved list'
660              : "· list inferred from the username's domain (not saved)"}
661          </Text>
662        </Text>
663        {target ? (
664          <Box flexDirection="column" marginTop={1}>
665            <Text>
666              Target: <Text bold>{nameOf(target)}</Text> {badgeOf(target.kind, protectionOf(target, marks[target.username]))}{' '}
667              <Text dimColor>· from {SOURCE_LABEL[target.source]}</Text>
668            </Text>
669            <Text dimColor>
670              {target.username}
671              {target.instanceUrl ? ` · ${target.instanceUrl}` : ''}
672            </Text>
673          </Box>
674        ) : (
675          <Box marginTop={1}>
676            <Text dimColor>No target org set.</Text>
677          </Box>
678        )}
679        <Box flexDirection="column" marginTop={1}>
680          {shownPanel.orgs.length === 0 && <Text dimColor>No orgs. Use Edit list.</Text>}
681          {shownPanel.orgs.map(one => (
682            <Button key={`row:${one.username}`} plain onPress={() => select($, one.username)}>
683              {one.username === row?.username ? '›' : ' '} {badgeOf(one.kind, guardOf(one))} <Text bold>{nameOf(one)}</Text>{' '}
684              <Text dimColor>{one.username}</Text>
685              {guardOf(one).isAuto ? <Text dimColor> (auto)</Text> : ''}
686              {one.username === target?.username ? <Text color="success"> ← target</Text> : ''}
687            </Button>
688          ))}
689        </Box>
690        {row && (
691          <Box marginTop={1}>
692            <Text dimColor>{infoLine(infos[row.username])}</Text>
693          </Box>
694        )}
695        <Box marginTop={1} gap={1} flexWrap="wrap">
696          {row && row.username !== target?.username && (
697            <Button key="target" label="Make target" hotkey="t" variant="primary" onPress={() => makeTarget($, row)} />
698          )}
699          {row && <Button key="open" label="Open in browser" hotkey="o" onPress={() => openInBrowser($, row)} />}
700          {row && <Button key="info" label="Refresh info" hotkey="i" onPress={() => loadInfo($, row.username)} />}
701          {row && rowGuard && (
702            <Button
703              key="protect"
704              label={
705                rowGuard.level === 'production'
706                  ? 'Downgrade production'
707                  : rowGuard.level === 'protected'
708                    ? 'Unprotect'
709                    : 'Protect'
710              }
711              hotkey="p"
712              onPress={() =>
713                rowGuard.level === 'production'
714                  ? update($, downgrading, () => row.username)
715                  : setOverride($, row.username, rowGuard.level === 'protected' ? 'free' : 'protected')
716              }
717            />
718          )}
719          {row && rowGuard?.isDowngraded && (
720            <Button key="restore" label="Restore production" hotkey="r" onPress={() => setOverride($, row.username, undefined)} />
721          )}
722          <Button
723            key="edit"
724            label="Edit list"
725            hotkey="e"
726            onPress={() => update($, editing, () => shownPanel.orgs.map(one => one.username))}
727          />
728          {shownPanel.listSource === 'inferred' && shownPanel.orgs.length > 0 && (
729            <Button key="keep" label="Save list" hotkey="s" onPress={() => saveList($, shownPanel.orgs.map(one => one.username))} />
730          )}
731        </Box>
732        {note && (
733          <Box marginTop={1}>
734            <Text color={note.startsWith('Failed') ? 'error' : 'suggestion'}>{note}</Text>
735          </Box>
736        )}
737      </Box>
738    )
739  })
740
741  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
742    // What other mods and Claude Code draw here stays, below the warning.
743    const rest = await next(e)
744    const [target, dismissed, marks] = await Promise.all([read($, current), read($, bandDismissed), read($, protection)])
745    const isProduction = target !== null && protectionOf(target, marks[target.username]).level === 'production'
746    if (e.props.hasSurvey || target === null || !isProduction || dismissed === target.username) {
747      return rest
748    }
749    const { Box, Text, Button } = $.ui.resolve(e)
750
751    return (
752      <Box flexDirection="column">
753        <Box gap={1}>
754          <Text color="error" bold>
755            ⚠ PRODUCTION
756          </Text>
757          <Text>
758            target org: <Text bold>{nameOf(target)}</Text> <Text dimColor>{target.username}</Text>
759          </Text>
760          <Button key="switch" label="Switch" onPress={() => openPanel($)} />
761          <Button key="hide" label="Hide" onPress={() => update($, bandDismissed, () => target.username)} />
762        </Box>
763        {rest}
764      </Box>
765    )
766  })
767}
768
hooks/gradient.ts 46 lines
1// Gradient bars that frame the panel, so what the mod draws stands apart
2// from the rest of the terminal. Pure: each cell is a character and a color.
3
4/** Color stops, by how guarded the target org is, in Neocol's palette: aqua to purple, yellow to salmon, salmon to red. */
5export const PALETTES = {
6  free: ['#00a6a5', '#3d7986', '#372554'],
7  protected: ['#ffc907', '#fb7268'],
8  production: ['#fb7268', '#e5383b', '#372554'],
9} as const
10
11export type Palette = keyof typeof PALETTES
12
13export type Cell = { char: string; color: string; isBold?: boolean }
14
15const rgbOf = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16)) as [number, number, number]
16
17const hexOf = (rgb: number[]) => `#${rgb.map(part => Math.round(part).toString(16).padStart(2, '0')).join('')}`
18
19/** `count` colors running evenly through the stops. */
20export const gradient = (stops: readonly string[], count: number): string[] => {
21  const rgbs = stops.map(rgbOf)
22  if (count <= 1 || rgbs.length === 1) return Array.from({ length: count }, () => hexOf(rgbs[0] ?? [0, 0, 0]))
23
24  return Array.from({ length: count }, (_, i) => {
25    const at = (i / (count - 1)) * (rgbs.length - 1)
26    const from = rgbs[Math.floor(at)] as number[]
27    const to = rgbs[Math.min(Math.ceil(at), rgbs.length - 1)] as number[]
28    const t = at - Math.floor(at)
29    return hexOf(from.map((part, k) => part + ((to[k] as number) - part) * t))
30  })
31}
32
33/** A line `width` wide: `━━ label ━━━…` with the label bold, all of it on the gradient. */
34export const barCells = (width: number, palette: Palette, label?: string): Cell[] => {
35  const text = label === undefined ? '' : ` ${label} `
36  const lead = label === undefined ? 0 : 2
37  const chars = [...'━'.repeat(lead), ...text, ...'━'.repeat(Math.max(0, width - lead - [...text].length))].slice(0, width)
38  const colors = gradient(PALETTES[palette], chars.length)
39
40  return chars.map((char, i) => ({
41    char,
42    color: colors[i] as string,
43    isBold: i >= lead && i < lead + [...text].length,
44  }))
45}
46
hooks/guard.ts 376 lines
1// Pure logic of the production guard: how protected an org is, and which
2// orgs a Bash command would write to. No `$` here, so tests drive it alone.
3
4import type { OrgKind, Override, ProtectionLevel } from '../types'
5
6export type Protection = {
7  level: ProtectionLevel
8  /** Protected by the environment-name rule, not by a mark of the person's. */
9  isAuto: boolean
10  /** A production org the person marked as not really production (demo, SDO). */
11  isDowngraded: boolean
12}
13
14// Names that say "shared environment": uat, qa, staging and so on.
15const ENVIRONMENTS = ['uat', 'qa', 'stg', 'staging', 'preprod', 'full', 'partial']
16
17/** The sandbox suffix of a username (`me@globex.com.globexqa` → `globexqa`), when it has one. */
18const sandboxSuffix = (username: string) => {
19  const labels = username.slice(username.indexOf('@') + 1).split('.')
20  return labels.length > 2 ? labels[labels.length - 1] : undefined
21}
22
23/**
24 * An alias names the environment as a word (`acme-uat`); a sandbox suffix is
25 * often glued to the client (`globexqa`, `initechuat`), so there it may start or end it;
26 * a number after it counts too (`QA3`, `uat2`).
27 */
28export const looksLikeEnvironment = (org: { username: string; alias?: string }) => {
29  const words = (org.alias ?? '').toLowerCase().split(/[^a-z0-9]+/)
30  const suffix = sandboxSuffix(org.username)?.toLowerCase()
31
32  return ENVIRONMENTS.some(
33    env =>
34      words.some(word => word.replace(/\d+$/, '') === env) ||
35      (suffix !== undefined && (suffix.startsWith(env) || suffix.replace(/\d+$/, '').endsWith(env))),
36  )
37}
38
39/** How protected an org is: its kind and name, then the person's mark over them. */
40export const protectionOf = (org: { username: string; alias?: string; kind: OrgKind }, override?: Override): Protection => {
41  if (org.kind === 'production') {
42    return override === undefined
43      ? { level: 'production', isAuto: false, isDowngraded: false }
44      : { level: override, isAuto: false, isDowngraded: true }
45  }
46  if (override !== undefined) return { level: override, isAuto: false, isDowngraded: false }
47  const isAuto = org.kind === 'sandbox' && looksLikeEnvironment(org)
48
49  return { level: isAuto ? 'protected' : 'free', isAuto, isDowngraded: false }
50}
51
52export const LOCK = '🔒'
53
54// --- Reading a Bash command -------------------------------------------------
55
56/** One simple command: its words, quotes removed. */
57type Words = string[]
58
59/**
60 * The shell a command line is written for. PowerShell keeps `\` literal (Windows
61 * paths) and escapes with the backtick, which in bash runs a command instead.
62 */
63export type Shell = 'bash' | 'powershell'
64
65/** Splits a command line into simple commands: `&&`, `||`, `;`, `|`, `&`, newlines, parentheses, `$(`, and in bash backticks. */
66export const splitCommands = (line: string, shell: Shell = 'bash'): Words[] => {
67  const escapeChar = shell === 'powershell' ? '`' : '\\'
68  const commands: Words[] = []
69  let words: Words = []
70  let word = ''
71  let hasWord = false
72  const endWord = () => {
73    if (hasWord) words.push(word)
74    word = ''
75    hasWord = false
76  }
77  const endCommand = () => {
78    endWord()
79    if (words.length > 0) commands.push(words)
80    words = []
81  }
82
83  for (let i = 0; i < line.length; i++) {
84    const c = line[i] as string
85    if (c === "'") {
86      const end = line.indexOf("'", i + 1)
87      word += line.slice(i + 1, end < 0 ? line.length : end)
88      hasWord = true
89      i = end < 0 ? line.length : end
90    } else if (c === '"') {
91      i++
92      while (i < line.length && line[i] !== '"') {
93        if (line[i] === escapeChar && i + 1 < line.length) i++
94        word += line[i]
95        i++
96      }
97      hasWord = true
98    } else if (c === escapeChar && i + 1 < line.length) {
99      // A PowerShell backtick before a newline only continues the line.
100      if (line[i + 1] !== '\n') word += line[i + 1]
101      hasWord = hasWord || line[i + 1] !== '\n'
102      i++
103    } else if (c === ' ' || c === '\t') {
104      endWord()
105    } else if (';&|\n()'.includes(c) || (c === '`' && shell === 'bash') || (c === '$' && line[i + 1] === '(')) {
106      endCommand()
107      if (c === '$') i++
108    } else {
109      word += c
110      hasWord = true
111    }
112  }
113  endCommand()
114
115  return commands
116}
117
118// Words that run the command after them: `sudo sf ...`, `npx sf ...`, PowerShell's `. ./x.ps1`.
119const PREFIXES = new Set(['sudo', 'npx', 'time', 'command', 'exec', 'env', 'nohup', 'xargs', '.'])
120
121/** A program's bare name: `/usr/local/bin/sf` → `sf`, `C:\\nodejs\\sf.cmd` → `sf`. */
122const programName = (word: string) =>
123  (word.split(/[\\/]/).pop() ?? word).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, '')
124
125/** The command line a shell wrapper runs (`bash -c '…'`, `pwsh -Command …`, `cmd /c …`), and how to read it. */
126const wrapped = (words: Words): { line: string; shell: Shell } | undefined => {
127  const name = programName(words[0] ?? '')
128  const rest = words.slice(1)
129  if (['bash', 'sh', 'zsh'].includes(name)) {
130    const at = rest.findIndex(word => /^-[a-z]*c$/.test(word))
131    const line = at >= 0 ? rest[at + 1] : undefined
132    return line === undefined ? undefined : { line, shell: 'bash' }
133  }
134  if (name === 'pwsh' || name === 'powershell') {
135    const at = rest.findIndex(word => /^-(c|command)$/i.test(word))
136    return at < 0 ? undefined : { line: rest.slice(at + 1).join(' '), shell: 'powershell' }
137  }
138  if (name === 'cmd') {
139    const at = rest.findIndex(word => /^\/[ck]$/i.test(word))
140    return at < 0 ? undefined : { line: rest.slice(at + 1).join(' '), shell: 'powershell' }
141  }
142
143  return undefined
144}
145
146/** The words that matter: leading `FOO=bar`, prefixes and shell wrappers peeled off. */
147const commandWords = (words: Words): Words => {
148  let rest = words
149  while (rest.length > 0) {
150    const first = rest[0] as string
151    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first) || PREFIXES.has(first)) rest = rest.slice(1)
152    else break
153  }
154
155  return rest
156}
157
158export type Operation = 'deploy' | 'delete' | 'data' | 'api' | 'apex' | 'package' | 'permset' | 'org-delete' | 'script'
159
160export const OPERATION_LABEL: Record<Operation, string> = {
161  deploy: 'metadata deploy',
162  delete: 'metadata delete',
163  data: 'data write (DML)',
164  api: 'API call that writes',
165  apex: 'anonymous Apex',
166  package: 'package install',
167  permset: 'permission set assignment',
168  'org-delete': 'org deletion',
169  script: 'project script',
170}
171
172/** A write found in a command: what it does and the org it names, if any. */
173export type Write = {
174  operation: Operation
175  /** `-o`/`--target-org` (or the legacy `-u`); absent, the default target org. */
176  target?: string
177  /** The simple command it was found in, for the confirmation. */
178  text: string
179  /**
180   * Anonymous Apex and GraphQL may only read: the code to look at before asking,
181   * from `file` (`--file`, `--body`) or, absent, the whole command line (heredoc, pipe).
182   */
183  code?: { language: 'apex' | 'graphql'; file?: string }
184}
185
186const flagValue = (words: Words, names: string[]): string | undefined => {
187  for (let i = 0; i < words.length; i++) {
188    const word = words[i] as string
189    for (const name of names) {
190      if (word === name) return words[i + 1]
191      if (name.startsWith('--') && word.startsWith(`${name}=`)) return word.slice(name.length + 1)
192      if (!name.startsWith('--') && word.startsWith(name) && word.length > name.length) return word.slice(name.length)
193    }
194  }
195
196  return undefined
197}
198
199const TARGET_FLAGS = ['--target-org', '-o', '--targetusername', '-u']
200
201/** What an `sf`/`sfdx` command writes, from its topic and verb; undefined when it only reads. */
202const sfOperation = (args: Words): Operation | undefined => {
203  const plain = args.filter(word => !word.startsWith('-'))
204  const [topic, verb, object] = plain
205  const isDryRun = args.includes('--dry-run')
206
207  // sfdx legacy colon form: force:source:deploy, force:data:record:create ...
208  if (topic?.includes(':')) {
209    const parts = topic.split(':')
210    if (parts[0] === 'force') parts.shift()
211    const [t, v, w] = parts
212    // `-c` is --checkonly here; in `sf project deploy start` it is --ignore-conflicts.
213    const isCheckOnly = args.includes('--checkonly') || args.includes('-c')
214    if ((t === 'source' || t === 'mdapi') && (v === 'deploy' || v === 'push') && !isCheckOnly) return 'deploy'
215    if (t === 'source' && v === 'delete') return 'delete'
216    if (t === 'data' && (v === 'record' ? w !== 'get' : v === 'bulk' || v === 'tree' && w === 'import')) return 'data'
217    if (t === 'apex' && v === 'execute') return 'apex'
218    if (t === 'package' && v === 'install') return 'package'
219    if (t === 'user' && v === 'permset') return 'permset'
220    if (t === 'org' && v === 'delete') return 'org-delete'
221    return undefined
222  }
223
224  if (topic === 'project' && verb === 'deploy' && ['start', 'quick', 'resume'].includes(object ?? '') && !isDryRun) return 'deploy'
225  if (topic === 'project' && verb === 'delete' && object === 'source') return 'delete'
226  if (topic === 'data' && ['create', 'update', 'delete', 'upsert', 'import', 'bulk'].includes(verb ?? '')) return 'data'
227  // `sf api request rest` is a GET unless told otherwise; GraphQL may carry a mutation.
228  if (topic === 'api' && verb === 'request' && object === 'rest') {
229    const method = flagValue(args, ['--method', '-X'])?.toUpperCase() ?? 'GET'
230    return method === 'GET' || method === 'HEAD' ? undefined : 'api'
231  }
232  if (topic === 'api' && verb === 'request' && object === 'graphql') return 'api'
233  if (topic === 'apex' && verb === 'run' && object !== 'test') return 'apex'
234  if (topic === 'package' && verb === 'install') return 'package'
235  if (topic === 'org' && verb === 'assign') return 'permset'
236  if (topic === 'org' && verb === 'delete') return 'org-delete'
237
238  return undefined
239}
240
241/** Where the code of an anonymous Apex or GraphQL call is, so a read-only one can pass. */
242const codeOf = (operation: Operation, args: Words): Write['code'] => {
243  if (operation === 'apex') return { language: 'apex', file: flagValue(args, ['--file', '-f', '--apexcodefile']) }
244  if (operation === 'api' && args.includes('graphql')) return { language: 'graphql', file: flagValue(args, ['--body']) }
245
246  return undefined
247}
248
249/** Code without comments and string literals, so a word in a string or a comment counts for nothing. */
250const bareCode = (code: string) =>
251  code.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ').replace(/'(?:\\.|[^'\\])*'/g, "''")
252
253// Apex types whose static calls and constructors never write.
254// Their writing methods (`Database.insert`, `System.enqueueJob`) are in APEX_WRITES.
255const APEX_READ_TYPES = new Set([
256  'system', 'database', 'string', 'integer', 'long', 'decimal', 'double', 'boolean', 'date', 'datetime', 'time', 'id',
257  'blob', 'list', 'set', 'map', 'json', 'jsongenerator', 'jsonparser', 'schema', 'limits', 'userinfo', 'math', 'url',
258  'encodingutil', 'crypto', 'pattern', 'matcher', 'sobjecttype', 'sobjectfield', 'object', 'exception', 'organization',
259])
260// Standard objects a read script may build without saving them.
261const SOBJECTS = new Set(['account', 'contact', 'lead', 'opportunity', 'case', 'user', 'product2', 'pricebookentry', 'quote', 'order', 'contract', 'task', 'event'])
262
263const APEX_WRITES = [
264  // DML statements: `insert acc;`, `update new Account(...)`, `delete [SELECT ...]`.
265  /\b(insert|update|upsert|delete|undelete|merge)\s+[\w([]/i,
266  /\bdatabase\s*\.\s*(insert|update|upsert|delete|undelete|merge|convertlead|emptyrecyclebin|executebatch|rollback|setsavepoint)\w*\s*\(/i,
267  /\b(eventbus\s*\.\s*publish|system\s*\.\s*(enqueuejob|schedule|schedulebatch|setpassword|resetpassword)|messaging\s*\.\s*send\w*|approval\s*\.\s*(process|lock|unlock))\b/i,
268  /\bnew\s+http\s*\(/i,
269]
270
271/**
272 * Whether anonymous Apex only reads: no DML, no async or callout, and no call
273 * into the org's own classes, which may write behind a name.
274 */
275export const apexOnlyReads = (code: string) => {
276  const bare = bareCode(code)
277  if (APEX_WRITES.some(write => write.test(bare))) return false
278  const own = (name: string) => !APEX_READ_TYPES.has(name.toLowerCase()) && !/__(c|mdt|e)$/i.test(name)
279  const statics = [...bare.matchAll(/\b([A-Z]\w*)\s*\.\s*\w+\s*\(/g)].map(match => match[1] as string)
280  const created = [...bare.matchAll(/\bnew\s+([A-Za-z]\w*)\s*\(/g)].map(match => match[1] as string)
281
282  return !statics.some(own) && !created.some(name => own(name) && !SOBJECTS.has(name.toLowerCase()))
283}
284
285
286/** Whether a GraphQL document only queries. */
287export const graphqlOnlyReads = (code: string) => !/\bmutation\b/i.test(code)
288
289const SCRIPT_NAME = /deploy|push|release|data|seed|apex|migrate/i
290
291/** The name of a project script a command runs (`npm run deploy:uat`, `./scripts/deploy.sh`), if any. */
292const scriptName = (words: Words): string | undefined => {
293  const [first, second, third] = words
294  if ((first === 'npm' || first === 'pnpm') && (second === 'run' || second === 'run-script')) return third
295  if (first === 'yarn') return second === 'run' ? third : second
296  if (first === 'make') return second ?? 'make'
297  const file = first !== undefined && ['bash', 'sh', 'zsh', 'pwsh', 'powershell'].includes(programName(first)) ? second : first
298  if (file !== undefined && /\.(sh|ps1|cmd|bat)$/i.test(file)) return file.split(/[\\/]/).pop()
299
300  return undefined
301}
302
303/** Every write a shell command would make to a Salesforce org, unwrapping `bash -c` and the like. */
304export const findWrites = (line: string, shell: Shell = 'bash', depth = 0): Write[] =>
305  splitCommands(line, shell).flatMap(raw => {
306    const words = commandWords(raw)
307    const [first] = words
308    if (first === undefined) return []
309    const text = raw.join(' ')
310    const inner = depth < 3 ? wrapped(words) : undefined
311    if (inner !== undefined) return findWrites(inner.line, inner.shell, depth + 1)
312    const name = programName(first)
313    if (name === 'sf' || name === 'sfdx') {
314      const args = words.slice(1)
315      const operation = sfOperation(args)
316      return operation ? [{ operation, target: flagValue(args, TARGET_FLAGS), text, code: codeOf(operation, args) }] : []
317    }
318    const script = scriptName(words)
319    if (script !== undefined && SCRIPT_NAME.test(script)) return [{ operation: 'script' as const, text }]
320
321    return []
322  })
323
324const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
325// Programs that run code we cannot read: naming a protected org there may write.
326const INTERPRETERS = new Set(['node', 'python', 'python3', 'ruby', 'perl', 'deno', 'bun', 'tsx', 'ts-node', 'php', 'java', 'jshell'])
327
328/** Whether an HTTP client call sends a method other than a read, or a body. */
329const httpWrites = (name: string, args: Words): boolean => {
330  const writesBy = (method: string | undefined) => method !== undefined && !READ_METHODS.has(method.toUpperCase())
331  if (name === 'curl') {
332    const clustered = args.findIndex(word => /^-[a-zA-Z]*X$/.test(word))
333    const method = flagValue(args, ['--request', '-X']) ?? (clustered >= 0 ? args[clustered + 1] : undefined)
334    return writesBy(method) || args.some(word => /^(-[a-zA-Z]*[dFT]|--data.*|--form.*|--json|--upload-file)$/.test(word.split('=')[0] as string))
335  }
336  if (name === 'wget') return writesBy(flagValue(args, ['--method'])) || args.some(word => /^--(post|body)-(data|file)/.test(word))
337  if (['http', 'https', 'xh'].includes(name)) {
338    const plain = args.filter(word => !word.startsWith('-'))
339    return writesBy(/^[A-Za-z]+$/.test(plain[0] ?? '') ? plain[0] : undefined) || plain.some(word => /^[^=:]+(:=|=)(?!=)/.test(word) && !word.includes('=='))
340  }
341  if (['invoke-restmethod', 'invoke-webrequest', 'irm', 'iwr'].includes(name)) {
342    return writesBy(flagValue(args.map(word => word.toLowerCase()), ['-method'])) || args.some(word => /^-body$/i.test(word))
343  }
344
345  return false
346}
347
348/**
349 * The simple commands that are not `sf`/`sfdx` and may write, where a protected
350 * org named by hand is a write we cannot read: an HTTP client sending a body or
351 * a writing method, or an interpreter running code. Reads (`cat`, `grep`, a GET) pass.
352 */
353export const otherCommands = (line: string, shell: Shell = 'bash', depth = 0): string[] =>
354  splitCommands(line, shell).flatMap(raw => {
355    const words = commandWords(raw)
356    const [first] = words
357    if (first === undefined) return []
358    const inner = depth < 3 ? wrapped(words) : undefined
359    if (inner !== undefined) return otherCommands(inner.line, inner.shell, depth + 1)
360    const name = programName(first)
361
362    return INTERPRETERS.has(name) || httpWrites(name, words.slice(1)) ? [raw.join(' ')] : []
363  })
364
365const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
366
367/** Whether a text names an org: its alias as a whole word, its username, or its instance host. */
368export const mentions = (text: string, org: { username: string; alias?: string; instanceUrl?: string }) => {
369  const lower = text.toLowerCase()
370  if (lower.includes(org.username.toLowerCase())) return true
371  const host = org.instanceUrl?.replace(/^https?:\/\//, '').replace(/\/.*$/, '').toLowerCase()
372  if (host && lower.includes(host)) return true
373
374  return org.alias !== undefined && new RegExp(`(^|[^\\w.-])${escape(org.alias.toLowerCase())}($|[^\\w.-])`).test(lower)
375}
376
hooks/org.ts 226 lines
1// Pure logic: which org the sf CLI targets from a directory, what kind it is,
2// and which orgs belong to a project. No `$` here, so tests drive it with
3// in-memory files.
4
5import type { OrgInfo, OrgKind, OrgRow, TargetOrg } from '../types'
6
7/** Reads a file's text, or undefined when it is missing or unreadable. */
8export type Reader = (path: string) => Promise<string | undefined>
9
10export type Context = {
11  cwd: string
12  home: string
13  /** SF_TARGET_ORG, or the legacy SFDX_DEFAULTUSERNAME. */
14  envTarget?: string
15}
16
17export type AuthFile = {
18  username?: string
19  instanceUrl?: string
20  loginUrl?: string
21  isScratch?: boolean
22  isSandbox?: boolean
23  devHubUsername?: string
24}
25
26export const parse = <T>(text: string | undefined): T | undefined => {
27  if (text === undefined) return undefined
28  try {
29    return JSON.parse(text) as T
30  } catch {
31    return undefined
32  }
33}
34
35// Paths may be POSIX (`/work/app`) or Windows (`C:\\work\\app`): both separators count.
36const isRoot = (dir: string) => /^([A-Za-z]:)?[\\/]*$/.test(dir)
37
38export const parentOf = (dir: string) => {
39  const trimmed = dir.replace(/[\\/]+$/, '')
40  const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
41  if (cut < 0) return dir
42  const parent = trimmed.slice(0, cut)
43  if (parent === '') return dir[0] === '\\' ? '\\' : '/'
44
45  return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent
46}
47
48export const baseName = (dir: string) => dir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || dir
49
50/** The nearest directory at or above `cwd` holding sfdx-project.json. */
51export const findProjectRoot = async (read: Reader, cwd: string) => {
52  for (let dir = cwd; ; dir = parentOf(dir)) {
53    if ((await read(`${dir.replace(/[\\/]+$/, '')}/sfdx-project.json`)) !== undefined) return dir
54    if (isRoot(dir) || parentOf(dir) === dir) return undefined
55  }
56}
57
58/** target-org from a `.sf/config.json`, else defaultusername from the legacy file. */
59const targetIn = async (read: Reader, dir: string) =>
60  parse<Record<string, string>>(await read(`${dir}/.sf/config.json`))?.['target-org'] ??
61  parse<Record<string, string>>(await read(`${dir}/.sfdx/sfdx-config.json`))?.defaultusername
62
63export const aliasesIn = (text: string | undefined) =>
64  parse<{ orgs?: Record<string, string> }>(text)?.orgs ?? {}
65
66export const classify = (auth: AuthFile | undefined): OrgKind => {
67  if (auth === undefined) return 'unknown'
68  const url = auth.instanceUrl ?? ''
69  if (auth.isScratch === true || /\.scratch\.my\.salesforce\.com/.test(url)) return 'scratch'
70  if (
71    auth.isSandbox === true ||
72    /\.sandbox\.my\.salesforce\.com|--/.test(url) ||
73    /test\.salesforce\.com/.test(auth.loginUrl ?? '')
74  )
75    return 'sandbox'
76  if (/\.develop\.my\.salesforce\.com|-dev-ed\./.test(url)) return 'developer'
77  return 'production'
78}
79
80/**
81 * The org the sf CLI would target from `cwd`, or undefined outside a
82 * Salesforce DX project or when nothing is set.
83 */
84export const resolveTargetOrg = async (
85  read: Reader,
86  { cwd, home, envTarget }: Context,
87): Promise<TargetOrg | undefined> => {
88  const root = await findProjectRoot(read, cwd)
89  if (root === undefined) return undefined
90
91  let target: string | undefined
92  let source: TargetOrg['source']
93  if (envTarget) {
94    target = envTarget
95    source = 'env'
96  } else if ((target = await targetIn(read, root))) {
97    source = 'project'
98  } else if ((target = await targetIn(read, home))) {
99    source = 'global'
100  } else {
101    return undefined
102  }
103
104  const aliases = aliasesIn(await read(`${home}/.sfdx/alias.json`))
105  const username = aliases[target] ?? target
106  const alias = aliases[target] !== undefined ? target : undefined
107  const auth = parse<AuthFile>(await read(`${home}/.sfdx/${username}.json`))
108
109  return { target, alias, username, kind: classify(auth), instanceUrl: auth?.instanceUrl, source }
110}
111
112// Labels that end a company domain inside a username (`globex.com.globexqa`).
113const TLDS = new Set(['com', 'org', 'net', 'io', 'co', 'dev', 'ai', 'app', 'br'])
114// Domains every scratch org shares, which say nothing about the client.
115const GENERIC = new Set(['example.com', 'example.org', 'salesforce.com'])
116
117/** The company domain of a username: `oscar@globex.com.globexqa` → `globex.com`. */
118export const domainOf = (username: string) => {
119  const labels = username.slice(username.indexOf('@') + 1).toLowerCase().split('.')
120  const end = labels.findIndex((label, i) => i > 0 && TLDS.has(label))
121  if (end < 0) return undefined
122  const last = labels[end + 1] === 'br' ? end + 1 : end
123  const domain = labels.slice(0, last + 1).join('.')
124  return GENERIC.has(domain) ? undefined : domain
125}
126
127/** One row per auth file, its alias the first one pointing at it. */
128export const buildCatalog = (auths: AuthFile[], aliases: Record<string, string>): OrgRow[] => {
129  const firstAlias = new Map<string, string>()
130  for (const [alias, username] of Object.entries(aliases)) {
131    if (alias !== 'DEFAULT_TARGET_ORG' && !firstAlias.has(username)) firstAlias.set(username, alias)
132  }
133
134  return auths
135    .filter((auth): auth is AuthFile & { username: string } => typeof auth.username === 'string')
136    .map(auth => ({
137      username: auth.username,
138      alias: firstAlias.get(auth.username),
139      kind: classify(auth),
140      instanceUrl: auth.instanceUrl,
141      domain: domainOf(auth.devHubUsername ?? auth.username),
142    }))
143    .sort((a, b) =>
144      `${a.domain ?? '~'} ${a.alias ?? a.username}`.localeCompare(`${b.domain ?? '~'} ${b.alias ?? b.username}`),
145    )
146}
147
148/** The project's orgs when none were saved: the current org's domain mates. */
149export const inferProjectOrgs = (catalog: OrgRow[], current: TargetOrg | undefined): OrgRow[] => {
150  if (current === undefined) return []
151  const domain = catalog.find(row => row.username === current.username)?.domain ?? domainOf(current.username)
152  const mates = domain === undefined ? [] : catalog.filter(row => row.domain === domain)
153  const hasCurrent = mates.some(row => row.username === current.username)
154
155  return hasCurrent
156    ? mates
157    : [
158        { username: current.username, alias: current.alias, kind: current.kind, instanceUrl: current.instanceUrl, domain },
159        ...mates,
160      ]
161}
162
163/** The rows of a saved list, in its order, kept even when no longer authenticated. */
164export const savedProjectOrgs = (catalog: OrgRow[], usernames: string[]): OrgRow[] =>
165  usernames.map(
166    username => catalog.find(row => row.username === username) ?? { username, kind: 'unknown' as const },
167  )
168
169/** Info from `sf org display --json` and an Organization query's `--json`. */
170export const parseInfo = (displayJson: string, queryJson: string | undefined): OrgInfo => {
171  const display = parse<{ status?: number; message?: string; result?: Record<string, unknown> }>(displayJson)
172  if (display?.result === undefined) {
173    return { status: 'error', message: display?.message ?? 'sf org display falhou' }
174  }
175  const org = parse<{ result?: { records?: Record<string, unknown>[] } }>(queryJson)?.result?.records?.[0]
176  const text = (value: unknown) => (typeof value === 'string' && value !== '' ? value : undefined)
177
178  return {
179    status: 'ok',
180    orgId: text(display.result.id),
181    apiVersion: text(display.result.apiVersion),
182    connectedStatus: text(display.result.connectedStatus),
183    edition: text(org?.OrganizationType),
184    pod: text(org?.InstanceName),
185    expires: text(display.result.expirationDate) ?? text(org?.TrialExpirationDate)?.slice(0, 10),
186  }
187}
188
189export const BADGE: Record<OrgKind, string> = {
190  production: '🔴 PROD',
191  sandbox: '🟡 sandbox',
192  scratch: '🟢 scratch',
193  developer: '🔵 dev-ed',
194  unknown: '⚪ not authenticated',
195}
196
197/** The badge with the guard's marks: `🟡🔒 sandbox`, `🔴 PROD↓` for a downgraded production. */
198export const badgeOf = (kind: OrgKind, protection?: { level: string; isDowngraded: boolean }) => {
199  const [emoji, ...rest] = BADGE[kind].split(' ')
200  const lock = protection?.level === 'protected' ? '🔒' : ''
201  const label = rest.join(' ') + (protection?.isDowngraded ? '↓' : '')
202
203  return `${emoji}${lock} ${label}`
204}
205
206export const statusText = (org: TargetOrg, protection?: { level: string; isDowngraded: boolean }) =>
207  `SF ${badgeOf(org.kind, protection)} · ${org.alias ?? org.username}`
208
209export const SOURCE_LABEL: Record<TargetOrg['source'], string> = {
210  env: 'SF_TARGET_ORG variable',
211  project: "the project's .sf/config.json",
212  global: '~/.sf/config.json',
213}
214
215export const explain = (org: TargetOrg | undefined) =>
216  org === undefined
217    ? 'No target org: outside a Salesforce DX project, or no target-org set.'
218    : [
219        `Target org: ${org.alias ?? org.username} (${org.kind})`,
220        `Username: ${org.username}`,
221        org.instanceUrl && `Instance: ${org.instanceUrl}`,
222        `Source: ${SOURCE_LABEL[org.source]}`,
223      ]
224        .filter(Boolean)
225        .join('\n')
226
types/index.d.ts 86 lines
1export type OrgKind = 'production' | 'sandbox' | 'scratch' | 'developer' | 'unknown'
2
3/** The org the sf CLI targets from the session's directory. */
4export type TargetOrg = {
5  /** What the config named: an alias or a username. */
6  target: string
7  alias?: string
8  username: string
9  kind: OrgKind
10  instanceUrl?: string
11  /** Where the target came from, in the sf CLI's precedence order. */
12  source: 'env' | 'project' | 'global'
13}
14
15/** One authenticated org, from its auth file in ~/.sfdx. */
16export type OrgRow = {
17  username: string
18  /** The alias shown: the first one pointing at this username. */
19  alias?: string
20  kind: OrgKind
21  instanceUrl?: string
22  /** The company domain the username belongs to (`globex.com`), when known. */
23  domain?: string
24}
25
26export type OrgInfo =
27  | { status: 'loading' }
28  | { status: 'error'; message: string }
29  | {
30      status: 'ok'
31      orgId?: string
32      apiVersion?: string
33      connectedStatus?: string
34      edition?: string
35      pod?: string
36      expires?: string
37    }
38
39/** What the panel shows for the project the session is in. */
40export type Panel = {
41  root: string
42  name: string
43  /** The project's orgs: saved for this project, else inferred by domain. */
44  orgs: OrgRow[]
45  listSource: 'saved' | 'inferred'
46  /** Every authenticated org, for editing the list. */
47  catalog: OrgRow[]
48}
49
50/** How a write to an org is guarded: free, a click, or the typed alias. */
51export type ProtectionLevel = 'free' | 'protected' | 'production'
52
53/** The person's mark on an org, over its kind and name. */
54export type Override = 'protected' | 'free'
55
56/** A production write waiting for the person to type the org's alias. */
57export type ProdAsk = {
58  name: string
59  username: string
60  operation: string
61  command: string
62}
63
64declare module 'claude-code' {
65  interface PluginState {
66    'og-sf-org-status': {
67      current: TargetOrg | null
68      panel: Panel | null
69      /** The username highlighted in the panel. */
70      selected: string | null
71      /** Usernames checked while editing the list; null when not editing. */
72      editing: string[] | null
73      info: Record<string, OrgInfo>
74      message: string | null
75      /** The person's marks, by username: the store's copy, for drawing. */
76      protection: Record<string, Override>
77      /** The production write on screen, waiting for the typed alias. */
78      prodAsk: ProdAsk | null
79      /** The production org being downgraded in the panel, waiting for its alias. */
80      downgrading: string | null
81      /** The production username whose warning band was dismissed. */
82      bandDismissed: string | null
83    }
84  }
85}
86