mirror of
https://github.com/payloadcms/payload.git
synced 2026-09-14 20:07:19 +08:00
chore: add run-mechanical-steps option to upgrade dispatch picker
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user