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.
@@ -3,19 +3,33 @@ 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 { resolveEntryPoint, 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 deploySource = resolveDeploySource();
32
+ const plan = [];
19
33
  const spinnerPackage = spinnerFactory.start('Packaging');
20
34
  try {
21
35
  const def = await parseIntegrationDefinitions({ fullPath, debug });
@@ -25,47 +39,46 @@ export async function deploy({ fullPath, options, environmentName, interactive =
25
39
  console.log(def.error instanceof ReadableError ? def.error.toText() : chalk.red(def.error.message));
26
40
  return Err(def.error);
27
41
  }
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') {
42
+ const selections = file !== undefined ? planFileDeployment({ fullPath, file, parsed: def.value }).map((selection) => [selection]) : Ok(planDeployment({ options }));
43
+ if (selections.isErr()) {
35
44
  spinnerPackage.fail();
36
- console.log(chalk.red('Legacy scripts and functions cannot be deployed together.'));
45
+ console.log(chalk.red(selections.error.message));
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 };
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');
48
+ for (const selection of selections.value) {
49
+ if (selection.kind === 'legacy') {
50
+ const postData = await createLegacyPackage({
51
+ parsed: def.value,
52
+ fullPath,
53
+ debug,
54
+ version,
55
+ reconciliationScope: selection.integrationId ? { kind: 'integration', integrationId: selection.integrationId } : { kind: 'environment' },
56
+ optionalSyncName: selection.syncName,
57
+ optionalActionName: selection.actionName
58
+ });
59
+ if (postData.isErr()) {
60
+ spinnerPackage.fail();
61
+ console.log(chalk.red(postData.error.message));
62
+ return Err('no_data');
63
+ }
64
+ for (const legacyPackage of postData.value) {
65
+ plan.push({ kind: 'legacy', package: legacyPackage });
66
+ }
61
67
  }
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');
68
+ else {
69
+ const bundle = await createFunctionsBundle({
70
+ fullPath,
71
+ functionConfigs: def.value.functions,
72
+ reconciliationScope: selection.reconciliationScope,
73
+ source: deploySource
74
+ });
75
+ if (bundle.isErr()) {
76
+ spinnerPackage.fail();
77
+ console.log(chalk.red(bundle.error.message));
78
+ return Err('no_data');
79
+ }
80
+ plan.push({ kind: 'functions', bundle: bundle.value });
67
81
  }
68
- deployment = { kind: 'functions', bundle: bundle.value };
69
82
  }
70
83
  spinnerPackage.succeed();
71
84
  }
@@ -74,6 +87,9 @@ export async function deploy({ fullPath, options, environmentName, interactive =
74
87
  console.error(chalk.red('Unknown error'), err);
75
88
  return Err('failed');
76
89
  }
90
+ if (version && plan.some((deployment) => deployment.kind === 'functions' && deployment.bundle.functions.length > 0)) {
91
+ console.log(chalk.yellow('The --version option only applies to syncs/actions; it has no effect on functions.'));
92
+ }
77
93
  // Get the private key before reaching the API
78
94
  await parseSecretKey(environmentName, debug);
79
95
  const nangoYamlBody = '';
@@ -81,28 +97,16 @@ export async function deploy({ fullPath, options, environmentName, interactive =
81
97
  const hostport = resolveHostport(env);
82
98
  // Check remote state
83
99
  const spinnerState = spinnerFactory.start(`Acquiring remote state ${chalk.gray(`(${new URL(hostport).origin})`)}`);
84
- let confirmation;
100
+ const confirmations = [];
85
101
  try {
86
- if (deployment.kind === 'legacy') {
87
- const confirmationRes = await postLegacyConfirmation({
88
- hostport,
89
- body: { ...deployment.package, reconcile: false, debug, sdkVersion }
90
- });
102
+ const confirmationResults = await Promise.all(plan.map((deployment) => previewDeployment({ deployment, hostport, debug, sdkVersion })));
103
+ for (const confirmationRes of confirmationResults) {
91
104
  if (confirmationRes.isErr()) {
92
105
  spinnerState.fail();
93
106
  console.log(chalk.red(confirmationRes.error.message));
94
107
  return Err(confirmationRes.error);
95
108
  }
96
- confirmation = { kind: 'legacy', value: confirmationRes.value };
97
- }
98
- else {
99
- const confirmationRes = await previewFunctionsDeployment({ hostport, body: deployment.bundle });
100
- if (confirmationRes.isErr()) {
101
- spinnerState.fail();
102
- console.log(chalk.red(confirmationRes.error.message));
103
- return Err(confirmationRes.error);
104
- }
105
- confirmation = { kind: 'functions', value: confirmationRes.value };
109
+ confirmations.push(confirmationRes.value);
106
110
  }
107
111
  spinnerState.succeed();
108
112
  }
@@ -110,90 +114,157 @@ export async function deploy({ fullPath, options, environmentName, interactive =
110
114
  spinnerState.fail();
111
115
  return Err('failed');
112
116
  }
113
- const deploySource = resolveDeploySource();
114
117
  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
- });
118
+ const confirmed = await handleConfirmation({
119
+ autoconfirm,
120
+ allowDestructive: options.allowDestructive || false,
121
+ confirmations,
122
+ interactive
123
+ });
122
124
  if (confirmed.isErr()) {
123
125
  return Err('not_confirmed');
124
126
  }
125
127
  console.log('');
126
128
  // 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;
130
- 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');
129
+ const pendingConfirmations = confirmations.filter((confirmation) => !isNoop(confirmation));
130
+ const total = pendingConfirmations.reduce((count, { deployment }) => count + countFunctions(deployment), 0);
131
+ const spinnerDeploy = spinnerFactory.start(total > 0 ? `Deploying ${total} functions` : 'Deploying');
132
+ const results = [];
133
+ // Deployments are intentionally not atomic: completed ones are not rolled back on failure.
134
+ for (const [failedIndex, confirmation] of pendingConfirmations.entries()) {
135
+ const deployRes = await executeDeployment({ confirmation, hostport, debug, nangoYamlBody, sdkVersion, source: deploySource });
136
+ if (deployRes.isErr()) {
137
+ spinnerDeploy.fail();
138
+ printDeploymentMessage(results);
139
+ console.log(chalk.red(deployRes.error.message));
140
+ console.log(chalk.red(failedIndex > 0 ? 'Deployment partially completed.' : 'Deployment failed.'));
141
+ for (const [index, { kind }] of pendingConfirmations.entries()) {
142
+ const status = index < failedIndex ? 'completed' : index === failedIndex ? 'failed' : 'not attempted';
143
+ console.log(`- ${kind === 'legacy' ? 'Syncs/actions/on-events' : 'Functions'}: ${status}`);
151
144
  }
152
- spinnerDeploy.succeed('Deployed');
153
- console.log(chalk.green(functionsDeploymentMessage(deployRes.value)));
145
+ console.log(chalk.yellow('The failed request may have applied changes. Check remote state before retrying.'));
146
+ return Err('failed_to_deploy');
154
147
  }
155
- return Ok(true);
148
+ results.push(deployRes.value);
156
149
  }
157
- catch {
158
- spinnerDeploy.fail();
159
- return Err('failed');
150
+ spinnerDeploy.succeed('Deployed');
151
+ if (results.every((result) => result.deployed.length + result.deleted.length === 0)) {
152
+ console.log(chalk.gray('No changes were deployed.'));
160
153
  }
154
+ else {
155
+ printDeploymentMessage(results);
156
+ }
157
+ return Ok(true);
161
158
  }
162
- function getDeploymentKind({ parsed, optionalIntegrationId, optionalSyncName, optionalActionName }) {
163
- if (optionalSyncName || optionalActionName) {
164
- return 'legacy';
165
- }
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';
173
- }
174
- if (hasLegacyScripts) {
175
- return 'legacy';
176
- }
177
- return 'functions';
159
+ function countFunctions(deployment) {
160
+ if (deployment.kind === 'legacy') {
161
+ const onEventScriptsCount = deployment.package.onEventScriptsByProvider?.reduce((count, { scripts }) => count + scripts.length, 0) || 0;
162
+ return deployment.package.flowConfigs.length + onEventScriptsCount;
163
+ }
164
+ return deployment.bundle.functions.length;
178
165
  }
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}`));
166
+ function isNoop(confirmation) {
167
+ if (countFunctions(confirmation.deployment) > 0) {
168
+ return false;
169
+ }
170
+ if (confirmation.kind === 'legacy') {
171
+ const { deletedSyncs, deletedActions, deletedOnEventScripts } = confirmation.value;
172
+ return deletedSyncs.length + deletedActions.length + deletedOnEventScripts.length === 0;
173
+ }
174
+ return confirmation.value.deleted.length === 0;
175
+ }
176
+ async function previewDeployment({ deployment, hostport, debug, sdkVersion }) {
177
+ if (deployment.kind === 'legacy') {
178
+ const result = await postLegacyConfirmation({ hostport, body: { ...deployment.package, reconcile: false, debug, sdkVersion } });
179
+ return result.map((value) => ({ kind: 'legacy', value, deployment }));
180
+ }
181
+ const result = await previewFunctionsDeployment({ hostport, body: deployment.bundle });
182
+ return result.map((value) => ({ kind: 'functions', value, deployment }));
183
+ }
184
+ async function executeDeployment({ confirmation, hostport, debug, nangoYamlBody, sdkVersion, source }) {
185
+ try {
186
+ if (confirmation.kind === 'legacy') {
187
+ const { deployment, value } = confirmation;
188
+ const result = await postLegacyDeploy({ hostport, body: { ...deployment.package, reconcile: true, debug, nangoYamlBody, sdkVersion, source } });
189
+ return result.map((deployed) => ({
190
+ deployed: deployed.map(({ providerConfigKey, name, version }) => ({ integrationId: providerConfigKey, name, version })),
191
+ // The legacy API only returns deployed entries.
192
+ // Getting deleted entries from the confirmation response instead.
193
+ deleted: [...value.deletedSyncs, ...value.deletedActions, ...value.deletedOnEventScripts].map(({ providerConfigKey, name }) => ({
194
+ integrationId: providerConfigKey,
195
+ name
196
+ }))
197
+ }));
198
+ }
199
+ const result = await deployFunctions({ hostport, body: confirmation.deployment.bundle });
200
+ return result.map((value) => ({ deployed: [...value.created, ...value.updated], deleted: value.deleted }));
201
+ }
202
+ catch (err) {
203
+ return Err(new Error(`Unexpected deploy response:\n${err instanceof Error ? err.message : String(err)}`));
204
+ }
205
+ }
206
+ function planDeployment({ options }) {
207
+ const { integration: integrationId, sync: syncName, action: actionName } = options;
208
+ if (syncName || actionName) {
209
+ return [{ kind: 'legacy', integrationId, syncName, actionName }];
210
+ }
211
+ // Reconcile both families even when their last local script was removed.
212
+ return [
213
+ { kind: 'legacy', integrationId },
214
+ { kind: 'functions', reconciliationScope: integrationId ? { kind: 'integration', integrationId } : { kind: 'environment' } }
215
+ ];
216
+ }
217
+ const environmentNameSchema = z
218
+ .string()
219
+ .regex(/^[a-z0-9_-]+$/)
220
+ .max(255);
221
+ export function validateDeployArguments({ environment, file, options }) {
222
+ if (!environmentNameSchema.safeParse(environment).success) {
223
+ return Err(new Error(`'${environment}' is not a valid environment name. Usage: nango deploy <environment> [file]`));
224
+ }
225
+ if (file === undefined) {
226
+ return Ok(undefined);
227
+ }
228
+ if (file.trim() === '') {
229
+ return Err(new Error('The file argument is empty.'));
230
+ }
231
+ if (options.sync || options.action || options.integration) {
232
+ return Err(new Error('A file cannot be combined with --sync, --action or --integration.'));
233
+ }
234
+ return Ok(undefined);
235
+ }
236
+ function planFileDeployment({ fullPath, file, parsed }) {
237
+ const resolved = resolveEntryPoint({ fullPath, file, entryPoints: parsed.entryPoints });
238
+ if (resolved.isErr()) {
239
+ return Err(resolved.error);
240
+ }
241
+ const entryPoint = resolved.value;
242
+ const { integrationId, name } = entryPoint;
243
+ switch (entryPoint.type) {
244
+ case 'function':
245
+ return Ok({ kind: 'functions', reconciliationScope: { kind: 'function', integrationId, name } });
246
+ case 'sync':
247
+ return Ok({ kind: 'legacy', integrationId, syncName: name });
248
+ case 'action':
249
+ return Ok({ kind: 'legacy', integrationId, actionName: name });
250
+ case 'on-event':
251
+ 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.`));
252
+ }
253
+ }
254
+ async function createFunctionsBundle({ fullPath, functionConfigs, reconciliationScope, source }) {
255
+ const selectedConfigs = functionConfigs.filter((config) => {
256
+ switch (reconciliationScope.kind) {
257
+ case 'environment':
258
+ return true;
259
+ case 'function':
260
+ return config.integrationId === reconciliationScope.integrationId && config.name === reconciliationScope.name;
261
+ case 'integration':
262
+ return config.integrationId === reconciliationScope.integrationId;
194
263
  }
264
+ });
265
+ if (reconciliationScope.kind === 'function' && selectedConfigs.length === 0) {
266
+ return Err(new Error(`No compiled function found for "${reconciliationScope.name}"`));
195
267
  }
196
- const selectedConfigs = optionalIntegrationId ? functionConfigs.filter((config) => config.integrationId === optionalIntegrationId) : functionConfigs;
197
268
  const functions = [];
198
269
  for (const config of selectedConfigs) {
199
270
  const { filePath, ...artifact } = config;
@@ -211,27 +282,24 @@ async function createFunctionsBundle({ fullPath, optionalIntegrationId }) {
211
282
  if (!fileBody) {
212
283
  return Err(new Error(`No function files found for "${config.name}"`));
213
284
  }
214
- functions.push({ ...artifact, fileBody });
285
+ functions.push({ ...artifact, fileBody, source });
215
286
  }
216
- return Ok({
217
- reconciliationScope: optionalIntegrationId ? { kind: 'integration', integrationId: optionalIntegrationId } : { kind: 'environment' },
218
- functions
219
- });
287
+ return Ok({ reconciliationScope, functions });
220
288
  }
221
289
  /**
222
290
  * Maps NangoYamlParsed (which is a list of integrations and its function definitions) into the shape expected by the API,
223
291
  * while also loading the content of the related script files.
224
292
  * It also supports filtering by integration, sync or action name for single deploys.
225
293
  */
226
- async function createDeployConfirmationPackage({ parsed, fullPath, debug, version = '', optionalIntegrationId, optionalSyncName, optionalActionName }) {
294
+ async function createLegacyPackage({ parsed, fullPath, debug, version = '', reconciliationScope, optionalSyncName, optionalActionName }) {
227
295
  printDebug('Packaging', debug);
296
+ const isSingleScript = Boolean(optionalSyncName || optionalActionName);
228
297
  const postData = [];
229
- const onEventScriptsByProvider = optionalActionName || optionalSyncName ? undefined : []; // only load on-event scripts if we're not deploying a single sync or action
230
- const hasSingleScript = Boolean(optionalSyncName || optionalActionName);
231
- const deployMode = hasSingleScript ? 'single' : optionalIntegrationId ? 'integration' : 'all';
298
+ const onEventScriptsByProvider = isSingleScript ? undefined : []; // only load on-event scripts if we're not deploying a single sync or action
299
+ const integrationId = reconciliationScope.kind === 'integration' ? reconciliationScope.integrationId : undefined;
232
300
  for (const integration of parsed.integrations) {
233
301
  const { providerConfigKey, onEventScripts } = integration;
234
- if (optionalIntegrationId && integration.providerConfigKey !== optionalIntegrationId) {
302
+ if (integrationId && integration.providerConfigKey !== integrationId) {
235
303
  continue;
236
304
  }
237
305
  if (onEventScriptsByProvider) {
@@ -322,14 +390,16 @@ async function createDeployConfirmationPackage({ parsed, fullPath, debug, versio
322
390
  }
323
391
  }
324
392
  }
325
- if (postData.length <= 0) {
393
+ if (isSingleScript && postData.length === 0) {
326
394
  return Err(new Error('No syncs or actions to deploy'));
327
395
  }
328
- return Ok({
329
- flowConfigs: postData,
330
- onEventScriptsByProvider,
331
- deployMode
332
- });
396
+ if (isSingleScript) {
397
+ return Ok(postData.map((flow) => ({
398
+ flowConfigs: [flow],
399
+ reconciliationScope: { kind: 'function', integrationId: flow.providerConfigKey, name: flow.syncName }
400
+ })));
401
+ }
402
+ return Ok([{ flowConfigs: postData, onEventScriptsByProvider, reconciliationScope }]);
333
403
  }
334
404
  /**
335
405
  * Load source and bundled files
@@ -433,19 +503,11 @@ async function postLegacyDeploy({ hostport, body }) {
433
503
  if ('error' in json) {
434
504
  return Err(new Error(`Error deploying:\n${json.error.message} ${chalk.gray(`(${json.error.code})`)}`));
435
505
  }
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')}`);
506
+ return Ok(json);
445
507
  }
446
508
  catch (err) {
447
509
  const errorMessage = getFetchError(err);
448
- return Err(new Error(`Error deploying the functions:\n${errorMessage}`));
510
+ return Err(new Error(`Error deploying:\n${errorMessage}`));
449
511
  }
450
512
  }
451
513
  function formatFunctionsDeploymentError(error) {
@@ -499,21 +561,32 @@ async function deployFunctions({ hostport, body }) {
499
561
  return Err(new Error(`Error deploying functions:\n${getFetchError(err)}`));
500
562
  }
501
563
  }
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.';
564
+ function printDeploymentMessage(results) {
565
+ const deployed = results.flatMap((result) => result.deployed);
566
+ const deleted = results.flatMap((result) => result.deleted);
567
+ const messages = [];
568
+ if (deployed.length > 0) {
569
+ messages.push(`Successfully deployed: \r\n${deployed.map((func) => `- ${func.integrationId} → ${func.name}${func.version ? `@v${func.version}` : ''}`).join('\r\n')}`);
570
+ }
571
+ if (deleted.length > 0) {
572
+ messages.push(`Successfully removed: \r\n${deleted.map((func) => `- ${func.integrationId} → ${func.name}`).join('\r\n')}`);
573
+ }
574
+ if (messages.length > 0) {
575
+ console.log(chalk.green(messages.join('\r\n')));
506
576
  }
507
- return `Successfully deployed the functions: \r\n${deployed.map((func) => `- ${func.integrationId} → ${func.name}`).join('\r\n')}`;
508
577
  }
509
- /**
510
- * Handle state, display plan and eventually ask for confirmation if destructive
511
- */
512
- async function handleLegacyConfirmation({ autoconfirm, allowDestructive, confirmation }) {
578
+ function renderLegacyConfirmation(confirmation) {
513
579
  const { newSyncs, updatedSyncs, deletedSyncs, newActions, updatedActions, deletedActions, newOnEventScripts, updatedOnEventScripts, deletedOnEventScripts, deletedModels } = confirmation;
580
+ const scriptSummaries = [
581
+ { name: 'Actions', newItems: newActions, updatedItems: updatedActions, deleteItems: deletedActions },
582
+ { name: 'OnEvents', newItems: newOnEventScripts, updatedItems: updatedOnEventScripts, deleteItems: deletedOnEventScripts }
583
+ ];
584
+ const summaries = [{ name: 'Syncs', newItems: newSyncs, updatedItems: updatedSyncs, deleteItems: deletedSyncs }, ...scriptSummaries];
585
+ if (deletedModels.length === 0 &&
586
+ summaries.every(({ newItems, updatedItems, deleteItems }) => newItems.length + updatedItems.length + deleteItems.length === 0)) {
587
+ return { rows: [], requiresConfirmation: false };
588
+ }
514
589
  let deletedSyncsConnectionsCount = 0;
515
- console.log('');
516
- console.log('', chalk.underline('Nango will perform this plan:'));
517
590
  // Syncs
518
591
  if (newSyncs.length > 0 || deletedSyncs.length > 0) {
519
592
  console.log('');
@@ -544,101 +617,32 @@ async function handleLegacyConfirmation({ autoconfirm, allowDestructive, confirm
544
617
  });
545
618
  console.log(columns);
546
619
  }
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}`);
620
+ for (const { name, newItems, deleteItems } of scriptSummaries) {
621
+ if (newItems.length === 0 && deleteItems.length === 0) {
622
+ continue;
556
623
  }
557
- }
558
- // OnEvents
559
- if (newOnEventScripts.length > 0 || deletedOnEventScripts.length > 0) {
560
624
  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}`);
625
+ console.log(shortSummaryMessage({ name, newItems, deleteItems }));
626
+ for (const item of newItems) {
627
+ console.log(` ${chalk.green('+')} ${item.providerConfigKey} → ${item.name}`);
564
628
  }
565
- for (const onEvent of deletedOnEventScripts) {
566
- console.log(` ${chalk.red('-')} ${onEvent.providerConfigKey} → ${onEvent.name}`);
629
+ for (const item of deleteItems) {
630
+ console.log(` ${chalk.red('-')} ${item.providerConfigKey} → ${item.name}`);
567
631
  }
568
632
  }
569
633
  if (deletedModels.length > 0) {
570
634
  console.log(chalk.red(`The following models have been removed: ${deletedModels.join(', ')}. `));
571
635
  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
636
  }
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);
637
+ return {
638
+ rows: summaries.map(summaryMessageColumns),
639
+ requiresConfirmation: deletedSyncsConnectionsCount > 0 || deletedModels.length > 0
640
+ };
633
641
  }
634
- async function handleFunctionsConfirmation({ autoconfirm, allowDestructive, confirmation }) {
642
+ function renderFunctionsConfirmation(confirmation) {
635
643
  const { created, updated, deleted } = confirmation;
636
- console.log('');
637
- console.log('', chalk.underline('Nango will perform this plan:'));
638
644
  if (created.length + updated.length + deleted.length === 0) {
639
- console.log('');
640
- console.log(chalk.gray.italic(' No changes'));
641
- return Ok(true);
645
+ return { rows: [], requiresConfirmation: false };
642
646
  }
643
647
  else if (created.length > 0 || deleted.length > 0) {
644
648
  console.log('');
@@ -650,32 +654,42 @@ async function handleFunctionsConfirmation({ autoconfirm, allowDestructive, conf
650
654
  console.log(` ${chalk.red('-')} ${func.integrationId} → ${func.name}`);
651
655
  }
652
656
  }
653
- else if (updated.length > 0) {
657
+ return {
658
+ rows: [summaryMessageColumns({ name: 'Functions', newItems: created, updatedItems: updated, deleteItems: deleted })],
659
+ requiresConfirmation: deleted.length > 0
660
+ };
661
+ }
662
+ async function handleConfirmation({ autoconfirm, allowDestructive, confirmations, interactive }) {
663
+ console.log('');
664
+ console.log('', chalk.underline('Nango will perform this plan:'));
665
+ const summaries = confirmations.map((confirmation) => confirmation.kind === 'legacy' ? renderLegacyConfirmation(confirmation.value) : renderFunctionsConfirmation(confirmation.value));
666
+ const rows = summaries.flatMap((summary) => summary.rows);
667
+ if (rows.length > 0) {
668
+ console.log('');
669
+ console.log('', chalk.underline('Summary'));
670
+ console.log(columnify(rows, {
671
+ showHeaders: false,
672
+ minWidth: 18,
673
+ config: { name: { dataTransform: (value) => `\u2063\u2063${value}` } }
674
+ }));
675
+ console.log('');
676
+ }
677
+ else {
654
678
  console.log('');
655
- console.log(chalk.gray.italic(' Only updates'));
679
+ console.log(chalk.gray.italic(' No changes'));
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
+ if (!summaries.some((summary) => summary.requiresConfirmation)) {
682
+ if (rows.length > 0) {
683
+ console.log(chalk.grey('No destructive changes, proceeding without confirmation'));
666
684
  }
667
- }));
668
- console.log('');
669
- if (deleted.length === 0) {
670
- console.log(chalk.grey('No functions deleted, proceeding without confirmation'));
671
685
  return Ok(true);
672
686
  }
673
687
  if (autoconfirm && allowDestructive) {
674
688
  console.log(chalk.yellow('allowDestructive flag is on, proceeding without confirmation'));
675
689
  return Ok(true);
676
690
  }
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'));
691
+ if (!interactive) {
692
+ console.log(chalk.red('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting'));
679
693
  return Err('is_ci');
680
694
  }
681
695
  try {