nango 0.72.1 → 0.72.2

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: [],
@@ -99,11 +140,203 @@ describe('deploy', () => {
99
140
  vi.restoreAllMocks();
100
141
  await fs.rm(fullPath, { recursive: true, force: true });
101
142
  });
102
- it('prints an error for a mixed deploy', async () => {
103
- await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
143
+ it('previews both families before deploying them in order with one summary', async () => {
144
+ const functionChanges = { ...noFunctionChanges, updated: [{ integrationId: 'github', name: 'fetch' }] };
145
+ fetchMock
146
+ .mockResolvedValueOnce(jsonResponse({ ...legacyChangeConfirmation, deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }] }))
147
+ .mockResolvedValueOnce(jsonResponse(functionChanges))
148
+ .mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
149
+ .mockResolvedValueOnce(jsonResponse(functionChanges));
150
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
151
+ expect(result.isOk()).toBe(true);
152
+ expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([
153
+ '/sync/deploy/confirmation',
154
+ '/functions/deployments/bundle/preview',
155
+ '/sync/deploy',
156
+ '/functions/deployments/bundle'
157
+ ]);
104
158
  const output = logOutput(logSpy);
105
- expect(output).toContain('Legacy scripts and functions cannot be deployed together');
159
+ expect(output.match(/Nango will perform this plan:/g)).toHaveLength(1);
160
+ expect(output.match(/Summary/g)).toHaveLength(1);
161
+ expect(output).toContain('↳ Actions');
162
+ expect(output).toContain('↳ Functions');
163
+ expect(output).toContain('✓ Deployed');
164
+ expect(output.match(/Successfully deployed:/g)).toHaveLength(1);
165
+ expect(output).toContain('- github → legacy@v42');
166
+ expect(output).toContain('- github → fetch');
167
+ expect(output).toContain('Successfully removed:');
168
+ expect(output).toContain('- github → old-action');
169
+ expect(output.indexOf('✓ Deployed')).toBeLessThan(output.indexOf('Successfully deployed:'));
170
+ expect(promptly.confirm).not.toHaveBeenCalled();
171
+ });
172
+ it('scopes both families to the selected integration and only overrides sync/action versions', async () => {
173
+ fetchMock
174
+ .mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation))
175
+ .mockResolvedValueOnce(jsonResponse(noFunctionChanges))
176
+ .mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
177
+ .mockResolvedValueOnce(jsonResponse(noFunctionChanges));
178
+ const result = await deploy({
179
+ fullPath,
180
+ environmentName: 'test',
181
+ interactive: false,
182
+ options: deployOptions({ integration: 'github', version: '42' })
183
+ });
184
+ expect(result.isOk()).toBe(true);
185
+ const bodies = fetchMock.mock.calls.map(([, request]) => JSON.parse(request.body));
186
+ expect(bodies[0]).toMatchObject({ deployMode: 'integration', flowConfigs: [{ providerConfigKey: 'github', version: '42' }] });
187
+ expect(bodies[1]).toMatchObject({
188
+ reconciliationScope: { kind: 'integration', integrationId: 'github' },
189
+ functions: [{ integrationId: 'github', name: 'fetch' }]
190
+ });
191
+ expect(bodies[2]).toMatchObject({ flowConfigs: bodies[0]?.['flowConfigs'] });
192
+ expect(bodies[3]).toEqual(bodies[1]);
193
+ expect(logOutput(logSpy).match(/The --version option/g)).toHaveLength(1);
194
+ expect(logOutput(logSpy).indexOf('✓ Packaging')).toBeLessThan(logOutput(logSpy).indexOf('The --version option'));
195
+ expect(logOutput(logSpy)).not.toContain('No changes');
196
+ expect(logOutput(logSpy)).toContain('- github → legacy@v42');
197
+ });
198
+ it.each([
199
+ { name: 'unknown integration', options: { integration: 'missing' }, message: 'No local definitions found for integration "missing"' },
200
+ { name: 'conflicting selectors', options: { sync: 'contacts', action: 'legacy' }, message: 'The --sync and --action options cannot be used together' }
201
+ ])('rejects $name without contacting the API', async ({ options, message }) => {
202
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(options) });
203
+ expect(result.isErr()).toBe(true);
204
+ expect(logOutput(logSpy)).toContain(message);
205
+ expect(fetchMock).not.toHaveBeenCalled();
206
+ });
207
+ it.each(['legacy', 'functions'])('does not contact the API when %s packaging fails in a mixed project', async (family) => {
208
+ if (family === 'legacy') {
209
+ await fs.rm(path.join(fullPath, 'github', 'actions', 'legacy.ts'));
210
+ }
211
+ else {
212
+ await fs.rm(path.join(fullPath, 'build', 'github_functions_fetch.cjs'));
213
+ }
214
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
215
+ expect(result.isErr()).toBe(true);
216
+ expect(fetchMock).not.toHaveBeenCalled();
217
+ });
218
+ it.each(['legacy', 'functions'])('does not deploy either family when the %s preview fails', async (family) => {
219
+ const previewError = jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } });
220
+ fetchMock
221
+ .mockResolvedValueOnce(family === 'legacy' ? previewError : jsonResponse(legacyConfirmation))
222
+ .mockResolvedValueOnce(family === 'functions' ? previewError : jsonResponse(noFunctionChanges));
223
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
224
+ expect(result.isErr()).toBe(true);
225
+ expect(fetchMock).toHaveBeenCalledTimes(2);
226
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
227
+ });
228
+ it.each(['legacy', 'functions'])('clearly reports each outcome when the %s deployment fails', async (family) => {
229
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
230
+ if (family === 'functions') {
231
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult)).mockRejectedValueOnce(new Error('Connection lost'));
232
+ }
233
+ else {
234
+ fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
235
+ }
236
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
237
+ expect(result.isErr()).toBe(true);
238
+ expect(fetchMock).toHaveBeenCalledTimes(family === 'legacy' ? 3 : 4);
239
+ const output = logOutput(logSpy);
240
+ expect(output).toContain(`Syncs/actions/on-events: ${family === 'legacy' ? 'failed' : 'completed'}`);
241
+ expect(output).toContain(`Functions: ${family === 'legacy' ? 'not attempted' : 'failed'}`);
242
+ expect(output).toContain(family === 'legacy' ? 'Deployment failed.' : 'Deployment partially completed.');
243
+ expect(output).toContain('The failed request may have applied changes');
106
244
  expect(output).not.toContain('✓ Deployed');
245
+ if (family === 'functions') {
246
+ expect(output).toContain('- github → legacy@v42');
247
+ expect(output.indexOf('✗ Deploying')).toBeLessThan(output.indexOf('Successfully deployed:'));
248
+ }
249
+ });
250
+ it.each([
251
+ {
252
+ name: 'interactive approval',
253
+ interactive: true,
254
+ autoConfirm: false,
255
+ allowDestructive: false,
256
+ accepted: true,
257
+ succeeds: true,
258
+ prompts: 1
259
+ },
260
+ {
261
+ name: 'interactive rejection',
262
+ interactive: true,
263
+ autoConfirm: false,
264
+ allowDestructive: false,
265
+ accepted: false,
266
+ succeeds: false,
267
+ prompts: 1
268
+ },
269
+ {
270
+ name: 'noninteractive auto-confirm alone',
271
+ interactive: false,
272
+ autoConfirm: true,
273
+ allowDestructive: false,
274
+ accepted: false,
275
+ succeeds: false,
276
+ prompts: 0
277
+ },
278
+ {
279
+ name: 'noninteractive allow-destructive alone',
280
+ interactive: false,
281
+ autoConfirm: false,
282
+ allowDestructive: true,
283
+ accepted: false,
284
+ succeeds: false,
285
+ prompts: 0
286
+ },
287
+ {
288
+ name: 'noninteractive approval flags',
289
+ interactive: false,
290
+ autoConfirm: true,
291
+ allowDestructive: true,
292
+ accepted: false,
293
+ succeeds: true,
294
+ prompts: 0
295
+ }
296
+ ])('handles mixed destructive changes with $name', async ({ interactive, autoConfirm, allowDestructive, accepted, succeeds, prompts }) => {
297
+ const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
298
+ fetchMock
299
+ .mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }))
300
+ .mockResolvedValueOnce(jsonResponse(changes))
301
+ .mockResolvedValueOnce(jsonResponse([]))
302
+ .mockResolvedValueOnce(jsonResponse(changes));
303
+ vi.mocked(promptly.confirm).mockImplementationOnce(() => {
304
+ expect(fetchMock).toHaveBeenCalledTimes(2);
305
+ expect(logOutput(logSpy)).toContain('↳ Actions');
306
+ expect(logOutput(logSpy)).toContain('↳ Functions');
307
+ return Promise.resolve(accepted);
308
+ });
309
+ const result = await deploy({ fullPath, environmentName: 'test', interactive, options: deployOptions({ interactive, autoConfirm, allowDestructive }) });
310
+ expect(result.isOk()).toBe(succeeds);
311
+ expect(promptly.confirm).toHaveBeenCalledTimes(prompts);
312
+ expect(fetchMock).toHaveBeenCalledTimes(succeeds ? 4 : 2);
313
+ });
314
+ it.each(['legacy', 'functions'])('requires confirmation when only %s changes are destructive', async (family) => {
315
+ fetchMock
316
+ .mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: family === 'legacy' ? ['RemovedModel'] : [] }))
317
+ .mockResolvedValueOnce(jsonResponse({ ...noFunctionChanges, deleted: family === 'functions' ? [{ integrationId: 'github', name: 'removed' }] : [] }));
318
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
319
+ expect(result.isErr()).toBe(true);
320
+ expect(fetchMock).toHaveBeenCalledTimes(2);
321
+ expect(promptly.confirm).not.toHaveBeenCalled();
322
+ });
323
+ it('accepts the environment auto-confirm setting for a mixed deployment', async () => {
324
+ vi.stubEnv('NANGO_DEPLOY_AUTO_CONFIRM', 'true');
325
+ const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
326
+ fetchMock
327
+ .mockResolvedValueOnce(jsonResponse(legacyConfirmation))
328
+ .mockResolvedValueOnce(jsonResponse(changes))
329
+ .mockResolvedValueOnce(jsonResponse([]))
330
+ .mockResolvedValueOnce(jsonResponse(changes));
331
+ const result = await deploy({
332
+ fullPath,
333
+ environmentName: 'test',
334
+ interactive: false,
335
+ options: deployOptions({ autoConfirm: false, allowDestructive: true })
336
+ });
337
+ expect(result.isOk()).toBe(true);
338
+ expect(fetchMock).toHaveBeenCalledTimes(4);
339
+ expect(promptly.confirm).not.toHaveBeenCalled();
107
340
  });
108
341
  it('prints only legacy summary rows for a single action deploy', async () => {
109
342
  fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
@@ -119,6 +352,168 @@ describe('deploy', () => {
119
352
  expect(output).toContain('↳ OnEvents');
120
353
  expect(output).not.toContain('↳ Functions');
121
354
  expect(output).toContain('✓ Deployed');
355
+ expect(output).toContain('No destructive changes, proceeding without confirmation');
356
+ expect(output).toContain('--sync and --action are deprecated');
357
+ });
358
+ describe('with a file', () => {
359
+ const functionChanges = {
360
+ created: [{ integrationId: 'github', name: 'fetch' }],
361
+ updated: [],
362
+ unchanged: [],
363
+ deleted: []
364
+ };
365
+ it.each([
366
+ { label: 'a relative path', file: () => 'github/functions/fetch.ts' },
367
+ { label: 'a ./ relative path', file: () => './github/functions/fetch.ts' },
368
+ { label: 'a path with backslashes', file: () => 'github\\functions\\fetch.ts' },
369
+ { label: 'a .js path', file: () => 'github/functions/fetch.js' },
370
+ { label: 'an absolute path', file: () => path.join(fullPath, 'github', 'functions', 'fetch.ts') }
371
+ ])('deploys only that function from $label in a mixed project', async ({ file }) => {
372
+ fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
373
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: file() });
374
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
375
+ for (const [, request] of fetchMock.mock.calls) {
376
+ const body = JSON.parse(request.body);
377
+ expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'fetch' });
378
+ expect(body.functions).toEqual([expect.objectContaining({ integrationId: 'github', name: 'fetch' })]);
379
+ }
380
+ expect(fetchMock).toHaveBeenCalledTimes(2);
381
+ });
382
+ it('deploys a tool under its declared name', async () => {
383
+ const toolConfig = { ...functionConfig({ integrationId: 'github', name: 'get_issue' }), filePath: './github/tools/getIssue.ts' };
384
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
385
+ ...parsed,
386
+ functions: [githubFunction, toolConfig],
387
+ entryPoints: [...parsed.entryPoints, { filePath: toolConfig.filePath, integrationId: 'github', type: 'function', name: 'get_issue' }]
388
+ }));
389
+ await fs.mkdir(path.join(fullPath, 'github', 'tools'), { recursive: true });
390
+ await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction, toolConfig]));
391
+ await fs.writeFile(path.join(fullPath, 'github', 'tools', 'getIssue.ts'), 'source tool');
392
+ await fs.writeFile(path.join(fullPath, 'build', 'github_tools_getIssue.cjs'), 'compiled tool');
393
+ const toolChanges = { ...functionChanges, created: [{ integrationId: 'github', name: 'get_issue' }] };
394
+ fetchMock.mockResolvedValueOnce(jsonResponse(toolChanges)).mockResolvedValueOnce(jsonResponse(toolChanges));
395
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/tools/getIssue.ts' });
396
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
397
+ const previewRequest = fetchMock.mock.calls[0]?.[1];
398
+ const previewBody = JSON.parse(previewRequest.body);
399
+ expect(previewBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'get_issue' });
400
+ expect(previewBody.functions).toEqual([expect.objectContaining({ name: 'get_issue', fileBody: { js: 'compiled tool', ts: 'source tool' } })]);
401
+ });
402
+ it('deploys a legacy action as a single script', async () => {
403
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
404
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/actions/legacy.ts' });
405
+ const output = logOutput(logSpy);
406
+ expect(output).toContain('✓ Deployed');
407
+ expect(output).not.toContain('--sync and --action are deprecated');
408
+ const confirmationRequest = fetchMock.mock.calls[0]?.[1];
409
+ const confirmationBody = JSON.parse(confirmationRequest.body);
410
+ expect(confirmationBody.deployMode).toBe('single');
411
+ expect(confirmationBody.onEventScriptsByProvider).toBeUndefined();
412
+ expect(confirmationBody.flowConfigs).toEqual([expect.objectContaining({ syncName: 'legacy', providerConfigKey: 'github', type: 'action' })]);
413
+ });
414
+ describe('in a project with legacy scripts sharing names across integrations', () => {
415
+ beforeEach(async () => {
416
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(multiIntegrationLegacyParsed));
417
+ await fs.mkdir(path.join(fullPath, 'github', 'syncs'), { recursive: true });
418
+ await fs.mkdir(path.join(fullPath, 'slack', 'actions'), { recursive: true });
419
+ await fs.writeFile(path.join(fullPath, 'build', 'github_syncs_contacts.cjs'), 'compiled contacts');
420
+ await fs.writeFile(path.join(fullPath, 'github', 'syncs', 'contacts.ts'), 'source contacts');
421
+ await fs.writeFile(path.join(fullPath, 'build', 'slack_actions_legacy.cjs'), 'compiled slack legacy');
422
+ await fs.writeFile(path.join(fullPath, 'slack', 'actions', 'legacy.ts'), 'source slack legacy');
423
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
424
+ });
425
+ it('deploys a legacy sync as a single script', async () => {
426
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/syncs/contacts.ts' });
427
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
428
+ const confirmationRequest = fetchMock.mock.calls[0]?.[1];
429
+ const confirmationBody = JSON.parse(confirmationRequest.body);
430
+ expect(confirmationBody.deployMode).toBe('single');
431
+ expect(confirmationBody.flowConfigs).toEqual([
432
+ expect.objectContaining({
433
+ syncName: 'contacts',
434
+ providerConfigKey: 'github',
435
+ type: 'sync',
436
+ fileBody: { js: 'compiled contacts', ts: 'source contacts' }
437
+ })
438
+ ]);
439
+ });
440
+ it("deploys only the file's integration when another integration has an action with the same name", async () => {
441
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'slack/actions/legacy.ts' });
442
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
443
+ const confirmationRequest = fetchMock.mock.calls[0]?.[1];
444
+ const confirmationBody = JSON.parse(confirmationRequest.body);
445
+ expect(confirmationBody.flowConfigs).toEqual([
446
+ expect.objectContaining({
447
+ syncName: 'legacy',
448
+ providerConfigKey: 'slack',
449
+ type: 'action',
450
+ fileBody: { js: 'compiled slack legacy', ts: 'source slack legacy' }
451
+ })
452
+ ]);
453
+ });
454
+ });
455
+ it.each([
456
+ { label: 'an unknown file', file: 'github/functions/missing.ts', message: "'github/functions/missing.ts' is not a script" },
457
+ { label: 'a file outside the project', file: '../outside.ts', message: "'../outside.ts' must be inside the project folder" },
458
+ { label: 'an absolute path outside the project', file: '/outside.ts', message: "'/outside.ts' must be inside the project folder" },
459
+ {
460
+ label: 'an on-event script',
461
+ file: 'github/on-events/cleanup.ts',
462
+ message: "can't be deployed on its own. Use `nango deploy <environment> --integration github`",
463
+ entryPoint: { filePath: './github/on-events/cleanup.ts', integrationId: 'github', type: 'on-event', name: 'cleanup' }
464
+ }
465
+ ])('rejects $label', async ({ file, message, entryPoint }) => {
466
+ if (entryPoint) {
467
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...parsed, entryPoints: [...parsed.entryPoints, entryPoint] }));
468
+ }
469
+ const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
470
+ expect(res.isErr()).toBe(true);
471
+ expect(logOutput(logSpy)).toContain(message);
472
+ expect(fetchMock).not.toHaveBeenCalled();
473
+ });
474
+ it.each([
475
+ { flag: '--sync', options: { sync: 'legacy' } },
476
+ { flag: '--action', options: { action: 'legacy' } },
477
+ { flag: '--integration', options: { integration: 'github' } }
478
+ ])('rejects a file combined with $flag', async ({ options }) => {
479
+ const res = await deploy({
480
+ fullPath,
481
+ environmentName: 'test',
482
+ interactive: false,
483
+ options: deployOptions(options),
484
+ file: 'github/functions/fetch.ts'
485
+ });
486
+ expect(res.isErr()).toBe(true);
487
+ expect(logOutput(logSpy)).toContain('A file cannot be combined with --sync, --action or --integration');
488
+ expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
489
+ expect(fetchMock).not.toHaveBeenCalled();
490
+ });
491
+ it.each(['', ' '])('rejects an empty file argument %j instead of deploying everything', async (file) => {
492
+ const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
493
+ expect(res.isErr()).toBe(true);
494
+ expect(logOutput(logSpy)).toContain('The file argument is empty');
495
+ expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
496
+ expect(fetchMock).not.toHaveBeenCalled();
497
+ });
498
+ it('rejects a file passed in place of the environment', async () => {
499
+ const res = await deploy({ fullPath, environmentName: 'github/syncs/fetchIssues.ts', interactive: false, options: deployOptions() });
500
+ expect(res.isErr()).toBe(true);
501
+ expect(logOutput(logSpy)).toContain("'github/syncs/fetchIssues.ts' is not a valid environment name");
502
+ expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
503
+ expect(fetchMock).not.toHaveBeenCalled();
504
+ });
505
+ it('does not deploy when the server rejects the function scope', async () => {
506
+ fetchMock.mockResolvedValueOnce(jsonResponse({
507
+ error: {
508
+ code: 'invalid_body',
509
+ errors: [{ code: 'invalid_union_discriminator', message: 'Invalid discriminator value', path: ['reconciliationScope', 'kind'] }]
510
+ }
511
+ }));
512
+ const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
513
+ expect(res.isErr()).toBe(true);
514
+ expect(logOutput(logSpy)).toContain('reconciliationScope.kind: Invalid discriminator value');
515
+ expect(fetchMock).toHaveBeenCalledTimes(1);
516
+ });
122
517
  });
123
518
  it.each([
124
519
  { flag: '--sync', options: { sync: 'missing' } },
@@ -152,9 +547,9 @@ describe('deploy', () => {
152
547
  expect(previewBody.reconciliationScope).toEqual({ kind: 'integration', integrationId: 'slack' });
153
548
  expect(previewBody.functions).toEqual([expect.objectContaining({ integrationId: 'slack', name: 'other', trigger: { kind: 'http' } })]);
154
549
  });
155
- it('deletes the functions of an integration when its last function was removed', async () => {
550
+ // TODO: Re-enable these tests when empty deployments can delete the last definition for both legacy scripts and functions.
551
+ it.skip('deletes the functions of an integration when its last function was removed', async () => {
156
552
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [githubFunction] }));
157
- await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction]));
158
553
  const functionChanges = {
159
554
  created: [],
160
555
  updated: [],
@@ -171,11 +566,10 @@ describe('deploy', () => {
171
566
  const output = logOutput(logSpy);
172
567
  expect(output).toContain('- slack → other');
173
568
  expect(output).toContain('✓ Deployed');
174
- expect(output).toContain('Successfully removed the functions');
569
+ expect(output).toContain('Successfully removed:');
175
570
  });
176
- it('deletes every function of the environment when the last function was removed', async () => {
571
+ it.skip('deletes every function of the environment when the last function was removed', async () => {
177
572
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
178
- await fs.rm(path.join(fullPath, '.nango', 'functions.json'));
179
573
  const functionChanges = {
180
574
  created: [],
181
575
  updated: [],
@@ -191,18 +585,20 @@ describe('deploy', () => {
191
585
  expect(output).toContain('- github → fetch');
192
586
  expect(output).toContain('- slack → other');
193
587
  expect(output).toContain('✓ Deployed');
194
- expect(output).toContain('Successfully removed the functions');
588
+ expect(output).toContain('Successfully removed:');
195
589
  });
196
- it('rejects --version for native function deployments', async () => {
590
+ it('warns about --version for function deployments and still deploys', async () => {
197
591
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
198
- await deploy({
592
+ fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
593
+ const result = await deploy({
199
594
  fullPath,
200
595
  environmentName: 'test',
201
596
  interactive: false,
202
597
  options: deployOptions({ version: 'v1' })
203
598
  });
204
- expect(logOutput(logSpy)).toContain('The --version option can only be used with legacy scripts');
205
- expect(fetchMock).not.toHaveBeenCalled();
599
+ expect(result.isOk()).toBe(true);
600
+ expect(logOutput(logSpy)).toContain('The --version option only applies to syncs/actions; it has no effect on functions.');
601
+ expect(fetchMock).toHaveBeenCalledTimes(2);
206
602
  });
207
603
  it('prints only the Functions summary row for a functions-only project', async () => {
208
604
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
@@ -227,7 +623,6 @@ describe('deploy', () => {
227
623
  const config = functionConfig({ integrationId: 'github.js', name: 'fetch' });
228
624
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [config] }));
229
625
  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
626
  await fs.writeFile(path.join(fullPath, 'github.js', 'functions', 'fetch.ts'), 'source with dotted path');
232
627
  await fs.writeFile(path.join(fullPath, 'build', 'github.js_functions_fetch.cjs'), 'compiled with dotted path');
233
628
  const functionChanges = {
@@ -243,28 +638,14 @@ describe('deploy', () => {
243
638
  const previewBody = JSON.parse(previewRequest.body);
244
639
  expect(previewBody.functions[0]?.fileBody).toEqual({ js: 'compiled with dotted path', ts: 'source with dotted path' });
245
640
  });
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
641
  it('rejects function source paths outside the project directory', async () => {
260
- vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
261
642
  const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nango-cli-external-'));
262
643
  try {
263
644
  const externalFilePath = path.join(externalDir, 'secret.ts');
264
645
  const sourcePath = path.relative(fullPath, externalFilePath);
265
646
  const maliciousConfig = { ...githubFunction, filePath: sourcePath };
647
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [maliciousConfig] }));
266
648
  await fs.writeFile(externalFilePath, 'sensitive contents');
267
- await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([maliciousConfig]));
268
649
  await fs.writeFile(path.join(fullPath, 'build', tsToJsPath(sourcePath.replace(/\.ts$/, '.js'))), 'compiled function');
269
650
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
270
651
  expect(logOutput(logSpy)).toContain(`Function source path "${sourcePath}" must be within the project directory`);
@@ -276,8 +657,7 @@ describe('deploy', () => {
276
657
  });
277
658
  it('prints only legacy summary rows when no functions bundle', async () => {
278
659
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
279
- await fs.rm(path.join(fullPath, '.nango', 'functions.json'));
280
- fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
660
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
281
661
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
282
662
  const output = logOutput(logSpy);
283
663
  expect(output).toContain('↳ Syncs');
@@ -286,21 +666,36 @@ describe('deploy', () => {
286
666
  expect(output).not.toContain('↳ Functions');
287
667
  expect(output).toContain('✓ Deployed');
288
668
  });
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() });
669
+ it.each([
670
+ { name: 'legacy', definitions: legacyOnlyParsed },
671
+ { name: 'functions', definitions: functionsOnlyParsed },
672
+ { name: 'mixed', definitions: parsed }
673
+ ])('prints No changes when the $name deployment is unchanged', async ({ name, definitions }) => {
674
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(definitions));
675
+ fetchMock.mockResolvedValueOnce(jsonResponse(name === 'functions' ? noFunctionChanges : legacyConfirmation));
676
+ if (name === 'mixed') {
677
+ fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
678
+ }
679
+ fetchMock.mockResolvedValueOnce(jsonResponse(name === 'functions' ? noFunctionChanges : []));
680
+ if (name === 'mixed') {
681
+ fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
682
+ }
683
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
293
684
  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');
685
+ expect(result.isOk()).toBe(true);
686
+ expect(output.match(/No changes/g)).toHaveLength(2);
687
+ expect(output).toContain('No changes were deployed.');
688
+ expect(output).not.toContain('Successfully');
689
+ expect(output).not.toContain('Summary');
690
+ expect(output).not.toContain('↳');
691
+ expect(output).not.toContain('No destructive changes, proceeding');
298
692
  });
299
693
  it('prints Function preview errors', async () => {
300
694
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
301
695
  fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Preview failed' } }));
302
696
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
303
697
  expect(logOutput(logSpy)).toContain('Error checking functions state:\nPreview failed');
698
+ expect(fetchMock).toHaveBeenCalledTimes(1);
304
699
  });
305
700
  it('prints Function deploy errors', async () => {
306
701
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
@@ -309,6 +704,86 @@ describe('deploy', () => {
309
704
  .mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Apply failed' } }));
310
705
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
311
706
  expect(logOutput(logSpy)).toContain('Error deploying functions');
707
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
708
+ expect(fetchMock).toHaveBeenCalledTimes(2);
709
+ });
710
+ it('does not deploy legacy scripts when the preview fails', async () => {
711
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
712
+ fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } }));
713
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
714
+ expect(result.isErr()).toBe(true);
715
+ expect(fetchMock).toHaveBeenCalledTimes(1);
716
+ expect(logOutput(logSpy)).toContain('Error checking state:\nPreview failed');
717
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
718
+ });
719
+ it('reports legacy deployment errors', async () => {
720
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
721
+ fetchMock
722
+ .mockResolvedValueOnce(jsonResponse(legacyConfirmation))
723
+ .mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
724
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
725
+ expect(result.isErr()).toBe(true);
726
+ expect(fetchMock).toHaveBeenCalledTimes(2);
727
+ expect(logOutput(logSpy)).toContain('Error deploying:\nApply failed');
728
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
729
+ });
730
+ it.each([
731
+ { name: 'a sync with connections', changes: { deletedSyncs: [{ providerConfigKey: 'github', name: 'removed', connections: 1, auto_start: false }] } },
732
+ { name: 'a model', changes: { deletedModels: ['RemovedModel'] } }
733
+ ])('requires destructive confirmation when removing $name', async ({ changes }) => {
734
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
735
+ fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, ...changes }));
736
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
737
+ expect(result.isErr()).toBe(true);
738
+ expect(fetchMock).toHaveBeenCalledTimes(1);
739
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
740
+ });
741
+ it('deploys legacy destructive changes when confirmation flags are provided', async () => {
742
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
743
+ fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] })).mockResolvedValueOnce(jsonResponse([]));
744
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
745
+ expect(result.isOk()).toBe(true);
746
+ expect(fetchMock).toHaveBeenCalledTimes(2);
747
+ expect(promptly.confirm).not.toHaveBeenCalled();
748
+ expect(logOutput(logSpy)).toContain('allowDestructive flag is on');
749
+ expect(logOutput(logSpy)).toContain('✓ Deployed');
750
+ });
751
+ it('explains the required legacy confirmation flags when non-interactive', async () => {
752
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
753
+ fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }));
754
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
755
+ expect(result.isErr()).toBe(true);
756
+ expect(logOutput(logSpy)).toContain('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting');
757
+ expect(promptly.confirm).not.toHaveBeenCalled();
758
+ expect(fetchMock).toHaveBeenCalledTimes(1);
759
+ });
760
+ it('prints the legacy abort message when confirmation is declined', async () => {
761
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
762
+ fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }));
763
+ const result = await deploy({
764
+ fullPath,
765
+ environmentName: 'test',
766
+ interactive: true,
767
+ options: deployOptions({ autoConfirm: false, interactive: true })
768
+ });
769
+ expect(result.isErr()).toBe(true);
770
+ expect(logOutput(logSpy)).toContain('Deploy aborted. Exiting');
771
+ expect(promptly.confirm).toHaveBeenCalledTimes(1);
772
+ expect(fetchMock).toHaveBeenCalledTimes(1);
773
+ });
774
+ it('preserves the legacy version override in both preview and deployment', async () => {
775
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
776
+ fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
777
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ version: '42' }) });
778
+ expect(result.isOk()).toBe(true);
779
+ expect(fetchMock).toHaveBeenCalledTimes(2);
780
+ const previewRequest = fetchMock.mock.calls[0]?.[1];
781
+ const deployRequest = fetchMock.mock.calls[1]?.[1];
782
+ const previewBody = JSON.parse(previewRequest.body);
783
+ const deployBody = JSON.parse(deployRequest.body);
784
+ expect(previewBody).toMatchObject({ reconcile: false, flowConfigs: [{ version: '42' }] });
785
+ expect(deployBody).toMatchObject({ reconcile: true, flowConfigs: previewBody.flowConfigs });
786
+ expect(logOutput(logSpy)).not.toContain('The --version option');
312
787
  });
313
788
  it.each(['preview', 'deploy'])('prints backend validation details during function %s', async (stage) => {
314
789
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
@@ -343,14 +818,15 @@ describe('deploy', () => {
343
818
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
344
819
  const output = logOutput(logSpy);
345
820
  expect(output).toContain('↳ Functions');
346
- expect(output).toMatch(/Deploy aborted\. Exiting|Functions were not deployed/);
821
+ expect(output).toContain('Nothing was deployed.');
347
822
  expect(output).not.toContain('✓ Deployed');
823
+ expect(fetchMock).toHaveBeenCalledTimes(1);
348
824
  });
349
- it('prints a successful deployment when function deletions are allowed', async () => {
825
+ it.each([false, true])('reports function removals alongside updates: %s', async (withUpdates) => {
350
826
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
351
827
  const functionChanges = {
352
828
  created: [],
353
- updated: [],
829
+ updated: withUpdates ? [{ integrationId: 'slack', name: 'other' }] : [],
354
830
  unchanged: [{ integrationId: 'github', name: 'fetch' }],
355
831
  deleted: [{ integrationId: 'github', name: 'removed' }]
356
832
  };
@@ -359,8 +835,41 @@ describe('deploy', () => {
359
835
  const output = logOutput(logSpy);
360
836
  expect(output).toContain('allowDestructive flag is on');
361
837
  expect(output).toContain('✓ Deployed');
362
- expect(output).toContain('Successfully removed the functions');
838
+ expect(output).toContain('Successfully removed:');
839
+ expect(output).toContain('- github → removed');
363
840
  expect(output).not.toContain('- github → fetch');
841
+ if (withUpdates) {
842
+ expect(output).toContain('Successfully deployed:');
843
+ expect(output).toContain('- slack → other');
844
+ }
845
+ else {
846
+ expect(output).not.toContain('Successfully deployed:');
847
+ }
848
+ });
849
+ it.each([false, true])('reports legacy removals alongside deployments: %s', async (withDeployments) => {
850
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
851
+ const changes = {
852
+ ...legacyConfirmation,
853
+ deletedSyncs: [{ providerConfigKey: 'github', name: 'old-sync', connections: 1, auto_start: false }],
854
+ deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }],
855
+ deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
856
+ };
857
+ fetchMock.mockResolvedValueOnce(jsonResponse(changes)).mockResolvedValueOnce(jsonResponse(withDeployments ? legacyDeploymentResult : []));
858
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
859
+ const output = logOutput(logSpy);
860
+ expect(result.isOk()).toBe(true);
861
+ expect(output).toContain('Successfully removed:');
862
+ expect(output).toContain('- github → old-sync');
863
+ expect(output).toContain('- github → old-action');
864
+ expect(output).toContain('- github → old-event');
865
+ expect(output).not.toContain('No changes');
866
+ if (withDeployments) {
867
+ expect(output).toContain('Successfully deployed:');
868
+ expect(output).toContain('- github → legacy@v42');
869
+ }
870
+ else {
871
+ expect(output).not.toContain('Successfully deployed:');
872
+ }
364
873
  });
365
874
  });
366
875
  function functionConfig({ integrationId, name }) {
@@ -397,7 +906,6 @@ async function writeProject(fullPath) {
397
906
  await fs.mkdir(path.join(fullPath, 'github', 'actions'), { recursive: true });
398
907
  await fs.mkdir(path.join(fullPath, 'github', 'functions'), { recursive: true });
399
908
  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
909
  await fs.writeFile(path.join(fullPath, 'build', 'github_actions_legacy.cjs'), 'compiled legacy');
402
910
  await fs.writeFile(path.join(fullPath, 'github', 'actions', 'legacy.ts'), 'source legacy');
403
911
  await fs.writeFile(path.join(fullPath, 'build', 'github_functions_fetch.cjs'), 'compiled function');