nango 0.72.1 → 0.72.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,36 +11,40 @@ vi.mock('promptly', () => ({ default: { confirm: vi.fn() } }));
11
11
  vi.mock('./definitions.js', () => ({ parseIntegrationDefinitions: vi.fn() }));
12
12
  const githubFunction = functionConfig({ integrationId: 'github', name: 'fetch' });
13
13
  const slackFunction = functionConfig({ integrationId: 'slack', name: 'other' });
14
- const parsed = {
15
- yamlVersion: 'v2',
16
- models: new Map(),
17
- integrations: [
14
+ const githubIntegration = {
15
+ providerConfigKey: 'github',
16
+ syncs: [],
17
+ actions: [
18
18
  {
19
- providerConfigKey: 'github',
20
- syncs: [],
21
- actions: [
22
- {
23
- name: 'legacy',
24
- type: 'action',
25
- description: 'Legacy action',
26
- input: null,
27
- output: [],
28
- endpoint: null,
29
- scopes: [],
30
- usedModels: [],
31
- version: '',
32
- json_schema: { definitions: {} },
33
- features: []
34
- }
35
- ],
36
- onEventScripts: {
37
- 'post-connection-creation': [],
38
- 'pre-connection-deletion': [],
39
- 'validate-connection': []
40
- }
19
+ name: 'legacy',
20
+ type: 'action',
21
+ description: 'Legacy action',
22
+ input: null,
23
+ output: [],
24
+ endpoint: null,
25
+ scopes: [],
26
+ usedModels: [],
27
+ version: '',
28
+ json_schema: { definitions: {} },
29
+ features: []
41
30
  }
42
31
  ],
32
+ onEventScripts: {
33
+ 'post-connection-creation': [],
34
+ 'pre-connection-deletion': [],
35
+ 'validate-connection': []
36
+ }
37
+ };
38
+ const parsed = {
39
+ yamlVersion: 'v2',
40
+ models: new Map(),
41
+ integrations: [githubIntegration],
43
42
  functions: [githubFunction, slackFunction],
43
+ entryPoints: [
44
+ { filePath: './github/actions/legacy.ts', integrationId: 'github', type: 'action', name: 'legacy' },
45
+ { filePath: githubFunction.filePath, integrationId: 'github', type: 'function', name: 'fetch' },
46
+ { filePath: slackFunction.filePath, integrationId: 'slack', type: 'function', name: 'other' }
47
+ ],
44
48
  hasExperimentalFunctions: true
45
49
  };
46
50
  const legacyConfirmation = {
@@ -59,14 +63,51 @@ const legacyChangeConfirmation = {
59
63
  ...legacyConfirmation,
60
64
  updatedActions: [{ providerConfigKey: 'github', name: 'legacy' }]
61
65
  };
66
+ const legacyDeploymentResult = [{ name: 'legacy', version: '42', providerConfigKey: 'github', type: 'action', models: [] }];
62
67
  const legacyOnlyParsed = {
63
68
  ...parsed,
64
69
  functions: [],
70
+ entryPoints: parsed.entryPoints.filter((entryPoint) => entryPoint.type !== 'function'),
65
71
  hasExperimentalFunctions: false
66
72
  };
67
73
  const functionsOnlyParsed = {
68
74
  ...parsed,
69
- integrations: parsed.integrations.map((integration) => ({ ...integration, syncs: [], actions: [] }))
75
+ integrations: parsed.integrations.map((integration) => ({ ...integration, syncs: [], actions: [] })),
76
+ entryPoints: parsed.entryPoints.filter((entryPoint) => entryPoint.type === 'function')
77
+ };
78
+ const multiIntegrationLegacyParsed = {
79
+ ...legacyOnlyParsed,
80
+ integrations: [
81
+ {
82
+ ...githubIntegration,
83
+ syncs: [
84
+ {
85
+ name: 'contacts',
86
+ type: 'sync',
87
+ endpoints: [],
88
+ description: 'Contacts',
89
+ sync_type: 'full',
90
+ track_deletes: false,
91
+ auto_start: true,
92
+ runs: 'every day',
93
+ scopes: [],
94
+ input: null,
95
+ output: [],
96
+ usedModels: [],
97
+ webhookSubscriptions: [],
98
+ version: '',
99
+ json_schema: { definitions: {} },
100
+ features: []
101
+ }
102
+ ]
103
+ },
104
+ { ...githubIntegration, providerConfigKey: 'slack' }
105
+ ],
106
+ entryPoints: [
107
+ { filePath: './github/actions/legacy.ts', integrationId: 'github', type: 'action', name: 'legacy' },
108
+ { filePath: './github/syncs/contacts.ts', integrationId: 'github', type: 'sync', name: 'contacts' },
109
+ { filePath: './slack/actions/legacy.ts', integrationId: 'slack', type: 'action', name: 'legacy' }
110
+ ]
70
111
  };
71
112
  const noFunctionChanges = {
72
113
  created: [],
@@ -74,6 +115,13 @@ const noFunctionChanges = {
74
115
  unchanged: [{ integrationId: 'github', name: 'fetch' }],
75
116
  deleted: []
76
117
  };
118
+ const emptyFunctionChanges = { created: [], updated: [], unchanged: [], deleted: [] };
119
+ const apiRoutes = {
120
+ legacyPreview: '/sync/deploy/confirmation',
121
+ legacyDeploy: '/sync/deploy',
122
+ functionsPreview: '/functions/deployments/bundle/preview',
123
+ functionsDeploy: '/functions/deployments/bundle'
124
+ };
77
125
  describe('deploy', () => {
78
126
  let fullPath;
79
127
  let fetchMock;
@@ -99,11 +147,209 @@ describe('deploy', () => {
99
147
  vi.restoreAllMocks();
100
148
  await fs.rm(fullPath, { recursive: true, force: true });
101
149
  });
102
- it('prints an error for a mixed deploy', async () => {
103
- await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
150
+ it('previews both families before deploying them in order with one summary', async () => {
151
+ const functionChanges = { ...noFunctionChanges, updated: [{ integrationId: 'github', name: 'fetch' }] };
152
+ fetchMock
153
+ .mockResolvedValueOnce(jsonResponse({ ...legacyChangeConfirmation, deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }] }))
154
+ .mockResolvedValueOnce(jsonResponse(functionChanges))
155
+ .mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
156
+ .mockResolvedValueOnce(jsonResponse(functionChanges));
157
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
158
+ expect(result.isOk()).toBe(true);
159
+ expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([
160
+ '/sync/deploy/confirmation',
161
+ '/functions/deployments/bundle/preview',
162
+ '/sync/deploy',
163
+ '/functions/deployments/bundle'
164
+ ]);
165
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({ reconciliationScope: { kind: 'environment' } });
166
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconciliationScope: { kind: 'environment' } });
167
+ expect(requestBody(fetchMock, 'legacyPreview')).not.toHaveProperty('deployMode');
168
+ expect(requestBody(fetchMock, 'legacyDeploy')).not.toHaveProperty('deployMode');
169
+ const output = logOutput(logSpy);
170
+ expect(output.match(/Nango will perform this plan:/g)).toHaveLength(1);
171
+ expect(output.match(/Summary/g)).toHaveLength(1);
172
+ expect(output).toContain('↳ Actions');
173
+ expect(output).toContain('↳ Functions');
174
+ expect(output).toContain('✓ Deployed');
175
+ expect(output.match(/Successfully deployed:/g)).toHaveLength(1);
176
+ expect(output).toContain('- github → legacy@v42');
177
+ expect(output).toContain('- github → fetch');
178
+ expect(output).toContain('Successfully removed:');
179
+ expect(output).toContain('- github → old-action');
180
+ expect(output.indexOf('✓ Deployed')).toBeLessThan(output.indexOf('Successfully deployed:'));
181
+ expect(promptly.confirm).not.toHaveBeenCalled();
182
+ });
183
+ it('scopes both families to the selected integration and only overrides sync/action versions', async () => {
184
+ mockApi(fetchMock, {
185
+ legacyPreview: legacyChangeConfirmation,
186
+ legacyDeploy: legacyDeploymentResult,
187
+ functionsPreview: noFunctionChanges,
188
+ functionsDeploy: noFunctionChanges
189
+ });
190
+ const result = await deploy({
191
+ fullPath,
192
+ environmentName: 'test',
193
+ interactive: false,
194
+ options: deployOptions({ integration: 'github', version: '42' })
195
+ });
196
+ expect(result.isOk()).toBe(true);
197
+ const legacyPreviewBody = requestBody(fetchMock, 'legacyPreview');
198
+ const functionsPreviewBody = requestBody(fetchMock, 'functionsPreview');
199
+ expect(legacyPreviewBody).toMatchObject({
200
+ reconciliationScope: { kind: 'integration', integrationId: 'github' },
201
+ flowConfigs: [{ providerConfigKey: 'github', version: '42' }]
202
+ });
203
+ expect(functionsPreviewBody).toMatchObject({
204
+ reconciliationScope: { kind: 'integration', integrationId: 'github' },
205
+ functions: [{ integrationId: 'github', name: 'fetch' }]
206
+ });
207
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ flowConfigs: legacyPreviewBody?.['flowConfigs'] });
208
+ expect(requestBody(fetchMock, 'functionsDeploy')).toEqual(functionsPreviewBody);
209
+ expect(logOutput(logSpy).match(/The --version option/g)).toHaveLength(1);
210
+ expect(logOutput(logSpy).indexOf('✓ Packaging')).toBeLessThan(logOutput(logSpy).indexOf('The --version option'));
211
+ expect(logOutput(logSpy)).not.toContain('No changes');
212
+ expect(logOutput(logSpy)).toContain('- github → legacy@v42');
213
+ });
214
+ it('rejects conflicting selectors without contacting the API', async () => {
215
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ sync: 'contacts', action: 'legacy' }) });
216
+ expect(result.isErr()).toBe(true);
217
+ expect(logOutput(logSpy)).toContain('The --sync and --action options cannot be used together');
218
+ expect(fetchMock).not.toHaveBeenCalled();
219
+ });
220
+ it.each(['legacy', 'functions'])('does not contact the API when %s packaging fails in a mixed project', async (family) => {
221
+ if (family === 'legacy') {
222
+ await fs.rm(path.join(fullPath, 'github', 'actions', 'legacy.ts'));
223
+ }
224
+ else {
225
+ await fs.rm(path.join(fullPath, 'build', 'github_functions_fetch.cjs'));
226
+ }
227
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
228
+ expect(result.isErr()).toBe(true);
229
+ expect(fetchMock).not.toHaveBeenCalled();
230
+ });
231
+ it.each(['legacy', 'functions'])('does not deploy either family when the %s preview fails', async (family) => {
232
+ const previewError = jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } });
233
+ fetchMock
234
+ .mockResolvedValueOnce(family === 'legacy' ? previewError : jsonResponse(legacyConfirmation))
235
+ .mockResolvedValueOnce(family === 'functions' ? previewError : jsonResponse(noFunctionChanges));
236
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
237
+ expect(result.isErr()).toBe(true);
238
+ expect(fetchMock).toHaveBeenCalledTimes(2);
239
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
240
+ });
241
+ it.each(['legacy', 'functions'])('clearly reports each outcome when the %s deployment fails', async (family) => {
242
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
243
+ if (family === 'functions') {
244
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult)).mockRejectedValueOnce(new Error('Connection lost'));
245
+ }
246
+ else {
247
+ fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
248
+ }
249
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
250
+ expect(result.isErr()).toBe(true);
251
+ expect(fetchMock).toHaveBeenCalledTimes(family === 'legacy' ? 3 : 4);
104
252
  const output = logOutput(logSpy);
105
- expect(output).toContain('Legacy scripts and functions cannot be deployed together');
253
+ expect(output).toContain(`Syncs/actions/on-events: ${family === 'legacy' ? 'failed' : 'completed'}`);
254
+ expect(output).toContain(`Functions: ${family === 'legacy' ? 'not attempted' : 'failed'}`);
255
+ expect(output).toContain(family === 'legacy' ? 'Deployment failed.' : 'Deployment partially completed.');
256
+ expect(output).toContain('The failed request may have applied changes');
106
257
  expect(output).not.toContain('✓ Deployed');
258
+ if (family === 'functions') {
259
+ expect(output).toContain('- github → legacy@v42');
260
+ expect(output.indexOf('✗ Deploying')).toBeLessThan(output.indexOf('Successfully deployed:'));
261
+ }
262
+ });
263
+ it.each([
264
+ {
265
+ name: 'interactive approval',
266
+ interactive: true,
267
+ autoConfirm: false,
268
+ allowDestructive: false,
269
+ accepted: true,
270
+ succeeds: true,
271
+ prompts: 1
272
+ },
273
+ {
274
+ name: 'interactive rejection',
275
+ interactive: true,
276
+ autoConfirm: false,
277
+ allowDestructive: false,
278
+ accepted: false,
279
+ succeeds: false,
280
+ prompts: 1
281
+ },
282
+ {
283
+ name: 'noninteractive auto-confirm alone',
284
+ interactive: false,
285
+ autoConfirm: true,
286
+ allowDestructive: false,
287
+ accepted: false,
288
+ succeeds: false,
289
+ prompts: 0
290
+ },
291
+ {
292
+ name: 'noninteractive allow-destructive alone',
293
+ interactive: false,
294
+ autoConfirm: false,
295
+ allowDestructive: true,
296
+ accepted: false,
297
+ succeeds: false,
298
+ prompts: 0
299
+ },
300
+ {
301
+ name: 'noninteractive approval flags',
302
+ interactive: false,
303
+ autoConfirm: true,
304
+ allowDestructive: true,
305
+ accepted: false,
306
+ succeeds: true,
307
+ prompts: 0
308
+ }
309
+ ])('handles mixed destructive changes with $name', async ({ interactive, autoConfirm, allowDestructive, accepted, succeeds, prompts }) => {
310
+ const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
311
+ fetchMock
312
+ .mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }))
313
+ .mockResolvedValueOnce(jsonResponse(changes))
314
+ .mockResolvedValueOnce(jsonResponse([]))
315
+ .mockResolvedValueOnce(jsonResponse(changes));
316
+ vi.mocked(promptly.confirm).mockImplementationOnce(() => {
317
+ expect(fetchMock).toHaveBeenCalledTimes(2);
318
+ expect(logOutput(logSpy)).toContain('↳ Actions');
319
+ expect(logOutput(logSpy)).toContain('↳ Functions');
320
+ return Promise.resolve(accepted);
321
+ });
322
+ const result = await deploy({ fullPath, environmentName: 'test', interactive, options: deployOptions({ interactive, autoConfirm, allowDestructive }) });
323
+ expect(result.isOk()).toBe(succeeds);
324
+ expect(promptly.confirm).toHaveBeenCalledTimes(prompts);
325
+ expect(fetchMock).toHaveBeenCalledTimes(succeeds ? 4 : 2);
326
+ });
327
+ it.each(['legacy', 'functions'])('requires confirmation when only %s changes are destructive', async (family) => {
328
+ fetchMock
329
+ .mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: family === 'legacy' ? ['RemovedModel'] : [] }))
330
+ .mockResolvedValueOnce(jsonResponse({ ...noFunctionChanges, deleted: family === 'functions' ? [{ integrationId: 'github', name: 'removed' }] : [] }));
331
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
332
+ expect(result.isErr()).toBe(true);
333
+ expect(fetchMock).toHaveBeenCalledTimes(2);
334
+ expect(promptly.confirm).not.toHaveBeenCalled();
335
+ });
336
+ it('accepts the environment auto-confirm setting for a mixed deployment', async () => {
337
+ vi.stubEnv('NANGO_DEPLOY_AUTO_CONFIRM', 'true');
338
+ const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
339
+ fetchMock
340
+ .mockResolvedValueOnce(jsonResponse(legacyConfirmation))
341
+ .mockResolvedValueOnce(jsonResponse(changes))
342
+ .mockResolvedValueOnce(jsonResponse([]))
343
+ .mockResolvedValueOnce(jsonResponse(changes));
344
+ const result = await deploy({
345
+ fullPath,
346
+ environmentName: 'test',
347
+ interactive: false,
348
+ options: deployOptions({ autoConfirm: false, allowDestructive: true })
349
+ });
350
+ expect(result.isOk()).toBe(true);
351
+ expect(fetchMock).toHaveBeenCalledTimes(4);
352
+ expect(promptly.confirm).not.toHaveBeenCalled();
107
353
  });
108
354
  it('prints only legacy summary rows for a single action deploy', async () => {
109
355
  fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
@@ -119,6 +365,204 @@ describe('deploy', () => {
119
365
  expect(output).toContain('↳ OnEvents');
120
366
  expect(output).not.toContain('↳ Functions');
121
367
  expect(output).toContain('✓ Deployed');
368
+ expect(output).toContain('No destructive changes, proceeding without confirmation');
369
+ expect(output).toContain('--sync and --action are deprecated');
370
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/sync/deploy']);
371
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({ reconciliationScope: { kind: 'function', integrationId: 'github', name: 'legacy' } });
372
+ expect(requestBody(fetchMock, 'legacyPreview')).not.toHaveProperty('deployMode');
373
+ });
374
+ describe('with a file', () => {
375
+ const functionChanges = {
376
+ created: [{ integrationId: 'github', name: 'fetch' }],
377
+ updated: [],
378
+ unchanged: [],
379
+ deleted: []
380
+ };
381
+ it.each([
382
+ { label: 'a relative path', file: () => 'github/functions/fetch.ts' },
383
+ { label: 'a ./ relative path', file: () => './github/functions/fetch.ts' },
384
+ { label: 'a path with backslashes', file: () => 'github\\functions\\fetch.ts' },
385
+ { label: 'a .js path', file: () => 'github/functions/fetch.js' },
386
+ { label: 'an absolute path', file: () => path.join(fullPath, 'github', 'functions', 'fetch.ts') }
387
+ ])('deploys only that function from $label in a mixed project', async ({ file }) => {
388
+ fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
389
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: file() });
390
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
391
+ for (const [, request] of fetchMock.mock.calls) {
392
+ const body = JSON.parse(request.body);
393
+ expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'fetch' });
394
+ expect(body.functions).toEqual([expect.objectContaining({ integrationId: 'github', name: 'fetch' })]);
395
+ }
396
+ expect(fetchMock).toHaveBeenCalledTimes(2);
397
+ });
398
+ it('deploys a tool under its declared name', async () => {
399
+ const toolConfig = { ...functionConfig({ integrationId: 'github', name: 'get_issue' }), filePath: './github/tools/getIssue.ts' };
400
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
401
+ ...parsed,
402
+ functions: [githubFunction, toolConfig],
403
+ entryPoints: [...parsed.entryPoints, { filePath: toolConfig.filePath, integrationId: 'github', type: 'function', name: 'get_issue' }]
404
+ }));
405
+ await fs.mkdir(path.join(fullPath, 'github', 'tools'), { recursive: true });
406
+ await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction, toolConfig]));
407
+ await fs.writeFile(path.join(fullPath, 'github', 'tools', 'getIssue.ts'), 'source tool');
408
+ await fs.writeFile(path.join(fullPath, 'build', 'github_tools_getIssue.cjs'), 'compiled tool');
409
+ const toolChanges = { ...functionChanges, created: [{ integrationId: 'github', name: 'get_issue' }] };
410
+ fetchMock.mockResolvedValueOnce(jsonResponse(toolChanges)).mockResolvedValueOnce(jsonResponse(toolChanges));
411
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/tools/getIssue.ts' });
412
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
413
+ const previewRequest = fetchMock.mock.calls[0]?.[1];
414
+ const previewBody = JSON.parse(previewRequest.body);
415
+ expect(previewBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'get_issue' });
416
+ expect(previewBody.functions).toEqual([expect.objectContaining({ name: 'get_issue', fileBody: { js: 'compiled tool', ts: 'source tool' } })]);
417
+ });
418
+ it.each([
419
+ { env: undefined, source: 'repo' },
420
+ { env: 'standalone', source: 'standalone' }
421
+ ])('sends source $source on every function when NANGO_DEPLOY_SOURCE is $env', async ({ env, source }) => {
422
+ vi.stubEnv('NANGO_DEPLOY_SOURCE', env);
423
+ fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
424
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
425
+ const bodies = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body));
426
+ expect(bodies).toHaveLength(2);
427
+ for (const body of bodies) {
428
+ expect(body.functions.map((fn) => fn.source)).toEqual([source]);
429
+ }
430
+ });
431
+ it('deploys a legacy action as a single script', async () => {
432
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
433
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/actions/legacy.ts' });
434
+ const output = logOutput(logSpy);
435
+ expect(output).toContain('✓ Deployed');
436
+ expect(output).not.toContain('--sync and --action are deprecated');
437
+ const confirmationRequest = fetchMock.mock.calls[0]?.[1];
438
+ const confirmationBody = JSON.parse(confirmationRequest.body);
439
+ expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'legacy' });
440
+ expect(confirmationBody.onEventScriptsByProvider).toBeUndefined();
441
+ expect(confirmationBody.flowConfigs).toEqual([expect.objectContaining({ syncName: 'legacy', providerConfigKey: 'github', type: 'action' })]);
442
+ });
443
+ describe('in a project with legacy scripts sharing names across integrations', () => {
444
+ beforeEach(async () => {
445
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(multiIntegrationLegacyParsed));
446
+ await fs.mkdir(path.join(fullPath, 'github', 'syncs'), { recursive: true });
447
+ await fs.mkdir(path.join(fullPath, 'slack', 'actions'), { recursive: true });
448
+ await fs.writeFile(path.join(fullPath, 'build', 'github_syncs_contacts.cjs'), 'compiled contacts');
449
+ await fs.writeFile(path.join(fullPath, 'github', 'syncs', 'contacts.ts'), 'source contacts');
450
+ await fs.writeFile(path.join(fullPath, 'build', 'slack_actions_legacy.cjs'), 'compiled slack legacy');
451
+ await fs.writeFile(path.join(fullPath, 'slack', 'actions', 'legacy.ts'), 'source slack legacy');
452
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
453
+ });
454
+ it('deploys a legacy sync as a single script', async () => {
455
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/syncs/contacts.ts' });
456
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
457
+ const confirmationRequest = fetchMock.mock.calls[0]?.[1];
458
+ const confirmationBody = JSON.parse(confirmationRequest.body);
459
+ expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'contacts' });
460
+ expect(confirmationBody.flowConfigs).toEqual([
461
+ expect.objectContaining({
462
+ syncName: 'contacts',
463
+ providerConfigKey: 'github',
464
+ type: 'sync',
465
+ fileBody: { js: 'compiled contacts', ts: 'source contacts' }
466
+ })
467
+ ]);
468
+ });
469
+ it('deploys each match of a deprecated action selector in its own function scope', async () => {
470
+ fetchMock.mockReset();
471
+ mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
472
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ action: 'legacy' }) });
473
+ expect(result.isOk()).toBe(true);
474
+ const scopes = fetchMock.mock.calls.map(([url, init]) => {
475
+ const body = JSON.parse(init.body);
476
+ expect(body).not.toHaveProperty('deployMode');
477
+ expect(body.flowConfigs).toHaveLength(1);
478
+ const [flow] = body.flowConfigs;
479
+ expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: flow?.providerConfigKey, name: 'legacy' });
480
+ return { path: url.pathname, integrationId: flow?.providerConfigKey };
481
+ });
482
+ expect(scopes).toEqual([
483
+ { path: apiRoutes.legacyPreview, integrationId: 'github' },
484
+ { path: apiRoutes.legacyPreview, integrationId: 'slack' },
485
+ { path: apiRoutes.legacyDeploy, integrationId: 'github' },
486
+ { path: apiRoutes.legacyDeploy, integrationId: 'slack' }
487
+ ]);
488
+ });
489
+ it("deploys only the file's integration when another integration has an action with the same name", async () => {
490
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'slack/actions/legacy.ts' });
491
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
492
+ const confirmationRequest = fetchMock.mock.calls[0]?.[1];
493
+ const confirmationBody = JSON.parse(confirmationRequest.body);
494
+ expect(confirmationBody.flowConfigs).toEqual([
495
+ expect.objectContaining({
496
+ syncName: 'legacy',
497
+ providerConfigKey: 'slack',
498
+ type: 'action',
499
+ fileBody: { js: 'compiled slack legacy', ts: 'source slack legacy' }
500
+ })
501
+ ]);
502
+ });
503
+ });
504
+ it.each([
505
+ { label: 'an unknown file', file: 'github/functions/missing.ts', message: "'github/functions/missing.ts' is not a script" },
506
+ { label: 'a file outside the project', file: '../outside.ts', message: "'../outside.ts' must be inside the project folder" },
507
+ { label: 'an absolute path outside the project', file: '/outside.ts', message: "'/outside.ts' must be inside the project folder" },
508
+ {
509
+ label: 'an on-event script',
510
+ file: 'github/on-events/cleanup.ts',
511
+ message: "can't be deployed on its own. Use `nango deploy <environment> --integration github`",
512
+ entryPoint: { filePath: './github/on-events/cleanup.ts', integrationId: 'github', type: 'on-event', name: 'cleanup' }
513
+ }
514
+ ])('rejects $label', async ({ file, message, entryPoint }) => {
515
+ if (entryPoint) {
516
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...parsed, entryPoints: [...parsed.entryPoints, entryPoint] }));
517
+ }
518
+ const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
519
+ expect(res.isErr()).toBe(true);
520
+ expect(logOutput(logSpy)).toContain(message);
521
+ expect(fetchMock).not.toHaveBeenCalled();
522
+ });
523
+ it.each([
524
+ { flag: '--sync', options: { sync: 'legacy' } },
525
+ { flag: '--action', options: { action: 'legacy' } },
526
+ { flag: '--integration', options: { integration: 'github' } }
527
+ ])('rejects a file combined with $flag', async ({ options }) => {
528
+ const res = await deploy({
529
+ fullPath,
530
+ environmentName: 'test',
531
+ interactive: false,
532
+ options: deployOptions(options),
533
+ file: 'github/functions/fetch.ts'
534
+ });
535
+ expect(res.isErr()).toBe(true);
536
+ expect(logOutput(logSpy)).toContain('A file cannot be combined with --sync, --action or --integration');
537
+ expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
538
+ expect(fetchMock).not.toHaveBeenCalled();
539
+ });
540
+ it.each(['', ' '])('rejects an empty file argument %j instead of deploying everything', async (file) => {
541
+ const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
542
+ expect(res.isErr()).toBe(true);
543
+ expect(logOutput(logSpy)).toContain('The file argument is empty');
544
+ expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
545
+ expect(fetchMock).not.toHaveBeenCalled();
546
+ });
547
+ it('rejects a file passed in place of the environment', async () => {
548
+ const res = await deploy({ fullPath, environmentName: 'github/syncs/fetchIssues.ts', interactive: false, options: deployOptions() });
549
+ expect(res.isErr()).toBe(true);
550
+ expect(logOutput(logSpy)).toContain("'github/syncs/fetchIssues.ts' is not a valid environment name");
551
+ expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
552
+ expect(fetchMock).not.toHaveBeenCalled();
553
+ });
554
+ it('does not deploy when the server rejects the function scope', async () => {
555
+ fetchMock.mockResolvedValueOnce(jsonResponse({
556
+ error: {
557
+ code: 'invalid_body',
558
+ errors: [{ code: 'invalid_union_discriminator', message: 'Invalid discriminator value', path: ['reconciliationScope', 'kind'] }]
559
+ }
560
+ }));
561
+ const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
562
+ expect(res.isErr()).toBe(true);
563
+ expect(logOutput(logSpy)).toContain('reconciliationScope.kind: Invalid discriminator value');
564
+ expect(fetchMock).toHaveBeenCalledTimes(1);
565
+ });
122
566
  });
123
567
  it.each([
124
568
  { flag: '--sync', options: { sync: 'missing' } },
@@ -137,7 +581,7 @@ describe('deploy', () => {
137
581
  unchanged: [],
138
582
  deleted: []
139
583
  };
140
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
584
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
141
585
  await deploy({
142
586
  fullPath,
143
587
  environmentName: 'test',
@@ -147,21 +591,24 @@ describe('deploy', () => {
147
591
  const output = logOutput(logSpy);
148
592
  expect(output).toContain('↳ Functions');
149
593
  expect(output).toContain('✓ Deployed');
150
- const previewRequest = fetchMock.mock.calls[0]?.[1];
151
- const previewBody = JSON.parse(previewRequest.body);
594
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
595
+ flowConfigs: [],
596
+ onEventScriptsByProvider: [],
597
+ reconciliationScope: { kind: 'integration', integrationId: 'slack' }
598
+ });
599
+ const previewBody = requestBody(fetchMock, 'functionsPreview');
152
600
  expect(previewBody.reconciliationScope).toEqual({ kind: 'integration', integrationId: 'slack' });
153
601
  expect(previewBody.functions).toEqual([expect.objectContaining({ integrationId: 'slack', name: 'other', trigger: { kind: 'http' } })]);
154
602
  });
155
603
  it('deletes the functions of an integration when its last function was removed', async () => {
156
604
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [githubFunction] }));
157
- await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction]));
158
605
  const functionChanges = {
159
606
  created: [],
160
607
  updated: [],
161
608
  unchanged: [],
162
609
  deleted: [{ integrationId: 'slack', name: 'other' }]
163
610
  };
164
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
611
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
165
612
  await deploy({
166
613
  fullPath,
167
614
  environmentName: 'test',
@@ -171,11 +618,17 @@ describe('deploy', () => {
171
618
  const output = logOutput(logSpy);
172
619
  expect(output).toContain('- slack → other');
173
620
  expect(output).toContain('✓ Deployed');
174
- expect(output).toContain('Successfully removed the functions');
621
+ expect(output).toContain('Successfully removed:');
622
+ expect(requestBody(fetchMock, 'functionsDeploy')).toEqual({ reconciliationScope: { kind: 'integration', integrationId: 'slack' }, functions: [] });
623
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
624
+ flowConfigs: [],
625
+ onEventScriptsByProvider: [],
626
+ reconciliationScope: { kind: 'integration', integrationId: 'slack' }
627
+ });
628
+ expect(requestPaths(fetchMock)).not.toContain(apiRoutes.legacyDeploy);
175
629
  });
176
630
  it('deletes every function of the environment when the last function was removed', async () => {
177
631
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
178
- await fs.rm(path.join(fullPath, '.nango', 'functions.json'));
179
632
  const functionChanges = {
180
633
  created: [],
181
634
  updated: [],
@@ -185,24 +638,87 @@ describe('deploy', () => {
185
638
  { integrationId: 'slack', name: 'other' }
186
639
  ]
187
640
  };
188
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
641
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
189
642
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
190
643
  const output = logOutput(logSpy);
191
644
  expect(output).toContain('- github → fetch');
192
645
  expect(output).toContain('- slack → other');
193
646
  expect(output).toContain('✓ Deployed');
194
- expect(output).toContain('Successfully removed the functions');
647
+ expect(output).toContain('Successfully removed:');
648
+ expect(requestBody(fetchMock, 'functionsDeploy')).toEqual({ reconciliationScope: { kind: 'environment' }, functions: [] });
649
+ });
650
+ it('deletes every legacy script of the environment when the last one was removed', async () => {
651
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
652
+ const changes = {
653
+ ...legacyConfirmation,
654
+ deletedActions: [{ providerConfigKey: 'github', name: 'legacy' }],
655
+ deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
656
+ };
657
+ mockApi(fetchMock, { legacyPreview: changes });
658
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
659
+ expect(result.isOk()).toBe(true);
660
+ const output = logOutput(logSpy);
661
+ expect(output).toContain('Successfully removed:');
662
+ expect(output).toContain('- github → legacy');
663
+ expect(output).toContain('- github → old-event');
664
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({
665
+ flowConfigs: [],
666
+ onEventScriptsByProvider: [],
667
+ reconciliationScope: { kind: 'environment' }
668
+ });
195
669
  });
196
- it('rejects --version for native function deployments', async () => {
670
+ it('deploys a project that only has on-event scripts', async () => {
671
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
672
+ ...functionsOnlyParsed,
673
+ integrations: functionsOnlyParsed.integrations.map((integration) => ({
674
+ ...integration,
675
+ onEventScripts: { ...integration.onEventScripts, 'post-connection-creation': ['on-create'] }
676
+ })),
677
+ functions: []
678
+ }));
679
+ await fs.mkdir(path.join(fullPath, 'github', 'on-events'), { recursive: true });
680
+ await fs.writeFile(path.join(fullPath, 'build', 'github_on-events_on-create.cjs'), 'compiled on-event');
681
+ await fs.writeFile(path.join(fullPath, 'github', 'on-events', 'on-create.ts'), 'source on-event');
682
+ mockApi(fetchMock, {
683
+ legacyPreview: {
684
+ ...legacyConfirmation,
685
+ newOnEventScripts: [{ providerConfigKey: 'github', name: 'on-create', event: 'post-connection-creation' }]
686
+ },
687
+ legacyDeploy: [{ name: 'on-create', version: '0.0.1', providerConfigKey: 'github', type: 'on-event', models: [] }]
688
+ });
689
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
690
+ expect(result.isOk()).toBe(true);
691
+ expect(logOutput(logSpy)).toContain('- github → on-create@v0.0.1');
692
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({
693
+ flowConfigs: [],
694
+ onEventScriptsByProvider: [
695
+ {
696
+ providerConfigKey: 'github',
697
+ scripts: [{ name: 'on-create', event: 'post-connection-creation', fileBody: { js: 'compiled on-event', ts: 'source on-event' } }]
698
+ }
699
+ ]
700
+ });
701
+ });
702
+ it('warns about --version for function deployments and still deploys', async () => {
197
703
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
198
- await deploy({
704
+ mockApi(fetchMock);
705
+ const result = await deploy({
199
706
  fullPath,
200
707
  environmentName: 'test',
201
708
  interactive: false,
202
709
  options: deployOptions({ version: 'v1' })
203
710
  });
204
- expect(logOutput(logSpy)).toContain('The --version option can only be used with legacy scripts');
205
- expect(fetchMock).not.toHaveBeenCalled();
711
+ expect(result.isOk()).toBe(true);
712
+ expect(logOutput(logSpy)).toContain('The --version option only applies to syncs/actions; it has no effect on functions.');
713
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
714
+ });
715
+ it('only previews when no deployment has anything to deploy or delete', async () => {
716
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
717
+ mockApi(fetchMock);
718
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
719
+ expect(result.isOk()).toBe(true);
720
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview]);
721
+ expect(logOutput(logSpy)).toContain('No changes were deployed.');
206
722
  });
207
723
  it('prints only the Functions summary row for a functions-only project', async () => {
208
724
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
@@ -212,7 +728,7 @@ describe('deploy', () => {
212
728
  unchanged: [{ integrationId: 'slack', name: 'other' }],
213
729
  deleted: []
214
730
  };
215
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
731
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
216
732
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
217
733
  const output = logOutput(logSpy);
218
734
  expect(output).not.toContain('↳ Syncs');
@@ -227,7 +743,6 @@ describe('deploy', () => {
227
743
  const config = functionConfig({ integrationId: 'github.js', name: 'fetch' });
228
744
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [config] }));
229
745
  await fs.mkdir(path.join(fullPath, 'github.js', 'functions'), { recursive: true });
230
- await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([config]));
231
746
  await fs.writeFile(path.join(fullPath, 'github.js', 'functions', 'fetch.ts'), 'source with dotted path');
232
747
  await fs.writeFile(path.join(fullPath, 'build', 'github.js_functions_fetch.cjs'), 'compiled with dotted path');
233
748
  const functionChanges = {
@@ -236,35 +751,20 @@ describe('deploy', () => {
236
751
  unchanged: [],
237
752
  deleted: []
238
753
  };
239
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
754
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
240
755
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
241
756
  expect(logOutput(logSpy)).toContain('✓ Deployed');
242
- const previewRequest = fetchMock.mock.calls[0]?.[1];
243
- const previewBody = JSON.parse(previewRequest.body);
757
+ const previewBody = requestBody(fetchMock, 'functionsPreview');
244
758
  expect(previewBody.functions[0]?.fileBody).toEqual({ js: 'compiled with dotted path', ts: 'source with dotted path' });
245
759
  });
246
- it.each([
247
- { state: 'malformed', write: async (artifactPath) => await fs.writeFile(artifactPath, '{'.repeat(2)) },
248
- { state: 'not an array', write: async (artifactPath) => await fs.writeFile(artifactPath, JSON.stringify({})) }
249
- ])('does not reconcile anything when the functions artifact is $state', async ({ write }) => {
250
- vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
251
- await write(path.join(fullPath, '.nango', 'functions.json'));
252
- await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
253
- const output = logOutput(logSpy);
254
- expect(output).not.toContain('↳ Functions');
255
- expect(output).toContain('functions artifact');
256
- expect(output).not.toContain('✓ Deployed');
257
- expect(fetchMock).not.toHaveBeenCalled();
258
- });
259
760
  it('rejects function source paths outside the project directory', async () => {
260
- vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
261
761
  const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nango-cli-external-'));
262
762
  try {
263
763
  const externalFilePath = path.join(externalDir, 'secret.ts');
264
764
  const sourcePath = path.relative(fullPath, externalFilePath);
265
765
  const maliciousConfig = { ...githubFunction, filePath: sourcePath };
766
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [maliciousConfig] }));
266
767
  await fs.writeFile(externalFilePath, 'sensitive contents');
267
- await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([maliciousConfig]));
268
768
  await fs.writeFile(path.join(fullPath, 'build', tsToJsPath(sourcePath.replace(/\.ts$/, '.js'))), 'compiled function');
269
769
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
270
770
  expect(logOutput(logSpy)).toContain(`Function source path "${sourcePath}" must be within the project directory`);
@@ -276,8 +776,7 @@ describe('deploy', () => {
276
776
  });
277
777
  it('prints only legacy summary rows when no functions bundle', async () => {
278
778
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
279
- await fs.rm(path.join(fullPath, '.nango', 'functions.json'));
280
- fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
779
+ mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
281
780
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
282
781
  const output = logOutput(logSpy);
283
782
  expect(output).toContain('↳ Syncs');
@@ -286,81 +785,207 @@ describe('deploy', () => {
286
785
  expect(output).not.toContain('↳ Functions');
287
786
  expect(output).toContain('✓ Deployed');
288
787
  });
289
- it('prints no summary when functions are unchanged', async () => {
290
- vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
291
- fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
292
- await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
788
+ it.each([
789
+ { name: 'legacy', definitions: legacyOnlyParsed },
790
+ { name: 'functions', definitions: functionsOnlyParsed },
791
+ { name: 'mixed', definitions: parsed }
792
+ ])('prints No changes when the $name deployment is unchanged', async ({ definitions }) => {
793
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(definitions));
794
+ mockApi(fetchMock, { functionsPreview: noFunctionChanges, functionsDeploy: noFunctionChanges });
795
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
293
796
  const output = logOutput(logSpy);
294
- expect(output).toContain('No changes');
295
- expect(output).toContain('No function changes were deployed');
296
- expect(output).not.toContain('↳ Functions');
297
- expect(output).not.toContain('- github → fetch');
797
+ expect(result.isOk()).toBe(true);
798
+ expect(output.match(/No changes/g)).toHaveLength(2);
799
+ expect(output).toContain('No changes were deployed.');
800
+ expect(output).not.toContain('Successfully');
801
+ expect(output).not.toContain('Summary');
802
+ expect(output).not.toContain('↳');
803
+ expect(output).not.toContain('No destructive changes, proceeding');
298
804
  });
299
805
  it('prints Function preview errors', async () => {
300
806
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
301
- fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Preview failed' } }));
807
+ mockApi(fetchMock, { functionsPreview: { error: { code: 'functions_deployment_error', message: 'Preview failed' } } });
302
808
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
303
809
  expect(logOutput(logSpy)).toContain('Error checking functions state:\nPreview failed');
810
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
304
811
  });
305
812
  it('prints Function deploy errors', async () => {
306
813
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
307
- fetchMock
308
- .mockResolvedValueOnce(jsonResponse(noFunctionChanges))
309
- .mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Apply failed' } }));
814
+ mockApi(fetchMock, { functionsDeploy: { error: { code: 'functions_deployment_error', message: 'Apply failed' } } });
310
815
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
311
816
  expect(logOutput(logSpy)).toContain('Error deploying functions');
817
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
818
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
819
+ });
820
+ it('does not deploy legacy scripts when the preview fails', async () => {
821
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
822
+ mockApi(fetchMock, { legacyPreview: { error: { code: 'server_error', message: 'Preview failed' } } });
823
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
824
+ expect(result.isErr()).toBe(true);
825
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
826
+ expect(logOutput(logSpy)).toContain('Error checking state:\nPreview failed');
827
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
828
+ });
829
+ it('does not deploy when the selected integration does not exist', async () => {
830
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
831
+ mockApi(fetchMock, { functionsPreview: { error: { code: 'integration_not_found', message: 'Integration(s) not found: unknown' } } });
832
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ integration: 'unknown' }) });
833
+ expect(result.isErr()).toBe(true);
834
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
835
+ expect(logOutput(logSpy)).toContain('Integration(s) not found: unknown');
836
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
837
+ });
838
+ it('reports legacy deployment errors', async () => {
839
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
840
+ mockApi(fetchMock, { legacyDeploy: { error: { code: 'server_error', message: 'Apply failed' } } });
841
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
842
+ expect(result.isErr()).toBe(true);
843
+ expect(fetchMock).toHaveBeenCalledTimes(3);
844
+ expect(logOutput(logSpy)).toContain('Error deploying:\nApply failed');
845
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
846
+ });
847
+ it.each([
848
+ { name: 'a sync with connections', changes: { deletedSyncs: [{ providerConfigKey: 'github', name: 'removed', connections: 1, auto_start: false }] } },
849
+ { name: 'a model', changes: { deletedModels: ['RemovedModel'] } }
850
+ ])('requires destructive confirmation when removing $name', async ({ changes }) => {
851
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
852
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, ...changes } });
853
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
854
+ expect(result.isErr()).toBe(true);
855
+ expect(fetchMock).toHaveBeenCalledTimes(2);
856
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
857
+ });
858
+ it('deploys legacy destructive changes when confirmation flags are provided', async () => {
859
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
860
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
861
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
862
+ expect(result.isOk()).toBe(true);
863
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
864
+ expect(promptly.confirm).not.toHaveBeenCalled();
865
+ expect(logOutput(logSpy)).toContain('allowDestructive flag is on');
866
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
867
+ });
868
+ it('explains the required legacy confirmation flags when non-interactive', async () => {
869
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
870
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
871
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
872
+ expect(result.isErr()).toBe(true);
873
+ expect(logOutput(logSpy)).toContain('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting');
874
+ expect(promptly.confirm).not.toHaveBeenCalled();
875
+ expect(fetchMock).toHaveBeenCalledTimes(2);
876
+ });
877
+ it('prints the legacy abort message when confirmation is declined', async () => {
878
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
879
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
880
+ const result = await deploy({
881
+ fullPath,
882
+ environmentName: 'test',
883
+ interactive: true,
884
+ options: deployOptions({ autoConfirm: false, interactive: true })
885
+ });
886
+ expect(result.isErr()).toBe(true);
887
+ expect(logOutput(logSpy)).toContain('Deploy aborted. Exiting');
888
+ expect(promptly.confirm).toHaveBeenCalledTimes(1);
889
+ expect(fetchMock).toHaveBeenCalledTimes(2);
890
+ });
891
+ it('preserves the legacy version override in both preview and deployment', async () => {
892
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
893
+ mockApi(fetchMock);
894
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ version: '42' }) });
895
+ expect(result.isOk()).toBe(true);
896
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
897
+ const previewBody = requestBody(fetchMock, 'legacyPreview');
898
+ expect(previewBody).toMatchObject({ reconcile: false, flowConfigs: [{ version: '42' }] });
899
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconcile: true, flowConfigs: previewBody?.['flowConfigs'] });
900
+ expect(logOutput(logSpy)).not.toContain('The --version option');
312
901
  });
313
902
  it.each(['preview', 'deploy'])('prints backend validation details during function %s', async (stage) => {
314
903
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
315
- if (stage === 'deploy') {
316
- fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
317
- }
318
- fetchMock.mockResolvedValueOnce(jsonResponse({
319
- error: {
320
- code: 'invalid_body',
321
- errors: [
322
- { code: 'invalid_union', path: ['functions', 0, 'tags', 'required_scopes'], message: 'Expected a structured scope requirement' },
323
- { code: 'custom', path: [], message: 'Another validation error' }
324
- ]
904
+ mockApi(fetchMock, {
905
+ functionsPreview: noFunctionChanges,
906
+ [stage === 'preview' ? 'functionsPreview' : 'functionsDeploy']: {
907
+ error: {
908
+ code: 'invalid_body',
909
+ errors: [
910
+ { code: 'invalid_union', path: ['functions', 0, 'tags', 'required_scopes'], message: 'Expected a structured scope requirement' },
911
+ { code: 'custom', path: [], message: 'Another validation error' }
912
+ ]
913
+ }
325
914
  }
326
- }));
915
+ });
327
916
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
328
917
  const output = logOutput(logSpy);
329
918
  expect(output).toContain('invalid_body');
330
919
  expect(output).toContain('- functions.0.tags.required_scopes: Expected a structured scope requirement');
331
920
  expect(output).toContain('- Another validation error');
332
921
  expect(output).not.toContain('✓ Deployed');
333
- expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ? 1 : 2);
922
+ expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ? 2 : 3);
334
923
  });
335
924
  it('prints an aborted deployment when function deletions are not allowed', async () => {
336
925
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
337
- fetchMock.mockResolvedValueOnce(jsonResponse({
338
- created: [],
339
- updated: [],
340
- unchanged: [{ integrationId: 'github', name: 'fetch' }],
341
- deleted: [{ integrationId: 'github', name: 'removed' }]
342
- }));
926
+ mockApi(fetchMock, {
927
+ functionsPreview: {
928
+ created: [],
929
+ updated: [],
930
+ unchanged: [{ integrationId: 'github', name: 'fetch' }],
931
+ deleted: [{ integrationId: 'github', name: 'removed' }]
932
+ }
933
+ });
343
934
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
344
935
  const output = logOutput(logSpy);
345
936
  expect(output).toContain('↳ Functions');
346
- expect(output).toMatch(/Deploy aborted\. Exiting|Functions were not deployed/);
937
+ expect(output).toContain('Nothing was deployed.');
347
938
  expect(output).not.toContain('✓ Deployed');
939
+ expect(fetchMock).toHaveBeenCalledTimes(2);
348
940
  });
349
- it('prints a successful deployment when function deletions are allowed', async () => {
941
+ it.each([false, true])('reports function removals alongside updates: %s', async (withUpdates) => {
350
942
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
351
943
  const functionChanges = {
352
944
  created: [],
353
- updated: [],
945
+ updated: withUpdates ? [{ integrationId: 'slack', name: 'other' }] : [],
354
946
  unchanged: [{ integrationId: 'github', name: 'fetch' }],
355
947
  deleted: [{ integrationId: 'github', name: 'removed' }]
356
948
  };
357
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
949
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
358
950
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
359
951
  const output = logOutput(logSpy);
360
952
  expect(output).toContain('allowDestructive flag is on');
361
953
  expect(output).toContain('✓ Deployed');
362
- expect(output).toContain('Successfully removed the functions');
954
+ expect(output).toContain('Successfully removed:');
955
+ expect(output).toContain('- github → removed');
363
956
  expect(output).not.toContain('- github → fetch');
957
+ if (withUpdates) {
958
+ expect(output).toContain('Successfully deployed:');
959
+ expect(output).toContain('- slack → other');
960
+ }
961
+ else {
962
+ expect(output).not.toContain('Successfully deployed:');
963
+ }
964
+ });
965
+ it.each([false, true])('reports legacy removals alongside deployments: %s', async (withDeployments) => {
966
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
967
+ const changes = {
968
+ ...legacyConfirmation,
969
+ deletedSyncs: [{ providerConfigKey: 'github', name: 'old-sync', connections: 1, auto_start: false }],
970
+ deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }],
971
+ deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
972
+ };
973
+ mockApi(fetchMock, { legacyPreview: changes, legacyDeploy: withDeployments ? legacyDeploymentResult : [] });
974
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
975
+ const output = logOutput(logSpy);
976
+ expect(result.isOk()).toBe(true);
977
+ expect(output).toContain('Successfully removed:');
978
+ expect(output).toContain('- github → old-sync');
979
+ expect(output).toContain('- github → old-action');
980
+ expect(output).toContain('- github → old-event');
981
+ expect(output).not.toContain('No changes');
982
+ if (withDeployments) {
983
+ expect(output).toContain('Successfully deployed:');
984
+ expect(output).toContain('- github → legacy@v42');
985
+ }
986
+ else {
987
+ expect(output).not.toContain('Successfully deployed:');
988
+ }
364
989
  });
365
990
  });
366
991
  function functionConfig({ integrationId, name }) {
@@ -397,7 +1022,6 @@ async function writeProject(fullPath) {
397
1022
  await fs.mkdir(path.join(fullPath, 'github', 'actions'), { recursive: true });
398
1023
  await fs.mkdir(path.join(fullPath, 'github', 'functions'), { recursive: true });
399
1024
  await fs.mkdir(path.join(fullPath, 'slack', 'functions'), { recursive: true });
400
- await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction, slackFunction]));
401
1025
  await fs.writeFile(path.join(fullPath, 'build', 'github_actions_legacy.cjs'), 'compiled legacy');
402
1026
  await fs.writeFile(path.join(fullPath, 'github', 'actions', 'legacy.ts'), 'source legacy');
403
1027
  await fs.writeFile(path.join(fullPath, 'build', 'github_functions_fetch.cjs'), 'compiled function');
@@ -408,6 +1032,28 @@ async function writeProject(fullPath) {
408
1032
  function jsonResponse(value) {
409
1033
  return { json: vi.fn().mockResolvedValue(value) };
410
1034
  }
1035
+ function mockApi(fetchMock, responses = {}) {
1036
+ const defaults = {
1037
+ legacyPreview: legacyConfirmation,
1038
+ legacyDeploy: [],
1039
+ functionsPreview: emptyFunctionChanges,
1040
+ functionsDeploy: emptyFunctionChanges
1041
+ };
1042
+ fetchMock.mockImplementation((url) => {
1043
+ const route = Object.keys(apiRoutes).find((key) => apiRoutes[key] === url.pathname);
1044
+ if (!route) {
1045
+ return Promise.reject(new Error(`Unexpected request to ${url.pathname}`));
1046
+ }
1047
+ return Promise.resolve(jsonResponse(responses[route] ?? defaults[route]));
1048
+ });
1049
+ }
1050
+ function requestPaths(fetchMock) {
1051
+ return fetchMock.mock.calls.map(([url]) => url.pathname);
1052
+ }
1053
+ function requestBody(fetchMock, route) {
1054
+ const call = fetchMock.mock.calls.find(([url]) => url.pathname === apiRoutes[route]);
1055
+ return call ? JSON.parse(call[1].body) : undefined;
1056
+ }
411
1057
  function logOutput(logSpy) {
412
1058
  const calls = logSpy.mock.calls;
413
1059
  return calls.map((args) => args.map(String).join(' ')).join('\n');