chore: add run-mechanical-steps option to upgrade dispatch picker

This commit is contained in:
Elliot DeNolf
2026-09-01 16:02:30 -04:00
parent e170e248e1
commit 301a482be6
6 changed files with 89 additions and 20 deletions
+3 -2
View File
@@ -30,10 +30,11 @@ The `upgrade` command has three verbs.
- Hand the orchestration prompt to a detected agent. The agent runs the whole sequence, calling
`upgrade run` for the mechanical slice as one step.
- Run the mechanical steps here, the same work as `upgrade run`.
- Just print the prompt to run it yourself or paste it elsewhere.
Pin an agent with `--agent <claude|codex>` to skip the picker. When there is no TTY (CI) or no
agent is installed, it prints the prompt instead of prompting.
Pin an agent with `--agent <claude|codex>` to skip the picker. When there is no TTY (CI), it prints
the prompt instead of prompting.
### `upgrade run` (mechanical slice)
+5 -1
View File
@@ -37,7 +37,11 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
return
}
const { failed } = await runDispatch({ agentFlag: flags.agent, path: flags.path })
const { failed } = await runDispatch({
agentFlag: flags.agent,
path: flags.path,
upgradeFlags: { dry: flags.dry, force: flags.force, tag: flags.tag ?? 'canary' },
})
if (failed) {
process.exitCode = 1
}
+41 -4
View File
@@ -9,12 +9,15 @@ import { AGENTS } from './selectAgent.js'
const claude = AGENTS.find((a) => a.id === 'claude')!
const FLAGS = { dry: false, force: false, tag: 'canary' }
function makeDeps(overrides: Partial<DispatchDeps> = {}): DispatchDeps {
return {
detectAgents: () => [claude],
isInteractive: true,
promptChoice: async () => ({ agent: claude, kind: 'agent' }),
renderPrompt: () => 'PROMPT-TEXT',
runMechanical: async () => ({ failed: false }),
spawnAgent: async () => ({ code: 0 }),
writePromptFile: () => '/tmp/prompt.md',
...overrides,
@@ -28,19 +31,53 @@ describe('runDispatch', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const spawnAgent = vi.fn()
const result = await runDispatch({ path: '.' }, makeDeps({ isInteractive: false, spawnAgent }))
const result = await runDispatch(
{ path: '.', upgradeFlags: FLAGS },
makeDeps({ isInteractive: false, spawnAgent }),
)
expect(result).toEqual({ failed: false })
expect(log).toHaveBeenCalledWith('PROMPT-TEXT')
expect(spawnAgent).not.toHaveBeenCalled()
})
it('runs the mechanical slice and does not spawn or print when the choice is run', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const runMechanical = vi.fn(async () => ({ failed: false }))
const spawnAgent = vi.fn()
const result = await runDispatch(
{ path: './app', upgradeFlags: FLAGS },
makeDeps({ promptChoice: async () => ({ kind: 'run' }), runMechanical, spawnAgent }),
)
expect(runMechanical).toHaveBeenCalledWith({ flags: FLAGS, path: './app' })
expect(spawnAgent).not.toHaveBeenCalled()
expect(log).not.toHaveBeenCalledWith('PROMPT-TEXT')
expect(result).toEqual({ failed: false })
})
it('propagates a mechanical-slice failure', async () => {
const result = await runDispatch(
{ path: '.', upgradeFlags: FLAGS },
makeDeps({
promptChoice: async () => ({ kind: 'run' }),
runMechanical: async () => ({ failed: true }),
}),
)
expect(result).toEqual({ failed: true })
})
it('writes the prompt file and spawns the chosen agent with the resolved cwd', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined)
const writePromptFile = vi.fn(() => '/tmp/prompt.md')
const spawnAgent = vi.fn(async () => ({ code: 0 }))
const result = await runDispatch({ path: './app' }, makeDeps({ spawnAgent, writePromptFile }))
const result = await runDispatch(
{ path: './app', upgradeFlags: FLAGS },
makeDeps({ spawnAgent, writePromptFile }),
)
expect(writePromptFile).toHaveBeenCalledWith('PROMPT-TEXT')
expect(spawnAgent).toHaveBeenCalledWith({
@@ -55,7 +92,7 @@ describe('runDispatch', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined)
const result = await runDispatch(
{ path: '.' },
{ path: '.', upgradeFlags: FLAGS },
makeDeps({ spawnAgent: async () => ({ code: 2 }) }),
)
@@ -69,7 +106,7 @@ describe('runDispatch', () => {
const detectAgents = (): Agent[] => [claude]
const result = await runDispatch(
{ agentFlag: 'codex', path: '.' },
{ agentFlag: 'codex', path: '.', upgradeFlags: FLAGS },
makeDeps({ detectAgents, spawnAgent }),
)
+22 -5
View File
@@ -7,13 +7,17 @@ import { join, resolve } from 'node:path'
import type { Agent, DispatchChoice } from './selectAgent.js'
import { runUpgrade } from './index.js'
import { renderUpgradePrompt } from './prompt.js'
import { detectInstalledAgents, selectDispatch } from './selectAgent.js'
import { resolveSelfCommand } from './selfCommand.js'
type UpgradeFlags = Parameters<typeof runUpgrade>[0]['flags']
type RunDispatchArgs = {
agentFlag?: string
path: string
upgradeFlags: UpgradeFlags
}
export type DispatchDeps = {
@@ -21,6 +25,7 @@ export type DispatchDeps = {
isInteractive: boolean
promptChoice: (agents: Agent[]) => Promise<DispatchChoice>
renderPrompt: () => string
runMechanical: (args: { flags: UpgradeFlags; path: string }) => Promise<{ failed: boolean }>
spawnAgent: (args: { agent: Agent; cwd: string; promptFilePath: string }) => Promise<{
code: number
}>
@@ -29,12 +34,11 @@ export type DispatchDeps = {
/**
* Bare `upgrade`: pick how to run the full v3 -> v4 upgrade. Hands the
* orchestration prompt to a detected coding agent, or prints it for manual use.
* The mechanical slice itself runs later, when the agent (or the user) invokes
* `upgrade run` per the prompt's step 2.
* orchestration prompt to a detected coding agent, runs the mechanical slice
* here, or prints the prompt for manual use.
*/
export async function runDispatch(
{ agentFlag, path }: RunDispatchArgs,
{ agentFlag, path, upgradeFlags }: RunDispatchArgs,
deps: DispatchDeps = defaultDispatchDeps(),
): Promise<{ failed: boolean }> {
const installed = deps.detectAgents()
@@ -52,6 +56,10 @@ export async function runDispatch(
return { failed: true }
}
if (choice.kind === 'run') {
return deps.runMechanical({ flags: upgradeFlags, path })
}
const promptText = deps.renderPrompt()
if (choice.kind === 'print') {
@@ -75,6 +83,7 @@ function defaultDispatchDeps(): DispatchDeps {
isInteractive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
promptChoice: promptChoiceFromTerminal,
renderPrompt: () => renderUpgradePrompt({ command: resolveSelfCommand() }),
runMechanical: (args) => runUpgrade(args),
spawnAgent: spawnAgentInteractive,
writePromptFile: (contents) => {
const dir = mkdtempSync(join(tmpdir(), 'payload-v4-upgrade-'))
@@ -85,7 +94,7 @@ function defaultDispatchDeps(): DispatchDeps {
}
}
/** Clack select: each installed agent, then "print the prompt". */
/** Clack select: each installed agent, then run the slice or print the prompt. */
async function promptChoiceFromTerminal(agents: Agent[]): Promise<DispatchChoice> {
p.intro('Payload v3 -> v4 upgrade')
const selection = await p.select({
@@ -96,6 +105,11 @@ async function promptChoiceFromTerminal(agents: Agent[]): Promise<DispatchChoice
label: agent.label,
value: agent.id as string,
})),
{
hint: 'pin versions, install, run the codemods now',
label: 'Run the mechanical steps',
value: 'run',
},
{
hint: 'run it yourself or paste it elsewhere',
label: 'Just print the prompt',
@@ -108,6 +122,9 @@ async function promptChoiceFromTerminal(agents: Agent[]): Promise<DispatchChoice
if (p.isCancel(selection)) {
return { kind: 'print' }
}
if (selection === 'run') {
return { kind: 'run' }
}
const agent = agents.find((a) => a.id === selection)
return agent ? { agent, kind: 'agent' } : { kind: 'print' }
}
@@ -53,14 +53,20 @@ describe('selectDispatch', () => {
expect(choice).toEqual({ kind: 'print' })
})
it('falls back to printing when no agents are installed', async () => {
it('opens the picker even when no agents are installed, so run or print stay reachable', async () => {
const prompt = vi.fn(async (agents: Agent[]) => {
expect(agents).toEqual([])
return { kind: 'run' } as DispatchChoice
})
const choice = await selectDispatch({
installed: [],
isInteractive: true,
prompt: neverPrompt,
prompt,
})
expect(choice).toEqual({ kind: 'print' })
expect(prompt).toHaveBeenCalledOnce()
expect(choice).toEqual({ kind: 'run' })
})
it('opens the picker when interactive with at least one agent', async () => {
+9 -5
View File
@@ -16,8 +16,11 @@ export const AGENTS: Agent[] = [
{ id: 'codex', command: 'codex', label: 'Codex' },
]
/** Hand the prompt to `agent`, or print it to stdout for manual use. */
export type DispatchChoice = { agent: Agent; kind: 'agent' } | { kind: 'print' }
/**
* Hand the prompt to `agent`, run the mechanical slice here, or print the
* prompt to stdout for manual use.
*/
export type DispatchChoice = { agent: Agent; kind: 'agent' } | { kind: 'print' } | { kind: 'run' }
export type SelectDispatchArgs = {
/** Value of `--agent`, if the user pinned one. */
@@ -33,8 +36,9 @@ export type SelectDispatchArgs = {
/**
* Decide how to dispatch the upgrade, kept pure so the branching is unit-tested
* without a terminal. `--agent` wins and is strict (unknown or not-installed
* throws). Otherwise a non-interactive session or an empty install set falls
* back to printing the prompt; only a TTY with at least one agent opens the picker.
* throws). A non-interactive session falls back to printing the prompt. Any TTY
* opens the picker, which offers running the mechanical slice or printing even
* when no agent is installed.
*/
export async function selectDispatch({
agentFlag,
@@ -57,7 +61,7 @@ export async function selectDispatch({
return { agent: known, kind: 'agent' }
}
if (!isInteractive || installed.length === 0) {
if (!isInteractive) {
return { kind: 'print' }
}