diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index 8c6c5cd..c942491 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -147,3 +147,51 @@ test('connectIntegrations fails fast on auth errors without prompting to connect assert.ok(io.messages.some((message) => message.level === 'warn' && message.message.includes('failed to check connection status for notion'))); assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('auth failed'))); }); + +test('connectIntegrations honors --no-prompt for subscription provider setup', async () => { + const io = createBufferedIO(); + let confirmCalled = false; + let subscriptionConnectCalled = false; + io.confirm = async () => { + confirmCalled = true; + return true; + }; + + await assert.rejects( + connectIntegrations({ + persona: { + id: 'essay', + intent: 'essay', + description: 'test persona', + tags: ['implementation'], + useSubscription: true, + integrations: {} + } as never, + workspace: 'ws-1', + noConnect: false, + noPrompt: true, + io, + integrations: { + async isConnected() { + throw new Error('no integration checks expected'); + }, + async connect() { + throw new Error('no integration connects expected'); + } + }, + subscription: { + async isConnected() { + return false; + }, + async connect() { + subscriptionConnectCalled = true; + return { provider: 'anthropic' }; + } + } + }), + /--no-prompt was passed/ + ); + + assert.equal(confirmCalled, false); + assert.equal(subscriptionConnectCalled, false); +}); diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 31a1620..5110fce 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -152,6 +152,7 @@ export interface ConnectAllInput { persona: PersonaSpec; workspace: string; noConnect: boolean; + noPrompt?: boolean; io: DeployIO; integrations: IntegrationConnectResolver; /** Required only when persona.useSubscription is true. */ @@ -173,6 +174,8 @@ export interface ConnectAllResult { * Behavior summary: * - integrations: {} or undefined → returns immediately, no prompts * - already-connected provider → no prompt; emits `already-connected` + * - auth failure while checking status → fails without prompting + * - not connected + noPrompt=true → fails immediately without prompting * - not connected + noConnect=true → fails the deploy with a clear message * - not connected + noConnect=false → prompts; on yes runs `connect`, * on no marks `skipped`. The orchestrator decides what to do with @@ -210,6 +213,18 @@ export async function connectIntegrations(input: ConnectAllInput): Promise false); if (!isConn) { + if (input.noPrompt) { + throw new Error( + 'persona requires a subscription provider connection, but --no-prompt was passed. Connect it before deploying or run without --no-prompt.' + ); + } if (input.noConnect) { throw new Error( 'persona requires a subscription provider connection, but --no-connect was passed' diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 5343723..11867e4 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -78,6 +78,46 @@ async function withWorkspaceEnv( } } +function successfulBundleStager(): BundleStager { + return { + async stage(input) { + await mkdir(input.outDir, { recursive: true }); + const runner = path.join(input.outDir, 'runner.mjs'); + const bundle = path.join(input.outDir, 'agent.bundle.mjs'); + const personaCopy = path.join(input.outDir, 'persona.json'); + const pkg = path.join(input.outDir, 'package.json'); + await Promise.all([ + writeFile(runner, '', 'utf8'), + writeFile(bundle, '', 'utf8'), + writeFile(personaCopy, '{}', 'utf8'), + writeFile(pkg, '{}', 'utf8') + ]); + return { + runnerPath: runner, + bundlePath: bundle, + personaCopyPath: personaCopy, + packageJsonPath: pkg, + sizeBytes: 1 + }; + } + }; +} + +function successfulDevLauncher(onLaunch?: () => void): ModeLauncher { + return { + async launch() { + onLaunch?.(); + return { + id: 'pid-1', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + }; +} + test('preflightPersona accepts a valid deploy-shaped persona', async () => { const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); try { @@ -189,6 +229,143 @@ test('deploy fails clearly when integration is not connected and --no-connect is } }); +test('deploy connects each missing persona integration before launch', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ integrations: { github: {}, notion: {} } }) + ); + const io = createBufferedIO(); + const checked: string[] = []; + const connected: string[] = []; + let launched = false; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected({ provider }) { + checked.push(provider); + return false; + }, + async connect({ provider }) { + connected.push(provider); + return { connectionId: `conn-${provider}` }; + } + }; + + try { + const result = await deploy( + { personaPath, mode: 'dev', io }, + { + workspaceAuth, + integrations, + bundle: successfulBundleStager(), + modes: { dev: successfulDevLauncher(() => { launched = true; }) } + } + ); + + assert.deepEqual(checked, ['github', 'notion']); + assert.deepEqual(connected, ['github', 'notion']); + assert.deepEqual(result.connectedIntegrations, ['github', 'notion']); + assert.equal(launched, true); + } finally { + await cleanup(); + } +}); + +test('deploy aborts cleanly when one missing integration connect fails', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ integrations: { github: {}, notion: {} } }) + ); + const io = createBufferedIO(); + const connected: string[] = []; + let launched = false; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected() { + return false; + }, + async connect({ provider }) { + connected.push(provider); + if (provider === 'notion') { + throw new Error('notion oauth unavailable'); + } + return { connectionId: `conn-${provider}` }; + } + }; + + try { + await assert.rejects( + deploy( + { personaPath, mode: 'dev', io }, + { + workspaceAuth, + integrations, + bundle: successfulBundleStager(), + modes: { dev: successfulDevLauncher(() => { launched = true; }) } + } + ), + /deploy aborted: 1 integration\(s\) failed to connect: notion/ + ); + assert.deepEqual(connected, ['github', 'notion']); + assert.equal(launched, false); + assert.ok( + io.messages.find( + (m) => m.level === 'error' && m.message.includes('integrations.notion: connect failed: notion oauth unavailable') + ) + ); + } finally { + await cleanup(); + } +}); + +test('deploy treats --no-prompt as fail-fast for missing integration connects', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ integrations: { github: {}, notion: {} } }) + ); + const io = createBufferedIO(); + const checked: string[] = []; + let connectCalled = false; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected({ provider }) { + checked.push(provider); + return false; + }, + async connect() { + connectCalled = true; + throw new Error('connect should not be called when --no-prompt is set'); + } + }; + + try { + await assert.rejects( + deploy( + { personaPath, mode: 'dev', noPrompt: true, io }, + { workspaceAuth, integrations } + ), + /deploy aborted: 1 integration\(s\) failed to connect: github/ + ); + assert.deepEqual(checked, ['github']); + assert.equal(connectCalled, false); + assert.ok( + io.messages.find( + (m) => m.level === 'error' && m.message.includes('--no-prompt was passed') + ) + ); + } finally { + await cleanup(); + } +}); + test('deploy stages a bundle and hands off to the resolved launcher', async () => { const { personaPath, dir, cleanup } = await withTempPersona(basePersonaJson()); const io = createBufferedIO(); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 36feffb..28b62f7 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -143,6 +143,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { persona: preflight.persona, workspace, noConnect: opts.noConnect === true, + ...(opts.noPrompt ? { noPrompt: true } : {}), io, integrations: resolvers.integrations ?? defaultIntegrationResolver({ mode,