Skip to content

Commit cb3165d

Browse files
khaliqgantRicky Schema Cascade
andauthored
fix(deploy): cover integration connect preflight (#110)
Co-authored-by: Ricky Schema Cascade <ricky@agent-relay.com>
1 parent edac0b5 commit cb3165d

4 files changed

Lines changed: 246 additions & 0 deletions

File tree

packages/deploy/src/connect.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,51 @@ test('connectIntegrations fails fast on auth errors without prompting to connect
147147
assert.ok(io.messages.some((message) => message.level === 'warn' && message.message.includes('failed to check connection status for notion')));
148148
assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('auth failed')));
149149
});
150+
151+
test('connectIntegrations honors --no-prompt for subscription provider setup', async () => {
152+
const io = createBufferedIO();
153+
let confirmCalled = false;
154+
let subscriptionConnectCalled = false;
155+
io.confirm = async () => {
156+
confirmCalled = true;
157+
return true;
158+
};
159+
160+
await assert.rejects(
161+
connectIntegrations({
162+
persona: {
163+
id: 'essay',
164+
intent: 'essay',
165+
description: 'test persona',
166+
tags: ['implementation'],
167+
useSubscription: true,
168+
integrations: {}
169+
} as never,
170+
workspace: 'ws-1',
171+
noConnect: false,
172+
noPrompt: true,
173+
io,
174+
integrations: {
175+
async isConnected() {
176+
throw new Error('no integration checks expected');
177+
},
178+
async connect() {
179+
throw new Error('no integration connects expected');
180+
}
181+
},
182+
subscription: {
183+
async isConnected() {
184+
return false;
185+
},
186+
async connect() {
187+
subscriptionConnectCalled = true;
188+
return { provider: 'anthropic' };
189+
}
190+
}
191+
}),
192+
/--no-prompt was passed/
193+
);
194+
195+
assert.equal(confirmCalled, false);
196+
assert.equal(subscriptionConnectCalled, false);
197+
});

packages/deploy/src/connect.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export interface ConnectAllInput {
152152
persona: PersonaSpec;
153153
workspace: string;
154154
noConnect: boolean;
155+
noPrompt?: boolean;
155156
io: DeployIO;
156157
integrations: IntegrationConnectResolver;
157158
/** Required only when persona.useSubscription is true. */
@@ -173,6 +174,8 @@ export interface ConnectAllResult {
173174
* Behavior summary:
174175
* - integrations: {} or undefined → returns immediately, no prompts
175176
* - already-connected provider → no prompt; emits `already-connected`
177+
* - auth failure while checking status → fails without prompting
178+
* - not connected + noPrompt=true → fails immediately without prompting
176179
* - not connected + noConnect=true → fails the deploy with a clear message
177180
* - not connected + noConnect=false → prompts; on yes runs `connect`,
178181
* on no marks `skipped`. The orchestrator decides what to do with
@@ -210,6 +213,18 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
210213
continue;
211214
}
212215

216+
if (input.noPrompt) {
217+
input.io.error(
218+
`integrations.${provider}: not connected, and --no-prompt was passed. Connect it before deploying or run without --no-prompt.`
219+
);
220+
outcomes.push({
221+
provider,
222+
status: 'failed',
223+
message: 'not connected (--no-prompt was set)'
224+
});
225+
return { outcomes };
226+
}
227+
213228
if (input.noConnect) {
214229
input.io.error(
215230
`integrations.${provider}: not connected, and prompts are disabled`
@@ -256,6 +271,11 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
256271
.isConnected({ workspace: input.workspace })
257272
.catch(() => false);
258273
if (!isConn) {
274+
if (input.noPrompt) {
275+
throw new Error(
276+
'persona requires a subscription provider connection, but --no-prompt was passed. Connect it before deploying or run without --no-prompt.'
277+
);
278+
}
259279
if (input.noConnect) {
260280
throw new Error(
261281
'persona requires a subscription provider connection, but --no-connect was passed'

packages/deploy/src/deploy.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,46 @@ async function withWorkspaceEnv<T>(
7878
}
7979
}
8080

81+
function successfulBundleStager(): BundleStager {
82+
return {
83+
async stage(input) {
84+
await mkdir(input.outDir, { recursive: true });
85+
const runner = path.join(input.outDir, 'runner.mjs');
86+
const bundle = path.join(input.outDir, 'agent.bundle.mjs');
87+
const personaCopy = path.join(input.outDir, 'persona.json');
88+
const pkg = path.join(input.outDir, 'package.json');
89+
await Promise.all([
90+
writeFile(runner, '', 'utf8'),
91+
writeFile(bundle, '', 'utf8'),
92+
writeFile(personaCopy, '{}', 'utf8'),
93+
writeFile(pkg, '{}', 'utf8')
94+
]);
95+
return {
96+
runnerPath: runner,
97+
bundlePath: bundle,
98+
personaCopyPath: personaCopy,
99+
packageJsonPath: pkg,
100+
sizeBytes: 1
101+
};
102+
}
103+
};
104+
}
105+
106+
function successfulDevLauncher(onLaunch?: () => void): ModeLauncher {
107+
return {
108+
async launch() {
109+
onLaunch?.();
110+
return {
111+
id: 'pid-1',
112+
async stop() {
113+
/* no-op */
114+
},
115+
done: Promise.resolve({ code: 0 })
116+
};
117+
}
118+
};
119+
}
120+
81121
test('preflightPersona accepts a valid deploy-shaped persona', async () => {
82122
const { personaPath, cleanup } = await withTempPersona(basePersonaJson());
83123
try {
@@ -189,6 +229,143 @@ test('deploy fails clearly when integration is not connected and --no-connect is
189229
}
190230
});
191231

232+
test('deploy connects each missing persona integration before launch', async () => {
233+
const { personaPath, cleanup } = await withTempPersona(
234+
basePersonaJson({ integrations: { github: {}, notion: {} } })
235+
);
236+
const io = createBufferedIO();
237+
const checked: string[] = [];
238+
const connected: string[] = [];
239+
let launched = false;
240+
const workspaceAuth: WorkspaceAuth = {
241+
async resolveWorkspace() {
242+
return { workspace: 'ws-test', token: 'tok' };
243+
}
244+
};
245+
const integrations: IntegrationConnectResolver = {
246+
async isConnected({ provider }) {
247+
checked.push(provider);
248+
return false;
249+
},
250+
async connect({ provider }) {
251+
connected.push(provider);
252+
return { connectionId: `conn-${provider}` };
253+
}
254+
};
255+
256+
try {
257+
const result = await deploy(
258+
{ personaPath, mode: 'dev', io },
259+
{
260+
workspaceAuth,
261+
integrations,
262+
bundle: successfulBundleStager(),
263+
modes: { dev: successfulDevLauncher(() => { launched = true; }) }
264+
}
265+
);
266+
267+
assert.deepEqual(checked, ['github', 'notion']);
268+
assert.deepEqual(connected, ['github', 'notion']);
269+
assert.deepEqual(result.connectedIntegrations, ['github', 'notion']);
270+
assert.equal(launched, true);
271+
} finally {
272+
await cleanup();
273+
}
274+
});
275+
276+
test('deploy aborts cleanly when one missing integration connect fails', async () => {
277+
const { personaPath, cleanup } = await withTempPersona(
278+
basePersonaJson({ integrations: { github: {}, notion: {} } })
279+
);
280+
const io = createBufferedIO();
281+
const connected: string[] = [];
282+
let launched = false;
283+
const workspaceAuth: WorkspaceAuth = {
284+
async resolveWorkspace() {
285+
return { workspace: 'ws-test', token: 'tok' };
286+
}
287+
};
288+
const integrations: IntegrationConnectResolver = {
289+
async isConnected() {
290+
return false;
291+
},
292+
async connect({ provider }) {
293+
connected.push(provider);
294+
if (provider === 'notion') {
295+
throw new Error('notion oauth unavailable');
296+
}
297+
return { connectionId: `conn-${provider}` };
298+
}
299+
};
300+
301+
try {
302+
await assert.rejects(
303+
deploy(
304+
{ personaPath, mode: 'dev', io },
305+
{
306+
workspaceAuth,
307+
integrations,
308+
bundle: successfulBundleStager(),
309+
modes: { dev: successfulDevLauncher(() => { launched = true; }) }
310+
}
311+
),
312+
/deploy aborted: 1 integration\(s\) failed to connect: notion/
313+
);
314+
assert.deepEqual(connected, ['github', 'notion']);
315+
assert.equal(launched, false);
316+
assert.ok(
317+
io.messages.find(
318+
(m) => m.level === 'error' && m.message.includes('integrations.notion: connect failed: notion oauth unavailable')
319+
)
320+
);
321+
} finally {
322+
await cleanup();
323+
}
324+
});
325+
326+
test('deploy treats --no-prompt as fail-fast for missing integration connects', async () => {
327+
const { personaPath, cleanup } = await withTempPersona(
328+
basePersonaJson({ integrations: { github: {}, notion: {} } })
329+
);
330+
const io = createBufferedIO();
331+
const checked: string[] = [];
332+
let connectCalled = false;
333+
const workspaceAuth: WorkspaceAuth = {
334+
async resolveWorkspace() {
335+
return { workspace: 'ws-test', token: 'tok' };
336+
}
337+
};
338+
const integrations: IntegrationConnectResolver = {
339+
async isConnected({ provider }) {
340+
checked.push(provider);
341+
return false;
342+
},
343+
async connect() {
344+
connectCalled = true;
345+
throw new Error('connect should not be called when --no-prompt is set');
346+
}
347+
};
348+
349+
try {
350+
await assert.rejects(
351+
deploy(
352+
{ personaPath, mode: 'dev', noPrompt: true, io },
353+
{ workspaceAuth, integrations }
354+
),
355+
/deploy aborted: 1 integration\(s\) failed to connect: github/
356+
);
357+
assert.deepEqual(checked, ['github']);
358+
assert.equal(connectCalled, false);
359+
assert.ok(
360+
io.messages.find(
361+
(m) => m.level === 'error' && m.message.includes('--no-prompt was passed')
362+
)
363+
);
364+
} finally {
365+
await cleanup();
366+
}
367+
});
368+
192369
test('deploy stages a bundle and hands off to the resolved launcher', async () => {
193370
const { personaPath, dir, cleanup } = await withTempPersona(basePersonaJson());
194371
const io = createBufferedIO();

packages/deploy/src/deploy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = {
143143
persona: preflight.persona,
144144
workspace,
145145
noConnect: opts.noConnect === true,
146+
...(opts.noPrompt ? { noPrompt: true } : {}),
146147
io,
147148
integrations: resolvers.integrations ?? defaultIntegrationResolver({
148149
mode,

0 commit comments

Comments
 (0)