Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
20 changes: 20 additions & 0 deletions packages/deploy/src/connect.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 noPrompt flag not checked in subscription section, allowing interactive prompt despite --no-prompt

When noPrompt is true but all declared integrations are already connected, the integration loop completes normally and execution falls through to the subscription check (line 250). The subscription section at line 259-264 only gates on input.noConnect, not input.noPrompt. If the subscription is not connected, input.io.confirm(...) is called at line 265, prompting the user interactively — directly contradicting the --no-prompt flag whose documented purpose is "Fail instead of prompting for cloud auth/integration setup" (packages/deploy/src/types.ts:24-25). In a CI/non-interactive environment this would block waiting on stdin.

(Refers to lines 259-264)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -210,6 +213,18 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
continue;
}

if (input.noPrompt) {
input.io.error(
`integrations.${provider}: not connected, and --no-prompt was passed. Connect it before deploying or run without --no-prompt.`
);
outcomes.push({
provider,
status: 'failed',
message: 'not connected (--no-prompt was set)'
});
return { outcomes };
}

if (input.noConnect) {
input.io.error(
`integrations.${provider}: not connected, and prompts are disabled`
Expand Down Expand Up @@ -256,6 +271,11 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
.isConnected({ workspace: input.workspace })
.catch(() => 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'
Expand Down
177 changes: 177 additions & 0 deletions packages/deploy/src/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,46 @@ async function withWorkspaceEnv<T>(
}
}

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 {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/deploy/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading