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.
@@ -3,19 +3,32 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import columnify from 'columnify';
5
5
  import promptly from 'promptly';
6
+ import * as z from 'zod';
6
7
  import { cliFetch } from '../tls.js';
7
- import { getCliHeaders, isCI, parseSecretKey, printDebug, resolveHostport } from '../utils.js';
8
+ import { getCliHeaders, parseSecretKey, printDebug, resolveHostport } from '../utils.js';
8
9
  import { Err, Ok } from '../utils/result.js';
9
10
  import { Spinner } from '../utils/spinner.js';
10
11
  import { NANGO_VERSION } from '../version.js';
11
12
  import { tsToJsPath } from './compile.js';
12
13
  import { parseIntegrationDefinitions } from './definitions.js';
13
- import { resolveProjectPath } from './project-path.js';
14
+ import { normalizeProjectRelativePath, resolveProjectPath } from './project-path.js';
14
15
  import { ReadableError } from './utils.js';
15
- export async function deploy({ fullPath, options, environmentName, interactive = true }) {
16
+ export async function deploy({ fullPath, options, environmentName, file, interactive }) {
16
17
  const { env, version, debug } = options;
17
18
  const spinnerFactory = new Spinner({ interactive });
18
- let deployment;
19
+ if (options.sync && options.action) {
20
+ console.log(chalk.red('The --sync and --action options cannot be used together.'));
21
+ return Err('invalid_options');
22
+ }
23
+ const validArguments = validateDeployArguments({ environment: environmentName, file, options });
24
+ if (validArguments.isErr()) {
25
+ console.log(chalk.red(validArguments.error.message));
26
+ return Err(validArguments.error);
27
+ }
28
+ if (options.sync || options.action) {
29
+ console.log(chalk.yellow('--sync and --action are deprecated. Use `nango deploy <environment> <file>` to deploy a single script.'));
30
+ }
31
+ const plan = [];
19
32
  const spinnerPackage = spinnerFactory.start('Packaging');
20
33
  try {
21
34
  const def = await parseIntegrationDefinitions({ fullPath, debug });
@@ -25,47 +38,57 @@ export async function deploy({ fullPath, options, environmentName, interactive =
25
38
  console.log(def.error instanceof ReadableError ? def.error.toText() : chalk.red(def.error.message));
26
39
  return Err(def.error);
27
40
  }
28
- const deploymentKind = getDeploymentKind({
29
- parsed: def.value,
30
- optionalIntegrationId: options.integration,
31
- optionalSyncName: options.sync,
32
- optionalActionName: options.action
33
- });
34
- if (deploymentKind === 'mixed') {
41
+ if (options.integration &&
42
+ !def.value.integrations.some((integration) => integration.providerConfigKey === options.integration) &&
43
+ !def.value.functions.some((config) => config.integrationId === options.integration)) {
35
44
  spinnerPackage.fail();
36
- console.log(chalk.red('Legacy scripts and functions cannot be deployed together.'));
45
+ console.log(chalk.red(`No local definitions found for integration "${options.integration}".`));
37
46
  return Err('no_data');
38
47
  }
39
- if (deploymentKind === 'legacy') {
40
- const postData = await createDeployConfirmationPackage({
41
- parsed: def.value,
42
- fullPath,
43
- debug,
44
- version,
45
- optionalIntegrationId: options.integration,
46
- optionalSyncName: options.sync,
47
- optionalActionName: options.action
48
- });
49
- if (postData.isErr()) {
50
- spinnerPackage.fail();
51
- console.log(chalk.red(postData.error.message));
52
- return Err('no_data');
53
- }
54
- deployment = { kind: 'legacy', package: postData.value };
48
+ const selections = file !== undefined
49
+ ? planFileDeployment({ fullPath, file, parsed: def.value }).map((selection) => [selection])
50
+ : Ok(planDeployment({ parsed: def.value, options }));
51
+ if (selections.isErr()) {
52
+ spinnerPackage.fail();
53
+ console.log(chalk.red(selections.error.message));
54
+ return Err('no_data');
55
55
  }
56
- else {
57
- if (options.version) {
58
- spinnerPackage.fail();
59
- console.log(chalk.red('The --version option can only be used with legacy scripts.'));
60
- return Err('no_data');
56
+ for (const selection of selections.value) {
57
+ if (selection.kind === 'legacy') {
58
+ const postData = await createLegacyPackage({
59
+ parsed: def.value,
60
+ fullPath,
61
+ debug,
62
+ version,
63
+ optionalIntegrationId: selection.integrationId,
64
+ optionalSyncName: selection.syncName,
65
+ optionalActionName: selection.actionName
66
+ });
67
+ if (postData.isErr()) {
68
+ spinnerPackage.fail();
69
+ console.log(chalk.red(postData.error.message));
70
+ return Err('no_data');
71
+ }
72
+ plan.push({ kind: 'legacy', package: postData.value });
61
73
  }
62
- const bundle = await createFunctionsBundle({ fullPath, optionalIntegrationId: options.integration });
63
- if (bundle.isErr()) {
64
- spinnerPackage.fail();
65
- console.log(chalk.red(bundle.error.message));
66
- return Err('no_data');
74
+ else {
75
+ const bundle = await createFunctionsBundle({
76
+ fullPath,
77
+ functionConfigs: def.value.functions,
78
+ reconciliationScope: selection.reconciliationScope
79
+ });
80
+ if (bundle.isErr()) {
81
+ spinnerPackage.fail();
82
+ console.log(chalk.red(bundle.error.message));
83
+ return Err('no_data');
84
+ }
85
+ plan.push({ kind: 'functions', bundle: bundle.value });
67
86
  }
68
- deployment = { kind: 'functions', bundle: bundle.value };
87
+ }
88
+ if (plan.length === 0) {
89
+ spinnerPackage.fail();
90
+ console.log(chalk.red('No functions to deploy.'));
91
+ return Err('no_data');
69
92
  }
70
93
  spinnerPackage.succeed();
71
94
  }
@@ -74,6 +97,9 @@ export async function deploy({ fullPath, options, environmentName, interactive =
74
97
  console.error(chalk.red('Unknown error'), err);
75
98
  return Err('failed');
76
99
  }
100
+ if (version && plan.some((deployment) => deployment.kind === 'functions')) {
101
+ console.log(chalk.yellow('The --version option only applies to syncs/actions; it has no effect on functions.'));
102
+ }
77
103
  // Get the private key before reaching the API
78
104
  await parseSecretKey(environmentName, debug);
79
105
  const nangoYamlBody = '';
@@ -81,28 +107,16 @@ export async function deploy({ fullPath, options, environmentName, interactive =
81
107
  const hostport = resolveHostport(env);
82
108
  // Check remote state
83
109
  const spinnerState = spinnerFactory.start(`Acquiring remote state ${chalk.gray(`(${new URL(hostport).origin})`)}`);
84
- let confirmation;
110
+ const confirmations = [];
85
111
  try {
86
- if (deployment.kind === 'legacy') {
87
- const confirmationRes = await postLegacyConfirmation({
88
- hostport,
89
- body: { ...deployment.package, reconcile: false, debug, sdkVersion }
90
- });
91
- if (confirmationRes.isErr()) {
92
- spinnerState.fail();
93
- console.log(chalk.red(confirmationRes.error.message));
94
- return Err(confirmationRes.error);
95
- }
96
- confirmation = { kind: 'legacy', value: confirmationRes.value };
97
- }
98
- else {
99
- const confirmationRes = await previewFunctionsDeployment({ hostport, body: deployment.bundle });
112
+ const confirmationResults = await Promise.all(plan.map((deployment) => previewDeployment({ deployment, hostport, debug, sdkVersion })));
113
+ for (const confirmationRes of confirmationResults) {
100
114
  if (confirmationRes.isErr()) {
101
115
  spinnerState.fail();
102
116
  console.log(chalk.red(confirmationRes.error.message));
103
117
  return Err(confirmationRes.error);
104
118
  }
105
- confirmation = { kind: 'functions', value: confirmationRes.value };
119
+ confirmations.push(confirmationRes.value);
106
120
  }
107
121
  spinnerState.succeed();
108
122
  }
@@ -112,88 +126,151 @@ export async function deploy({ fullPath, options, environmentName, interactive =
112
126
  }
113
127
  const deploySource = resolveDeploySource();
114
128
  const autoconfirm = process.env['NANGO_DEPLOY_AUTO_CONFIRM'] === 'true' || options.autoConfirm;
115
- const confirmed = confirmation.kind === 'legacy'
116
- ? await handleLegacyConfirmation({ autoconfirm, allowDestructive: options.allowDestructive || false, confirmation: confirmation.value })
117
- : await handleFunctionsConfirmation({
118
- autoconfirm,
119
- allowDestructive: options.allowDestructive || false,
120
- confirmation: confirmation.value
121
- });
129
+ const confirmed = await handleConfirmation({
130
+ autoconfirm,
131
+ allowDestructive: options.allowDestructive || false,
132
+ confirmations,
133
+ interactive
134
+ });
122
135
  if (confirmed.isErr()) {
123
136
  return Err('not_confirmed');
124
137
  }
125
138
  console.log('');
126
139
  // Actual deploy
127
- const total = deployment.kind === 'legacy'
128
- ? deployment.package.flowConfigs.length + (deployment.package.onEventScriptsByProvider?.reduce((v, t) => v + t.scripts.length, 0) || 0)
129
- : deployment.bundle.functions.length;
140
+ const total = plan.reduce((count, deployment) => count +
141
+ (deployment.kind === 'legacy'
142
+ ? deployment.package.flowConfigs.length + (deployment.package.onEventScriptsByProvider?.reduce((v, t) => v + t.scripts.length, 0) || 0)
143
+ : deployment.bundle.functions.length), 0);
130
144
  const spinnerDeploy = spinnerFactory.start(`Deploying ${total} functions`);
131
- try {
132
- if (deployment.kind === 'legacy') {
133
- const deployRes = await postLegacyDeploy({
134
- hostport,
135
- body: { ...deployment.package, reconcile: true, debug, nangoYamlBody, sdkVersion, source: deploySource }
136
- });
137
- if (deployRes.isErr()) {
138
- spinnerDeploy.fail();
139
- console.log(chalk.red(deployRes.error.message));
140
- return Err('failed_to_deploy');
141
- }
142
- spinnerDeploy.succeed('Deployed');
143
- console.log(chalk.green(deployRes.value));
144
- }
145
- else {
146
- const deployRes = await deployFunctions({ hostport, body: deployment.bundle });
147
- if (deployRes.isErr()) {
148
- spinnerDeploy.fail();
149
- console.log(chalk.red(deployRes.error.message));
150
- return Err('failed_to_deploy');
145
+ const results = [];
146
+ // Deployments are intentionally not atomic: completed ones are not rolled back on failure.
147
+ for (const [failedIndex, confirmation] of confirmations.entries()) {
148
+ const deployRes = await executeDeployment({ confirmation, hostport, debug, nangoYamlBody, sdkVersion, source: deploySource });
149
+ if (deployRes.isErr()) {
150
+ spinnerDeploy.fail();
151
+ printDeploymentMessage(results);
152
+ console.log(chalk.red(deployRes.error.message));
153
+ console.log(chalk.red(failedIndex > 0 ? 'Deployment partially completed.' : 'Deployment failed.'));
154
+ for (const [index, { kind }] of confirmations.entries()) {
155
+ const status = index < failedIndex ? 'completed' : index === failedIndex ? 'failed' : 'not attempted';
156
+ console.log(`- ${kind === 'legacy' ? 'Syncs/actions/on-events' : 'Functions'}: ${status}`);
151
157
  }
152
- spinnerDeploy.succeed('Deployed');
153
- console.log(chalk.green(functionsDeploymentMessage(deployRes.value)));
158
+ console.log(chalk.yellow('The failed request may have applied changes. Check remote state before retrying.'));
159
+ return Err('failed_to_deploy');
154
160
  }
155
- return Ok(true);
161
+ results.push(deployRes.value);
156
162
  }
157
- catch {
158
- spinnerDeploy.fail();
159
- return Err('failed');
163
+ spinnerDeploy.succeed('Deployed');
164
+ if (results.every((result) => result.deployed.length + result.deleted.length === 0)) {
165
+ console.log(chalk.gray('No changes were deployed.'));
166
+ }
167
+ else {
168
+ printDeploymentMessage(results);
160
169
  }
170
+ return Ok(true);
161
171
  }
162
- function getDeploymentKind({ parsed, optionalIntegrationId, optionalSyncName, optionalActionName }) {
163
- if (optionalSyncName || optionalActionName) {
164
- return 'legacy';
172
+ async function previewDeployment({ deployment, hostport, debug, sdkVersion }) {
173
+ if (deployment.kind === 'legacy') {
174
+ const result = await postLegacyConfirmation({ hostport, body: { ...deployment.package, reconcile: false, debug, sdkVersion } });
175
+ return result.map((value) => ({ kind: 'legacy', value, deployment }));
165
176
  }
166
- const integrations = optionalIntegrationId
167
- ? parsed.integrations.filter((integration) => integration.providerConfigKey === optionalIntegrationId)
168
- : parsed.integrations;
169
- const hasLegacyScripts = integrations.some((integration) => integration.syncs.length > 0 || integration.actions.length > 0 || Object.values(integration.onEventScripts).some((scripts) => scripts.length > 0));
170
- const hasFunctions = parsed.functions.some((config) => !optionalIntegrationId || config.integrationId === optionalIntegrationId);
171
- if (hasLegacyScripts && hasFunctions) {
172
- return 'mixed';
177
+ const result = await previewFunctionsDeployment({ hostport, body: deployment.bundle });
178
+ return result.map((value) => ({ kind: 'functions', value, deployment }));
179
+ }
180
+ async function executeDeployment({ confirmation, hostport, debug, nangoYamlBody, sdkVersion, source }) {
181
+ try {
182
+ if (confirmation.kind === 'legacy') {
183
+ const { deployment, value } = confirmation;
184
+ const result = await postLegacyDeploy({ hostport, body: { ...deployment.package, reconcile: true, debug, nangoYamlBody, sdkVersion, source } });
185
+ return result.map((deployed) => ({
186
+ deployed: deployed.map(({ providerConfigKey, name, version }) => ({ integrationId: providerConfigKey, name, version })),
187
+ // The legacy API only returns deployed entries.
188
+ // Getting deleted entries from the confirmation response instead.
189
+ deleted: [...value.deletedSyncs, ...value.deletedActions, ...value.deletedOnEventScripts].map(({ providerConfigKey, name }) => ({
190
+ integrationId: providerConfigKey,
191
+ name
192
+ }))
193
+ }));
194
+ }
195
+ const result = await deployFunctions({ hostport, body: confirmation.deployment.bundle });
196
+ return result.map((value) => ({ deployed: [...value.created, ...value.updated], deleted: value.deleted }));
197
+ }
198
+ catch (err) {
199
+ return Err(new Error(`Unexpected deploy response:\n${err instanceof Error ? err.message : String(err)}`));
200
+ }
201
+ }
202
+ function planDeployment({ parsed, options }) {
203
+ const { integration: integrationId, sync: syncName, action: actionName } = options;
204
+ if (syncName || actionName) {
205
+ return [{ kind: 'legacy', integrationId, syncName, actionName }];
173
206
  }
207
+ const integrations = integrationId ? parsed.integrations.filter((integration) => integration.providerConfigKey === integrationId) : parsed.integrations;
208
+ const hasLegacyScripts = integrations.some((integration) => integration.syncs.length > 0 || integration.actions.length > 0 || Object.values(integration.onEventScripts).some((scripts) => scripts.length > 0));
209
+ const hasFunctions = parsed.functions.some((config) => !integrationId || config.integrationId === integrationId);
210
+ const selections = [];
174
211
  if (hasLegacyScripts) {
175
- return 'legacy';
212
+ selections.push({ kind: 'legacy', integrationId });
176
213
  }
177
- return 'functions';
214
+ if (hasFunctions) {
215
+ selections.push({ kind: 'functions', reconciliationScope: integrationId ? { kind: 'integration', integrationId } : { kind: 'environment' } });
216
+ }
217
+ return selections;
178
218
  }
179
- async function createFunctionsBundle({ fullPath, optionalIntegrationId }) {
180
- const artifactPath = path.join(fullPath, '.nango', 'functions.json');
181
- let functionConfigs = [];
182
- if (fs.existsSync(artifactPath)) {
183
- try {
184
- const content = await fs.promises.readFile(artifactPath, 'utf8');
185
- const parsed = JSON.parse(content);
186
- if (!Array.isArray(parsed)) {
187
- return Err(new Error(`Invalid functions artifact ${artifactPath}`));
188
- }
189
- functionConfigs = parsed;
190
- }
191
- catch (err) {
192
- const message = err instanceof Error ? err.message : String(err);
193
- return Err(new Error(`Could not read functions artifact ${artifactPath}: ${message}`));
219
+ const environmentNameSchema = z
220
+ .string()
221
+ .regex(/^[a-z0-9_-]+$/)
222
+ .max(255);
223
+ export function validateDeployArguments({ environment, file, options }) {
224
+ if (!environmentNameSchema.safeParse(environment).success) {
225
+ return Err(new Error(`'${environment}' is not a valid environment name. Usage: nango deploy <environment> [file]`));
226
+ }
227
+ if (file === undefined) {
228
+ return Ok(undefined);
229
+ }
230
+ if (file.trim() === '') {
231
+ return Err(new Error('The file argument is empty.'));
232
+ }
233
+ if (options.sync || options.action || options.integration) {
234
+ return Err(new Error('A file cannot be combined with --sync, --action or --integration.'));
235
+ }
236
+ return Ok(undefined);
237
+ }
238
+ function planFileDeployment({ fullPath, file, parsed }) {
239
+ const tsFile = file.replace(/\.js$/, '.ts');
240
+ const resolved = resolveProjectPath({ projectRoot: fullPath, filePath: path.isAbsolute(tsFile) ? path.relative(fullPath, tsFile) : tsFile });
241
+ if (!resolved) {
242
+ return Err(new Error(`'${file}' must be inside the project folder.`));
243
+ }
244
+ const entryPoint = parsed.entryPoints.find((candidate) => normalizeProjectRelativePath(candidate.filePath) === resolved.relative);
245
+ if (!entryPoint) {
246
+ return Err(new Error(`'${file}' is not a script. Make sure it is imported from index.ts.`));
247
+ }
248
+ const { integrationId, name } = entryPoint;
249
+ switch (entryPoint.type) {
250
+ case 'function':
251
+ return Ok({ kind: 'functions', reconciliationScope: { kind: 'function', integrationId, name } });
252
+ case 'sync':
253
+ return Ok({ kind: 'legacy', integrationId, syncName: name });
254
+ case 'action':
255
+ return Ok({ kind: 'legacy', integrationId, actionName: name });
256
+ case 'on-event':
257
+ return Err(new Error(`'${file}' is an on-event script and can't be deployed on its own. Use \`nango deploy <environment> --integration ${integrationId}\` or deploy the whole project.`));
258
+ }
259
+ }
260
+ async function createFunctionsBundle({ fullPath, functionConfigs, reconciliationScope }) {
261
+ const selectedConfigs = functionConfigs.filter((config) => {
262
+ switch (reconciliationScope.kind) {
263
+ case 'environment':
264
+ return true;
265
+ case 'function':
266
+ return config.integrationId === reconciliationScope.integrationId && config.name === reconciliationScope.name;
267
+ case 'integration':
268
+ return config.integrationId === reconciliationScope.integrationId;
194
269
  }
270
+ });
271
+ if (reconciliationScope.kind === 'function' && selectedConfigs.length === 0) {
272
+ return Err(new Error(`No compiled function found for "${reconciliationScope.name}"`));
195
273
  }
196
- const selectedConfigs = optionalIntegrationId ? functionConfigs.filter((config) => config.integrationId === optionalIntegrationId) : functionConfigs;
197
274
  const functions = [];
198
275
  for (const config of selectedConfigs) {
199
276
  const { filePath, ...artifact } = config;
@@ -213,17 +290,14 @@ async function createFunctionsBundle({ fullPath, optionalIntegrationId }) {
213
290
  }
214
291
  functions.push({ ...artifact, fileBody });
215
292
  }
216
- return Ok({
217
- reconciliationScope: optionalIntegrationId ? { kind: 'integration', integrationId: optionalIntegrationId } : { kind: 'environment' },
218
- functions
219
- });
293
+ return Ok({ reconciliationScope, functions });
220
294
  }
221
295
  /**
222
296
  * Maps NangoYamlParsed (which is a list of integrations and its function definitions) into the shape expected by the API,
223
297
  * while also loading the content of the related script files.
224
298
  * It also supports filtering by integration, sync or action name for single deploys.
225
299
  */
226
- async function createDeployConfirmationPackage({ parsed, fullPath, debug, version = '', optionalIntegrationId, optionalSyncName, optionalActionName }) {
300
+ async function createLegacyPackage({ parsed, fullPath, debug, version = '', optionalIntegrationId, optionalSyncName, optionalActionName }) {
227
301
  printDebug('Packaging', debug);
228
302
  const postData = [];
229
303
  const onEventScriptsByProvider = optionalActionName || optionalSyncName ? undefined : []; // only load on-event scripts if we're not deploying a single sync or action
@@ -433,19 +507,11 @@ async function postLegacyDeploy({ hostport, body }) {
433
507
  if ('error' in json) {
434
508
  return Err(new Error(`Error deploying:\n${json.error.message} ${chalk.gray(`(${json.error.code})`)}`));
435
509
  }
436
- if (json.length === 0) {
437
- return Ok(`Successfully removed the syncs/actions.`);
438
- }
439
- const nameAndVersions = json.map((result) => `${result.name}@v${result.version}`);
440
- return Ok(`Successfully deployed the functions: \r\n${nameAndVersions
441
- .map((row) => {
442
- return `- ${row}`;
443
- })
444
- .join('\r\n')}`);
510
+ return Ok(json);
445
511
  }
446
512
  catch (err) {
447
513
  const errorMessage = getFetchError(err);
448
- return Err(new Error(`Error deploying the functions:\n${errorMessage}`));
514
+ return Err(new Error(`Error deploying:\n${errorMessage}`));
449
515
  }
450
516
  }
451
517
  function formatFunctionsDeploymentError(error) {
@@ -499,21 +565,32 @@ async function deployFunctions({ hostport, body }) {
499
565
  return Err(new Error(`Error deploying functions:\n${getFetchError(err)}`));
500
566
  }
501
567
  }
502
- function functionsDeploymentMessage(result) {
503
- const deployed = [...result.created, ...result.updated];
504
- if (deployed.length === 0) {
505
- return result.deleted.length > 0 ? 'Successfully removed the functions.' : 'No function changes were deployed.';
568
+ function printDeploymentMessage(results) {
569
+ const deployed = results.flatMap((result) => result.deployed);
570
+ const deleted = results.flatMap((result) => result.deleted);
571
+ const messages = [];
572
+ if (deployed.length > 0) {
573
+ messages.push(`Successfully deployed: \r\n${deployed.map((func) => `- ${func.integrationId} → ${func.name}${func.version ? `@v${func.version}` : ''}`).join('\r\n')}`);
574
+ }
575
+ if (deleted.length > 0) {
576
+ messages.push(`Successfully removed: \r\n${deleted.map((func) => `- ${func.integrationId} → ${func.name}`).join('\r\n')}`);
577
+ }
578
+ if (messages.length > 0) {
579
+ console.log(chalk.green(messages.join('\r\n')));
506
580
  }
507
- return `Successfully deployed the functions: \r\n${deployed.map((func) => `- ${func.integrationId} → ${func.name}`).join('\r\n')}`;
508
581
  }
509
- /**
510
- * Handle state, display plan and eventually ask for confirmation if destructive
511
- */
512
- async function handleLegacyConfirmation({ autoconfirm, allowDestructive, confirmation }) {
582
+ function renderLegacyConfirmation(confirmation) {
513
583
  const { newSyncs, updatedSyncs, deletedSyncs, newActions, updatedActions, deletedActions, newOnEventScripts, updatedOnEventScripts, deletedOnEventScripts, deletedModels } = confirmation;
584
+ const scriptSummaries = [
585
+ { name: 'Actions', newItems: newActions, updatedItems: updatedActions, deleteItems: deletedActions },
586
+ { name: 'OnEvents', newItems: newOnEventScripts, updatedItems: updatedOnEventScripts, deleteItems: deletedOnEventScripts }
587
+ ];
588
+ const summaries = [{ name: 'Syncs', newItems: newSyncs, updatedItems: updatedSyncs, deleteItems: deletedSyncs }, ...scriptSummaries];
589
+ if (deletedModels.length === 0 &&
590
+ summaries.every(({ newItems, updatedItems, deleteItems }) => newItems.length + updatedItems.length + deleteItems.length === 0)) {
591
+ return { rows: [], requiresConfirmation: false };
592
+ }
514
593
  let deletedSyncsConnectionsCount = 0;
515
- console.log('');
516
- console.log('', chalk.underline('Nango will perform this plan:'));
517
594
  // Syncs
518
595
  if (newSyncs.length > 0 || deletedSyncs.length > 0) {
519
596
  console.log('');
@@ -544,101 +621,32 @@ async function handleLegacyConfirmation({ autoconfirm, allowDestructive, confirm
544
621
  });
545
622
  console.log(columns);
546
623
  }
547
- // Actions
548
- if (newActions.length > 0 || deletedActions.length > 0) {
549
- console.log('');
550
- console.log(shortSummaryMessage({ name: 'Actions', newItems: newActions, deleteItems: deletedActions }));
551
- for (const action of newActions) {
552
- console.log(` ${chalk.green('+')} ${action.providerConfigKey} → ${action.name}`);
553
- }
554
- for (const action of deletedActions) {
555
- console.log(` ${chalk.red('-')} ${action.providerConfigKey} → ${action.name}`);
624
+ for (const { name, newItems, deleteItems } of scriptSummaries) {
625
+ if (newItems.length === 0 && deleteItems.length === 0) {
626
+ continue;
556
627
  }
557
- }
558
- // OnEvents
559
- if (newOnEventScripts.length > 0 || deletedOnEventScripts.length > 0) {
560
628
  console.log('');
561
- console.log(shortSummaryMessage({ name: 'OnEvents', newItems: newOnEventScripts, deleteItems: deletedOnEventScripts }));
562
- for (const onEvent of newOnEventScripts) {
563
- console.log(` ${chalk.green('+')} ${onEvent.providerConfigKey} → ${onEvent.name}`);
629
+ console.log(shortSummaryMessage({ name, newItems, deleteItems }));
630
+ for (const item of newItems) {
631
+ console.log(` ${chalk.green('+')} ${item.providerConfigKey} → ${item.name}`);
564
632
  }
565
- for (const onEvent of deletedOnEventScripts) {
566
- console.log(` ${chalk.red('-')} ${onEvent.providerConfigKey} → ${onEvent.name}`);
633
+ for (const item of deleteItems) {
634
+ console.log(` ${chalk.red('-')} ${item.providerConfigKey} → ${item.name}`);
567
635
  }
568
636
  }
569
637
  if (deletedModels.length > 0) {
570
638
  console.log(chalk.red(`The following models have been removed: ${deletedModels.join(', ')}. `));
571
639
  console.log(chalk.red("WARNING: Renaming a model is the equivalent of deleting the old model and creating a new one. Records from the old model won't be transferred to the new model. Consider running a full sync to transfer records."));
572
640
  }
573
- if (newSyncs.length <= 0 &&
574
- deletedSyncs.length <= 0 &&
575
- newActions.length <= 0 &&
576
- deletedActions.length <= 0 &&
577
- newOnEventScripts.length <= 0 &&
578
- deletedOnEventScripts.length <= 0 &&
579
- deletedModels.length <= 0) {
580
- console.log('');
581
- console.log(chalk.gray.italic(' Only updates'));
582
- }
583
- console.log('');
584
- console.log('', chalk.underline('Summary'));
585
- const columns = columnify([
586
- summaryMessageColumns({ name: 'Syncs', newItems: newSyncs, updatedItems: updatedSyncs, deleteItems: deletedSyncs }),
587
- summaryMessageColumns({ name: 'Actions', newItems: newActions, updatedItems: updatedActions, deleteItems: deletedActions }),
588
- summaryMessageColumns({ name: 'OnEvents', newItems: newOnEventScripts, updatedItems: updatedOnEventScripts, deleteItems: deletedOnEventScripts })
589
- ], {
590
- showHeaders: false,
591
- minWidth: 18,
592
- config: {
593
- name: {
594
- dataTransform: (a) => {
595
- return `\u2063\u2063${a}`;
596
- }
597
- }
598
- }
599
- });
600
- console.log(columns);
601
- console.log('');
602
- const shouldConfirmDestructive = deletedSyncsConnectionsCount > 0 || deletedModels.length > 0;
603
- if (!shouldConfirmDestructive) {
604
- console.log(chalk.grey('No sync deleted with active connections, proceeding without confirmation'));
605
- return Ok(true);
606
- }
607
- if (autoconfirm && !shouldConfirmDestructive) {
608
- console.log(chalk.yellow('autoconfirm flag is on, proceeding without confirmation'));
609
- return Ok(true);
610
- }
611
- else if (autoconfirm && shouldConfirmDestructive && allowDestructive) {
612
- console.log(chalk.yellow('allowDestructive flag is on, proceeding without confirmation'));
613
- return Ok(true);
614
- }
615
- // Can't do anything here
616
- if (isCI) {
617
- console.log(chalk.red(`Syncs/Actions were not deployed. Confirm the deploy by passing the --auto-confirm flag${shouldConfirmDestructive ? ' and --allow-destructive flag' : ''}. Exiting`));
618
- return Err('is_ci');
619
- }
620
- try {
621
- const wait = await promptly.confirm(`Are you sure you want to continue y/n?`);
622
- if (!wait) {
623
- console.log(chalk.yellow('Deployed aborted. Exiting'));
624
- return Err('not_confirmed');
625
- }
626
- }
627
- catch {
628
- console.log('');
629
- console.log(chalk.yellow('Deployed aborted. Exiting'));
630
- return Err('not_confirmed');
631
- }
632
- return Ok(true);
641
+ return {
642
+ rows: summaries.map(summaryMessageColumns),
643
+ requiresConfirmation: deletedSyncsConnectionsCount > 0 || deletedModels.length > 0
644
+ };
633
645
  }
634
- async function handleFunctionsConfirmation({ autoconfirm, allowDestructive, confirmation }) {
646
+ function renderFunctionsConfirmation(confirmation) {
635
647
  const { created, updated, deleted } = confirmation;
636
- console.log('');
637
- console.log('', chalk.underline('Nango will perform this plan:'));
638
648
  if (created.length + updated.length + deleted.length === 0) {
639
- console.log('');
640
- console.log(chalk.gray.italic(' No changes'));
641
- return Ok(true);
649
+ return { rows: [], requiresConfirmation: false };
642
650
  }
643
651
  else if (created.length > 0 || deleted.length > 0) {
644
652
  console.log('');
@@ -650,32 +658,42 @@ async function handleFunctionsConfirmation({ autoconfirm, allowDestructive, conf
650
658
  console.log(` ${chalk.red('-')} ${func.integrationId} → ${func.name}`);
651
659
  }
652
660
  }
653
- else if (updated.length > 0) {
661
+ return {
662
+ rows: [summaryMessageColumns({ name: 'Functions', newItems: created, updatedItems: updated, deleteItems: deleted })],
663
+ requiresConfirmation: deleted.length > 0
664
+ };
665
+ }
666
+ async function handleConfirmation({ autoconfirm, allowDestructive, confirmations, interactive }) {
667
+ console.log('');
668
+ console.log('', chalk.underline('Nango will perform this plan:'));
669
+ const summaries = confirmations.map((confirmation) => confirmation.kind === 'legacy' ? renderLegacyConfirmation(confirmation.value) : renderFunctionsConfirmation(confirmation.value));
670
+ const rows = summaries.flatMap((summary) => summary.rows);
671
+ if (rows.length > 0) {
672
+ console.log('');
673
+ console.log('', chalk.underline('Summary'));
674
+ console.log(columnify(rows, {
675
+ showHeaders: false,
676
+ minWidth: 18,
677
+ config: { name: { dataTransform: (value) => `\u2063\u2063${value}` } }
678
+ }));
654
679
  console.log('');
655
- console.log(chalk.gray.italic(' Only updates'));
656
680
  }
657
- console.log('');
658
- console.log('', chalk.underline('Summary'));
659
- console.log(columnify([summaryMessageColumns({ name: 'Functions', newItems: created, updatedItems: updated, deleteItems: deleted })], {
660
- showHeaders: false,
661
- minWidth: 18,
662
- config: {
663
- name: {
664
- dataTransform: (value) => `\u2063\u2063${value}`
665
- }
681
+ else {
682
+ console.log('');
683
+ console.log(chalk.gray.italic(' No changes'));
684
+ }
685
+ if (!summaries.some((summary) => summary.requiresConfirmation)) {
686
+ if (rows.length > 0) {
687
+ console.log(chalk.grey('No destructive changes, proceeding without confirmation'));
666
688
  }
667
- }));
668
- console.log('');
669
- if (deleted.length === 0) {
670
- console.log(chalk.grey('No functions deleted, proceeding without confirmation'));
671
689
  return Ok(true);
672
690
  }
673
691
  if (autoconfirm && allowDestructive) {
674
692
  console.log(chalk.yellow('allowDestructive flag is on, proceeding without confirmation'));
675
693
  return Ok(true);
676
694
  }
677
- if (isCI) {
678
- console.log(chalk.red('Functions were not deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting'));
695
+ if (!interactive) {
696
+ console.log(chalk.red('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting'));
679
697
  return Err('is_ci');
680
698
  }
681
699
  try {