@lightdash/cli 1.53.0 → 1.55.0

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.
@@ -331,6 +331,15 @@ const extractChartSlugsFromDashboards = (dashboards) => dashboards.reduce((acc,
331
331
  .filter((slug) => slug !== undefined);
332
332
  return [...acc, ...slugs];
333
333
  }, []);
334
+ const extractAppSlugsFromDashboards = (dashboards) => [
335
+ ...new Set(dashboards.flatMap((dashboard) => dashboard.tiles.reduce((acc, tile) => {
336
+ if (tile.type !== common_1.DashboardTileTypes.DATA_APP)
337
+ return acc;
338
+ return tile.properties.appSlug
339
+ ? [...acc, tile.properties.appSlug]
340
+ : acc;
341
+ }, []))),
342
+ ];
334
343
  const downloadContent = async (ids, type, projectId, projectName, customPath, languageMap = false, nested = false, skipSpaces = false, stripPivotSeries = false, rootSpaces = false, onProgress) => {
335
344
  const contentFilters = parseContentFilters(ids);
336
345
  const folderScheme = nested ? 'nested' : 'flat';
@@ -338,6 +347,7 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
338
347
  let offset = 0;
339
348
  let total = 0;
340
349
  let chartSlugs = [];
350
+ let appSlugs = [];
341
351
  let allMetadataEntries = [];
342
352
  let allSpaces = [];
343
353
  do {
@@ -399,6 +409,10 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
399
409
  ...chartSlugs,
400
410
  ...extractChartSlugsFromDashboards(results.dashboards),
401
411
  ];
412
+ appSlugs = [
413
+ ...appSlugs,
414
+ ...extractAppSlugsFromDashboards(results.dashboards),
415
+ ];
402
416
  }
403
417
  else {
404
418
  const chartsBySpace = groupBySpace(results.charts);
@@ -432,7 +446,13 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
432
446
  ];
433
447
  await (0, spacesAsCode_1.writeSpaceFiles)(uniqueSpaces, projectName, customPath, folderScheme, rootSpaces ? 'root' : 'folder', true);
434
448
  }
435
- return [total, [...new Set(chartSlugs)], allMetadataEntries, allSpaces];
449
+ return {
450
+ total,
451
+ chartSlugs: [...new Set(chartSlugs)],
452
+ appSlugs: [...new Set(appSlugs)],
453
+ metadataEntries: allMetadataEntries,
454
+ spaces: allSpaces,
455
+ };
436
456
  };
437
457
  exports.downloadContent = downloadContent;
438
458
  const getScheduledDeliveriesFolder = (customPath) => path.join((0, contentAsCodePaths_1.getDownloadFolder)(customPath), projectResources_1.SCHEDULED_DELIVERY_CODE_RESOURCE.folderName);
@@ -836,8 +856,8 @@ const upsertScheduledContent = async (projectId, slugs, changes, force, contentT
836
856
  };
837
857
  // Space-scoped fallback listing for servers without the project-wide apps
838
858
  // endpoint; omits apps that were never added to a space.
839
- const listAppUuidsViaContentApi = async (projectId) => {
840
- const listedAppUuids = [];
859
+ const listAppsViaContentApi = async (projectId) => {
860
+ const listedApps = [];
841
861
  let page = 1;
842
862
  let totalPageCount = 1;
843
863
  do {
@@ -846,13 +866,13 @@ const listAppUuidsViaContentApi = async (projectId) => {
846
866
  url: `/api/v2/content?projectUuids=${projectId}&contentTypes=data_app&page=${page}&pageSize=100`,
847
867
  body: undefined,
848
868
  });
849
- listedAppUuids.push(...contentResult.data
869
+ listedApps.push(...contentResult.data
850
870
  .filter((item) => item.contentType === 'data_app')
851
- .map((item) => item.uuid));
871
+ .map((item) => ({ appUuid: item.uuid, slug: item.slug })));
852
872
  totalPageCount = contentResult.pagination?.totalPageCount ?? 1;
853
873
  page += 1;
854
874
  } while (page <= totalPageCount);
855
- return listedAppUuids;
875
+ return listedApps;
856
876
  };
857
877
  const downloadHandler = async (options) => {
858
878
  globalState_1.default.setVerbose(options.verbose);
@@ -966,6 +986,13 @@ const downloadHandler = async (options) => {
966
986
  });
967
987
  try {
968
988
  let allMetadataEntries = [];
989
+ // Shared across both apps-download steps so two different apps whose
990
+ // names collide under the pre-slug fallback naming don't clobber each other.
991
+ const downloadedAppFolders = new Set();
992
+ // App slugs referenced by downloaded dashboards' tiles, populated by
993
+ // the Dashboards step and consumed by the Linked data apps step.
994
+ let dashboardAppSlugs = [];
995
+ const explicitAppRefs = new Set((Array.isArray(options.apps) ? options.apps : []).map(appsDownload_1.getDataAppReference));
969
996
  if (shouldDownloadSpaces) {
970
997
  output.startItem('Spaces');
971
998
  try {
@@ -999,19 +1026,19 @@ const downloadHandler = async (options) => {
999
1026
  globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
1000
1027
  }
1001
1028
  else {
1002
- const [regularChartTotal, , regularChartMeta] = await output.runItem({
1029
+ const { total: regularChartTotal, metadataEntries: regularChartMeta, } = await output.runItem({
1003
1030
  label: 'Charts',
1004
1031
  action: () => (0, exports.downloadContent)(options.charts, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces, output.updateActive),
1005
- detail: ([total]) => `${total} downloaded`,
1032
+ detail: ({ total }) => `${total} downloaded`,
1006
1033
  });
1007
1034
  allMetadataEntries = [
1008
1035
  ...allMetadataEntries,
1009
1036
  ...regularChartMeta,
1010
1037
  ];
1011
- const [sqlChartTotal, , sqlChartMeta] = await output.runItem({
1038
+ const { total: sqlChartTotal, metadataEntries: sqlChartMeta } = await output.runItem({
1012
1039
  label: 'SQL charts',
1013
1040
  action: () => (0, exports.downloadContent)(options.charts, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive),
1014
- detail: ([total]) => `${total} downloaded`,
1041
+ detail: ({ total }) => `${total} downloaded`,
1015
1042
  });
1016
1043
  allMetadataEntries = [...allMetadataEntries, ...sqlChartMeta];
1017
1044
  chartTotal = regularChartTotal + sqlChartTotal;
@@ -1024,30 +1051,38 @@ const downloadHandler = async (options) => {
1024
1051
  }
1025
1052
  else {
1026
1053
  let chartSlugs = [];
1054
+ let appSlugs = [];
1027
1055
  let dashMeta;
1028
- [dashboardTotal, chartSlugs, dashMeta] = await output.runItem({
1056
+ ({
1057
+ total: dashboardTotal,
1058
+ chartSlugs,
1059
+ appSlugs,
1060
+ metadataEntries: dashMeta,
1061
+ } = await output.runItem({
1029
1062
  label: 'Dashboards',
1030
1063
  action: () => (0, exports.downloadContent)(options.dashboards, 'dashboards', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive),
1031
- detail: ([total]) => `${total} downloaded`,
1032
- });
1064
+ detail: ({ total }) => `${total} downloaded`,
1065
+ }));
1033
1066
  allMetadataEntries = [...allMetadataEntries, ...dashMeta];
1034
1067
  if (hasFilters &&
1035
1068
  chartSlugs.length > 0 &&
1036
1069
  !options.skipCharts) {
1037
1070
  output.startItem('Linked charts');
1038
1071
  output.updateActive(`${chartSlugs.length} dashboard dependencies`);
1039
- const [regularCharts, , linkedChartMeta] = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces, output.updateActive);
1072
+ const { total: regularCharts, metadataEntries: linkedChartMeta, } = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces, output.updateActive);
1040
1073
  allMetadataEntries = [
1041
1074
  ...allMetadataEntries,
1042
1075
  ...linkedChartMeta,
1043
1076
  ];
1044
- const [sqlCharts, , linkedSqlMeta] = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive);
1077
+ const { total: sqlCharts, metadataEntries: linkedSqlMeta } = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive);
1045
1078
  allMetadataEntries = [
1046
1079
  ...allMetadataEntries,
1047
1080
  ...linkedSqlMeta,
1048
1081
  ];
1049
1082
  output.completeItem(`${regularCharts + sqlCharts} downloaded`);
1050
1083
  }
1084
+ // Consumed after the explicit apps step (see cappedAppSlugs).
1085
+ dashboardAppSlugs = appSlugs;
1051
1086
  }
1052
1087
  }
1053
1088
  if (!options.spacesOnly && shouldDownloadAiAgents(options)) {
@@ -1106,6 +1141,9 @@ const downloadHandler = async (options) => {
1106
1141
  apps: Array.isArray(options.apps) ? options.apps : undefined,
1107
1142
  includeApps,
1108
1143
  });
1144
+ // Slugs covered by a (possibly --apps-limit-truncated) --include-apps
1145
+ // listing, so the Linked data apps step knows what fell outside the cap.
1146
+ let cappedAppSlugs = new Set();
1109
1147
  if (appsSelection.mode !== 'none') {
1110
1148
  output.startItem('Data apps');
1111
1149
  let appRefsToDownload;
@@ -1116,14 +1154,17 @@ const downloadHandler = async (options) => {
1116
1154
  else {
1117
1155
  // List every app in the project (includes apps not in any space)
1118
1156
  output.updateActive('listing project apps…');
1119
- let listedAppUuids;
1157
+ let listedApps;
1120
1158
  try {
1121
1159
  const projectApps = await (0, apiClient_1.lightdashApi)({
1122
1160
  method: 'GET',
1123
1161
  url: `/api/v1/ee/projects/${projectId}/apps`,
1124
1162
  body: undefined,
1125
1163
  });
1126
- listedAppUuids = projectApps.map((app) => app.appUuid);
1164
+ listedApps = projectApps.map((app) => ({
1165
+ appUuid: app.appUuid,
1166
+ slug: app.slug,
1167
+ }));
1127
1168
  }
1128
1169
  catch (listErr) {
1129
1170
  if (!(0, appsDownload_1.shouldFallBackToSpaceScopedListing)(listErr)) {
@@ -1131,18 +1172,21 @@ const downloadHandler = async (options) => {
1131
1172
  throw listErr;
1132
1173
  }
1133
1174
  appListingError = (0, common_1.getErrorMessage)(listErr);
1134
- listedAppUuids = [];
1175
+ listedApps = [];
1135
1176
  }
1136
1177
  else {
1137
1178
  globalState_1.default.log(styles.warning('This server does not support project-wide app listing; only apps that are in a space will be included.'));
1138
- listedAppUuids =
1139
- await listAppUuidsViaContentApi(projectId);
1179
+ listedApps = await listAppsViaContentApi(projectId);
1140
1180
  }
1141
1181
  }
1142
- const { appUuids: cappedAppUuids, truncatedCount } = (0, appsDownload_1.capListedApps)(listedAppUuids, appsLimit);
1182
+ const { appUuids: cappedAppUuids, truncatedCount } = (0, appsDownload_1.capListedApps)(listedApps.map((app) => app.appUuid), appsLimit);
1143
1183
  if (truncatedCount > 0) {
1144
- globalState_1.default.log(styles.warning(`Found ${listedAppUuids.length} data apps, downloading the first ${appsLimit}. Pass --apps-limit <n> to raise the cap.`));
1184
+ globalState_1.default.log(styles.warning(`Found ${listedApps.length} data apps, downloading the first ${appsLimit}. Pass --apps-limit <n> to raise the cap.`));
1145
1185
  }
1186
+ const cappedAppUuidSet = new Set(cappedAppUuids);
1187
+ cappedAppSlugs = new Set(listedApps
1188
+ .filter((app) => cappedAppUuidSet.has(app.appUuid))
1189
+ .map((app) => app.slug));
1146
1190
  appRefsToDownload = [
1147
1191
  ...new Set([
1148
1192
  ...cappedAppUuids,
@@ -1162,71 +1206,62 @@ const downloadHandler = async (options) => {
1162
1206
  output.updateActive(`0 of ${appRefsToDownload.length} downloaded`);
1163
1207
  const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(options.path);
1164
1208
  const appsDir = path.join(baseDir, 'apps');
1165
- const takenFolders = new Set();
1166
- let appSuccessCount = 0;
1167
- let appSkippedNotBuiltCount = 0;
1168
- const appFailures = [];
1169
- for (const appRef of appRefsToDownload) {
1170
- try {
1171
- // eslint-disable-next-line no-await-in-loop
1172
- const code = (0, appsDownload_1.ensureDownloadedAppContext)(appRef, await (0, apiClient_1.lightdashApi)({
1173
- method: 'GET',
1174
- url: `/api/v1/ee/projects/${projectId}/apps/${appRef}/download`,
1175
- body: undefined,
1176
- }));
1177
- const folder = (0, appCodeFiles_1.resolveAppFolderName)(code.manifest, takenFolders);
1178
- takenFolders.add(folder);
1179
- const appDir = path.join(appsDir, folder);
1180
- const manifest = {
1181
- ...code.manifest,
1182
- scaffoldingVersion: env_1.CLI_VERSION,
1183
- };
1184
- // eslint-disable-next-line no-await-in-loop
1185
- await (0, appCodeFiles_1.writeBundleToDir)(appDir, { ...code, manifest });
1186
- // eslint-disable-next-line no-await-in-loop
1187
- await (0, appCodeFiles_1.writeFilesToDir)(appDir, (0, scaffolding_1.buildStaticAuthoringFiles)({
1188
- appName: code.manifest.name,
1189
- sdkVersion: env_1.CLI_VERSION,
1190
- }));
1191
- // Server-provided deps override the scaffold's
1192
- // template package.json so re-uploads round-trip.
1193
- if (code.dependencies) {
1194
- // eslint-disable-next-line no-await-in-loop
1195
- await (0, appCodeFiles_1.writeDependenciesToDir)(appDir, code.dependencies);
1196
- }
1197
- // eslint-disable-next-line no-await-in-loop
1198
- await (0, appCodeFiles_1.writeContextToDir)(appDir, code.context);
1199
- appSuccessCount += 1;
1200
- }
1201
- catch (appErr) {
1202
- const outcome = (0, appsDownload_1.classifyAppDownloadError)(appErr);
1203
- if (outcome.kind === 'skip-not-built') {
1204
- appSkippedNotBuiltCount += 1;
1205
- globalState_1.default.debug(`> Skipped app ${appRef}: no built version to download`);
1206
- }
1207
- else {
1208
- appFailures.push({
1209
- appRef,
1210
- message: outcome.message,
1211
- });
1212
- globalState_1.default.log(styles.error(`Failed to download app ${appRef}: ${outcome.message}`));
1213
- }
1214
- }
1215
- output.updateActive(`${appSuccessCount +
1216
- appSkippedNotBuiltCount +
1217
- appFailures.length} of ${appRefsToDownload.length} processed`);
1218
- }
1219
- const summary = (0, appsDownload_1.appsDownloadSummary)(appSuccessCount, appRefsToDownload.length, appFailures, appsDir, appSkippedNotBuiltCount);
1220
- output.completeItem(`${appSuccessCount} downloaded${appSkippedNotBuiltCount > 0
1221
- ? `, ${appSkippedNotBuiltCount} skipped`
1222
- : ''}${appFailures.length > 0
1223
- ? `, ${appFailures.length} failed`
1224
- : ''}`, summary.ok ? undefined : 'warning');
1209
+ const { successCount, skippedNotBuiltCount, failures } = await (0, appsDownload_1.downloadAppsToDir)({
1210
+ appRefs: appRefsToDownload,
1211
+ projectId,
1212
+ appsDir,
1213
+ takenFolders: downloadedAppFolders,
1214
+ cliVersion: env_1.CLI_VERSION,
1215
+ fetchApp: (fetchProjectId, appRef) => (0, apiClient_1.lightdashApi)({
1216
+ method: 'GET',
1217
+ url: `/api/v1/ee/projects/${fetchProjectId}/apps/${encodeURIComponent(appRef)}/download`,
1218
+ body: undefined,
1219
+ }),
1220
+ onProgress: (processed, total) => output.updateActive(`${processed} of ${total} processed`),
1221
+ });
1222
+ const summary = (0, appsDownload_1.appsDownloadSummary)(successCount, appRefsToDownload.length, failures, appsDir, skippedNotBuiltCount);
1223
+ output.completeItem(`${successCount} downloaded${skippedNotBuiltCount > 0
1224
+ ? `, ${skippedNotBuiltCount} skipped`
1225
+ : ''}${failures.length > 0 ? `, ${failures.length} failed` : ''}`, summary.ok ? undefined : 'warning');
1225
1226
  if (!summary.ok) {
1226
1227
  summary.failureLines.forEach((line) => globalState_1.default.log(styles.warning(line)));
1227
1228
  }
1228
1229
  }
1229
1230
  }
1231
+ // Dashboard-referenced apps not already covered above (explicit
1232
+ // --apps ref, or a non-truncated slot in the --include-apps cap).
1233
+ const linkedAppSlugs = (0, appsDownload_1.computeLinkedAppSlugs)({
1234
+ appSlugs: dashboardAppSlugs,
1235
+ explicitRefs: explicitAppRefs,
1236
+ includeApps,
1237
+ cappedAppSlugs,
1238
+ });
1239
+ if (linkedAppSlugs.length > 0) {
1240
+ output.startItem('Linked data apps');
1241
+ const appsDir = path.join((0, contentAsCodePaths_1.getDownloadFolder)(options.path), 'apps');
1242
+ const outcome = await (0, appsDownload_1.downloadAppsToDir)({
1243
+ appRefs: linkedAppSlugs,
1244
+ projectId,
1245
+ appsDir,
1246
+ takenFolders: downloadedAppFolders,
1247
+ cliVersion: env_1.CLI_VERSION,
1248
+ fetchApp: (fetchProjectId, appRef) => (0, apiClient_1.lightdashApi)({
1249
+ method: 'GET',
1250
+ url: `/api/v1/ee/projects/${fetchProjectId}/apps/${encodeURIComponent(appRef)}/download`,
1251
+ body: undefined,
1252
+ }),
1253
+ onProgress: (processed, total) => output.updateActive(`${processed} of ${total} processed`),
1254
+ });
1255
+ const linkedSummary = (0, appsDownload_1.appsDownloadSummary)(outcome.successCount, linkedAppSlugs.length, outcome.failures, appsDir, outcome.skippedNotBuiltCount);
1256
+ output.completeItem(`${outcome.successCount} downloaded${outcome.skippedNotBuiltCount > 0
1257
+ ? `, ${outcome.skippedNotBuiltCount} skipped`
1258
+ : ''}${outcome.failures.length > 0
1259
+ ? `, ${outcome.failures.length} failed`
1260
+ : ''}`, linkedSummary.ok ? undefined : 'warning');
1261
+ if (!linkedSummary.ok) {
1262
+ linkedSummary.failureLines.forEach((line) => globalState_1.default.log(styles.warning(line)));
1263
+ }
1264
+ }
1230
1265
  // Write metadata file with all downloadedAt timestamps
1231
1266
  const metadataToWrite = {
1232
1267
  version: 1,
@@ -1364,6 +1399,7 @@ const upsertSingleItem = async (item, type, projectId, changes, force, config, s
1364
1399
  }),
1365
1400
  });
1366
1401
  globalState_1.default.debug(`${type} "${item.name}": ${upsertData[type]?.[0].action}`);
1402
+ (upsertData.warnings ?? []).forEach((warning) => globalState_1.default.log(styles.warning(` ⚠ ${item.slug}: ${warning}`)));
1367
1403
  // Merge storeUploadChanges result into changes in-place
1368
1404
  const updatedChanges = storeUploadChanges(changes, upsertData);
1369
1405
  Object.keys(updatedChanges).forEach((key) => {
@@ -1524,21 +1560,31 @@ const upsertResources = async (type, projectId, changes, force, slugs, canUpload
1524
1560
  }
1525
1561
  return { changes, total: filteredItems.length };
1526
1562
  };
1527
- const getDashboardChartSlugs = async (dashboardSlugs, customPath, looseDashboards = []) => {
1563
+ // readCodeFiles walks the whole download folder recursively, so callers that
1564
+ // need both chart and app slugs should read once and reuse the result.
1565
+ const readDashboardItems = async (customPath, looseDashboards = []) => {
1528
1566
  const folderDashboards = await readCodeFiles('dashboards', customPath);
1529
- const dashboardItems = [...folderDashboards, ...looseDashboards];
1530
- const filteredDashboardItems = dashboardSlugs.length > 0
1531
- ? dashboardItems.filter((dashboard) => dashboardSlugs.includes(dashboard.slug))
1532
- : dashboardItems;
1533
- return filteredDashboardItems.reduce((acc, dashboard) => {
1534
- const dashboardChartSlugs = dashboard.tiles
1535
- .map((tile) => 'chartSlug' in tile.properties
1536
- ? tile.properties.chartSlug
1537
- : undefined)
1538
- .filter((dashboardChartSlug) => !!dashboardChartSlug);
1539
- return [...acc, ...dashboardChartSlugs];
1540
- }, []);
1567
+ return [...folderDashboards, ...looseDashboards];
1541
1568
  };
1569
+ const selectDashboards = (dashboardItems, dashboardSlugs) => dashboardSlugs.length > 0
1570
+ ? dashboardItems.filter((dashboard) => dashboardSlugs.includes(dashboard.slug))
1571
+ : dashboardItems;
1572
+ const selectDashboardChartSlugs = (dashboardItems, dashboardSlugs) => selectDashboards(dashboardItems, dashboardSlugs).reduce((acc, dashboard) => {
1573
+ const dashboardChartSlugs = dashboard.tiles
1574
+ .map((tile) => 'chartSlug' in tile.properties
1575
+ ? tile.properties.chartSlug
1576
+ : undefined)
1577
+ .filter((dashboardChartSlug) => !!dashboardChartSlug);
1578
+ return [...acc, ...dashboardChartSlugs];
1579
+ }, []);
1580
+ const selectDashboardAppSlugs = (dashboardItems, dashboardSlugs) => [
1581
+ ...new Set(extractAppSlugsFromDashboards(selectDashboards(dashboardItems, dashboardSlugs))),
1582
+ ];
1583
+ const getDashboardChartSlugs = async (dashboardSlugs, customPath, looseDashboards = []) => selectDashboardChartSlugs(await readDashboardItems(customPath, looseDashboards), dashboardSlugs);
1584
+ const getDashboardAppSlugs = async (dashboardSlugs, customPath, looseDashboards = []) => selectDashboardAppSlugs(await readDashboardItems(customPath, looseDashboards), dashboardSlugs);
1585
+ // Mirrors the Dashboards phase's own guard: a filtered upload with no
1586
+ // dashboard slugs uploads no dashboards, so there is nothing to derive from.
1587
+ const isFilteredWithNoDashboards = (hasFilters, dashboardSlugs) => hasFilters && dashboardSlugs.length === 0;
1542
1588
  const uploadHandler = async (options) => {
1543
1589
  globalState_1.default.setVerbose(options.verbose);
1544
1590
  if (options.spacesOnly && options.skipSpaces) {
@@ -1671,97 +1717,8 @@ const uploadHandler = async (options) => {
1671
1717
  });
1672
1718
  }
1673
1719
  }
1674
- changes = await runUploadChangesPhase({
1675
- output,
1676
- label: 'Charts',
1677
- changes,
1678
- action: async () => {
1679
- const chartSlugs = options.includeCharts
1680
- ? Array.from(new Set([
1681
- ...options.charts,
1682
- ...(await getDashboardChartSlugs(options.dashboards, options.path, looseFiles.dashboards)),
1683
- ]))
1684
- : options.charts;
1685
- if (hasFilters && chartSlugs.length === 0) {
1686
- globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
1687
- return changes;
1688
- }
1689
- const result = await upsertResources('charts', projectId, changes, options.force, chartSlugs, uploadPermissions.charts, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.charts, spaceNames);
1690
- chartTotal = result.total;
1691
- return result.changes;
1692
- },
1693
- });
1694
- changes = await runUploadChangesPhase({
1695
- output,
1696
- label: 'Dashboards',
1697
- changes,
1698
- action: async () => {
1699
- if (hasFilters && options.dashboards.length === 0) {
1700
- globalState_1.default.log(styles.warning(`No dashboard filters provided, skipping`));
1701
- return changes;
1702
- }
1703
- const result = await upsertResources('dashboards', projectId, changes, options.force, options.dashboards, uploadPermissions.dashboards, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.dashboards, spaceNames);
1704
- dashboardTotal = result.total;
1705
- return result.changes;
1706
- },
1707
- });
1708
- if (!options.skipAgents) {
1709
- if (hasFilters && options.agents.length === 0) {
1710
- globalState_1.default.log(styles.warning(`No AI agent filters provided, skipping`));
1711
- }
1712
- else {
1713
- try {
1714
- changes = await runUploadChangesPhase({
1715
- output,
1716
- label: 'AI agents',
1717
- changes,
1718
- action: () => upsertAiAgents(projectId, options.agents, changes, options.force, options.path, options.agents.length === 0),
1719
- });
1720
- }
1721
- catch (error) {
1722
- throw new AiAgentAsCodeUploadError(error);
1723
- }
1724
- }
1725
- }
1726
- if (!options.skipAlerts) {
1727
- if (hasFilters && options.alerts.length === 0) {
1728
- globalState_1.default.log(styles.warning(`No alert filters provided, skipping`));
1729
- }
1730
- else {
1731
- changes = await runUploadChangesPhase({
1732
- output,
1733
- label: 'Alerts',
1734
- changes,
1735
- action: () => upsertScheduledContent(projectId, options.alerts, changes, options.force, common_1.ContentAsCodeType.ALERT, uploadPermissions.alerts, options.path),
1736
- });
1737
- }
1738
- }
1739
- if (!options.skipScheduledDeliveries) {
1740
- if (hasFilters && options.scheduledDeliveries.length === 0) {
1741
- globalState_1.default.log(styles.warning(`No scheduled delivery filters provided, skipping`));
1742
- }
1743
- else {
1744
- changes = await runUploadChangesPhase({
1745
- output,
1746
- label: 'Scheduled deliveries',
1747
- changes,
1748
- action: () => upsertScheduledContent(projectId, options.scheduledDeliveries, changes, options.force, common_1.ContentAsCodeType.SCHEDULED_DELIVERY, uploadPermissions.scheduledDeliveries, options.path),
1749
- });
1750
- }
1751
- }
1752
- if (!options.skipGoogleSheets) {
1753
- if (hasFilters && options.googleSheets.length === 0) {
1754
- globalState_1.default.log(styles.warning(`No Google Sheets sync filters provided, skipping`));
1755
- }
1756
- else {
1757
- changes = await runUploadChangesPhase({
1758
- output,
1759
- label: 'Google Sheets syncs',
1760
- changes,
1761
- action: () => upsertScheduledContent(projectId, options.googleSheets, changes, options.force, common_1.ContentAsCodeType.GOOGLE_SHEETS_SYNC, uploadPermissions.googleSheets, options.path),
1762
- });
1763
- }
1764
- }
1720
+ // Apps resolve their external connection links by slug in the target
1721
+ // project, so connections must exist before any app is uploaded.
1765
1722
  if (!options.skipExternalConnections) {
1766
1723
  if (hasFilters && options.externalConnections.length === 0) {
1767
1724
  globalState_1.default.log(styles.warning(`No external connection filters provided, skipping`));
@@ -1775,27 +1732,72 @@ const uploadHandler = async (options) => {
1775
1732
  });
1776
1733
  }
1777
1734
  }
1778
- // Upload data apps (enterprise, opt-in via --apps <references...> or
1779
- // --include-apps, fire-and-forget)
1735
+ // Both the Data apps and Charts phases derive slugs from the same
1736
+ // dashboard YAML; read the download folder once and share it.
1737
+ let dashboardItemsPromise;
1738
+ const loadDashboardItems = () => {
1739
+ dashboardItemsPromise =
1740
+ dashboardItemsPromise ??
1741
+ readDashboardItems(options.path, looseFiles.dashboards);
1742
+ return dashboardItemsPromise;
1743
+ };
1744
+ // Upload data apps (enterprise; explicit --apps/--include-apps, or
1745
+ // auto-pushed for a dashboard's apps). Must land before dashboards.
1780
1746
  const explicitAppReferences = Array.isArray(options.apps)
1781
1747
  ? options.apps
1782
1748
  : [];
1783
- const shouldUploadApps = options.includeApps === true || explicitAppReferences.length > 0;
1749
+ const isExplicitAppSelection = options.includeApps === true || explicitAppReferences.length > 0;
1750
+ const autoPushAppSlugs = isFilteredWithNoDashboards(hasFilters, options.dashboards)
1751
+ ? []
1752
+ : selectDashboardAppSlugs(await loadDashboardItems(), options.dashboards);
1753
+ const shouldUploadApps = isExplicitAppSelection || autoPushAppSlugs.length > 0;
1784
1754
  let appsCreated = 0;
1785
1755
  let appsUpdated = 0;
1756
+ let appsUnchanged = 0;
1786
1757
  let appsFailed = 0;
1787
1758
  let appsSkipped = 0;
1759
+ let eeAppRoutesUnavailable = false;
1788
1760
  const changesBeforeApps = { ...changes };
1789
1761
  if (shouldUploadApps && !uploadPermissions.dataApps) {
1790
1762
  output.startItem('Data apps');
1791
- globalState_1.default.log(styles.error(`Error uploading data apps: create:DataApp or manage:DataApp permission is required`));
1763
+ globalState_1.default.log(styles.warning(`Skipping data apps: create:DataApp or manage:DataApp permission is required. Dashboard tiles will resolve only if their apps already exist in this project.`));
1792
1764
  output.completeItem('permission denied', 'warning');
1793
1765
  }
1794
1766
  else if (shouldUploadApps) {
1795
1767
  output.startItem('Data apps');
1796
- // --include-apps uploads every folder on disk; explicit references
1797
- // filter folders by their manifest slug or appUuid
1798
- const uploadFilter = (0, appsDownload_1.getDataAppUploadFilter)(explicitAppReferences, options.includeApps === true);
1768
+ // Explicit refs filter by slug/appUuid; --include-apps uploads all.
1769
+ // A pure auto-push run applies no filter — gated per folder below.
1770
+ const uploadFilter = isExplicitAppSelection
1771
+ ? (0, appsDownload_1.getDataAppUploadFilter)(explicitAppReferences, options.includeApps === true)
1772
+ : null;
1773
+ // The manifest always names the source project, so comparing it
1774
+ // to the target would re-push every run — list the target instead.
1775
+ let presence = {
1776
+ kind: 'unknown',
1777
+ targetProjectUuid: projectId,
1778
+ };
1779
+ // Only the auto-push gate below (skipped entirely when explicit)
1780
+ // reads presence, so an explicit run never needs the listing.
1781
+ if (!isExplicitAppSelection && autoPushAppSlugs.length > 0) {
1782
+ try {
1783
+ const projectApps = await (0, apiClient_1.lightdashApi)({
1784
+ method: 'GET',
1785
+ url: `/api/v1/ee/projects/${projectId}/apps`,
1786
+ body: undefined,
1787
+ });
1788
+ // An older server answers without slugs; that is not an
1789
+ // error, but it is not an answer either.
1790
+ if (projectApps.every((app) => app.slug !== undefined)) {
1791
+ presence = {
1792
+ kind: 'known',
1793
+ slugs: new Set(projectApps.map((app) => app.slug)),
1794
+ };
1795
+ }
1796
+ }
1797
+ catch (listErr) {
1798
+ globalState_1.default.debug(`Could not list target project apps: ${(0, common_1.getErrorMessage)(listErr)}`);
1799
+ }
1800
+ }
1799
1801
  const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(options.path);
1800
1802
  const appsDir = path.join(baseDir, 'apps');
1801
1803
  let appFolderEntries;
@@ -1806,7 +1808,6 @@ const uploadHandler = async (options) => {
1806
1808
  }
1807
1809
  catch (err) {
1808
1810
  if (err.code === 'ENOENT') {
1809
- globalState_1.default.log(styles.warning(`No apps directory found at ${appsDir}. Run 'lightdash download --include-apps' first.`));
1810
1811
  appFolderEntries = [];
1811
1812
  }
1812
1813
  else {
@@ -1815,7 +1816,9 @@ const uploadHandler = async (options) => {
1815
1816
  }
1816
1817
  const subDirs = appFolderEntries.filter((e) => e.isDirectory());
1817
1818
  if (subDirs.length === 0) {
1818
- globalState_1.default.log(styles.warning(`No app folders found in ${appsDir}.`));
1819
+ globalState_1.default.log(styles.warning(isExplicitAppSelection
1820
+ ? `No app folders found in ${appsDir}. Run 'lightdash download --include-apps' first.`
1821
+ : `No app folders found in ${appsDir} for the dashboard(s) being uploaded. Re-run 'lightdash download' to fetch their apps.`));
1819
1822
  }
1820
1823
  const matchedRefs = new Set();
1821
1824
  for (const subDir of subDirs) {
@@ -1824,13 +1827,41 @@ const uploadHandler = async (options) => {
1824
1827
  // eslint-disable-next-line no-await-in-loop
1825
1828
  const code = await (0, appCodeFiles_1.readBundleFromDir)(folderPath);
1826
1829
  if (!(0, appsDownload_1.uploadFilterMatches)(uploadFilter, code.manifest)) {
1827
- globalState_1.default.debug(`Skipping app folder "${subDir.name}" (not in filter)`);
1830
+ const isAutoPushCandidate = code.manifest.slug !== undefined &&
1831
+ autoPushAppSlugs.includes(code.manifest.slug);
1832
+ if (isAutoPushCandidate) {
1833
+ globalState_1.default.log(styles.warning(`Skipping app "${subDir.name}" — excluded by --apps, but a dashboard being uploaded references it. Its tile may fail to resolve unless the app already exists in the target project.`));
1834
+ }
1835
+ else {
1836
+ globalState_1.default.debug(`Skipping app folder "${subDir.name}" (not in filter)`);
1837
+ }
1828
1838
  // eslint-disable-next-line no-continue
1829
1839
  continue;
1830
1840
  }
1831
1841
  if (uploadFilter) {
1832
1842
  (0, appsDownload_1.matchedUploadRefs)(uploadFilter, code.manifest).forEach((ref) => matchedRefs.add(ref));
1833
1843
  }
1844
+ if (!isExplicitAppSelection) {
1845
+ const isAutoPushCandidate = code.manifest.slug !== undefined &&
1846
+ autoPushAppSlugs.includes(code.manifest.slug);
1847
+ if (!isAutoPushCandidate) {
1848
+ // eslint-disable-next-line no-continue
1849
+ continue;
1850
+ }
1851
+ // eslint-disable-next-line no-await-in-loop
1852
+ const folderChanged = await (0, appCodeFiles_1.appFolderNeedsUpdating)(folderPath, code.manifest);
1853
+ if (!(0, appsDownload_1.shouldAutoPushApp)({
1854
+ manifest: code.manifest,
1855
+ presence,
1856
+ folderChanged,
1857
+ force: options.force === true,
1858
+ })) {
1859
+ globalState_1.default.debug(`Skipping app "${subDir.name}" — unchanged and already in the target project`);
1860
+ appsSkipped += 1;
1861
+ // eslint-disable-next-line no-continue
1862
+ continue;
1863
+ }
1864
+ }
1834
1865
  // Read declared dependencies from the app folder (optional).
1835
1866
  // eslint-disable-next-line no-await-in-loop
1836
1867
  const rawDeps = await (0, appCodeFiles_1.readDependenciesFromDir)(folderPath);
@@ -1896,6 +1927,7 @@ const uploadHandler = async (options) => {
1896
1927
  }
1897
1928
  const body = (0, appCodeFiles_1.buildImportBody)(codeToUpload, projectId, {
1898
1929
  createNew: options.createNew === true,
1930
+ force: options.force,
1899
1931
  });
1900
1932
  // eslint-disable-next-line no-await-in-loop
1901
1933
  const { appUuid, version, action, slug, warnings } = await (0, apiClient_1.lightdashApi)({
@@ -1906,14 +1938,20 @@ const uploadHandler = async (options) => {
1906
1938
  // e.g. a manifest external-connection link whose slug is
1907
1939
  // missing in the target project was skipped
1908
1940
  (warnings ?? []).forEach((warning) => globalState_1.default.log(styles.warning(warning)));
1909
- if (action === 'create') {
1910
- appsCreated += 1;
1941
+ if (action === 'unchanged') {
1942
+ appsUnchanged += 1;
1943
+ globalState_1.default.log(styles.secondary(`"${code.manifest.name}" matches v${version} — skipped, no rebuild. Pass --force to rebuild anyway.`));
1911
1944
  }
1912
1945
  else {
1913
- appsUpdated += 1;
1946
+ if (action === 'create') {
1947
+ appsCreated += 1;
1948
+ }
1949
+ else {
1950
+ appsUpdated += 1;
1951
+ }
1952
+ const actionLabel = action === 'create' ? 'created' : 'updated';
1953
+ globalState_1.default.log(styles.success(`Uploaded "${code.manifest.name}" — ${actionLabel} v${version} (${appUuid}). Building in the background; the app will show "building" until the server finishes.`));
1914
1954
  }
1915
- const actionLabel = action === 'create' ? 'created' : 'updated';
1916
- globalState_1.default.log(styles.success(`Uploaded "${code.manifest.name}" — ${actionLabel} v${version} (${appUuid}). Building in the background; the app will show "building" until the server finishes.`));
1917
1955
  if (code.manifest.slug === undefined) {
1918
1956
  globalState_1.default.log(styles.warning((0, appsDownload_1.preSlugUploadHint)({
1919
1957
  folder: subDir.name,
@@ -1931,10 +1969,17 @@ const uploadHandler = async (options) => {
1931
1969
  }
1932
1970
  }
1933
1971
  catch (appErr) {
1934
- appsFailed += 1;
1935
1972
  const status = appErr instanceof common_1.LightdashError
1936
1973
  ? appErr.statusCode
1937
1974
  : undefined;
1975
+ // Auto-push is flag-free, so a server without the EE app
1976
+ // routes must not fail an upload the user never asked for.
1977
+ if (!isExplicitAppSelection && status === 404) {
1978
+ eeAppRoutesUnavailable = true;
1979
+ globalState_1.default.log(styles.warning(`Skipping data apps: the enterprise "data apps" feature is not available on this instance. Dashboard tiles will resolve only if their apps already exist in this project.`));
1980
+ break;
1981
+ }
1982
+ appsFailed += 1;
1938
1983
  const hint = status === 404
1939
1984
  ? ' — the enterprise "data apps" feature may not be enabled on this instance'
1940
1985
  : '';
@@ -1951,12 +1996,19 @@ const uploadHandler = async (options) => {
1951
1996
  changes['data apps created'] = appsCreated;
1952
1997
  if (appsUpdated > 0)
1953
1998
  changes['data apps updated'] = appsUpdated;
1999
+ if (appsUnchanged > 0)
2000
+ changes['data apps unchanged'] = appsUnchanged;
1954
2001
  if (appsFailed > 0)
1955
2002
  changes['data apps failed'] = appsFailed;
1956
2003
  if (appsSkipped > 0)
1957
2004
  changes['data apps skipped'] = appsSkipped;
1958
2005
  const appSummary = summarizeUploadChanges(changesBeforeApps, changes);
1959
- output.completeItem(appSummary.detail, appSummary.variant);
2006
+ if (eeAppRoutesUnavailable) {
2007
+ output.completeItem('not available on this server', 'warning');
2008
+ }
2009
+ else {
2010
+ output.completeItem(appSummary.detail, appSummary.variant);
2011
+ }
1960
2012
  if (appsFailed > 0) {
1961
2013
  // App uploads are fire-and-forget per folder, so failures are
1962
2014
  // logged and tallied rather than thrown — but the process must
@@ -1965,6 +2017,97 @@ const uploadHandler = async (options) => {
1965
2017
  process.exitCode = 1;
1966
2018
  }
1967
2019
  }
2020
+ changes = await runUploadChangesPhase({
2021
+ output,
2022
+ label: 'Charts',
2023
+ changes,
2024
+ action: async () => {
2025
+ const chartSlugs = options.includeCharts
2026
+ ? Array.from(new Set([
2027
+ ...options.charts,
2028
+ ...selectDashboardChartSlugs(await loadDashboardItems(), options.dashboards),
2029
+ ]))
2030
+ : options.charts;
2031
+ if (hasFilters && chartSlugs.length === 0) {
2032
+ globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
2033
+ return changes;
2034
+ }
2035
+ const result = await upsertResources('charts', projectId, changes, options.force, chartSlugs, uploadPermissions.charts, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.charts, spaceNames);
2036
+ chartTotal = result.total;
2037
+ return result.changes;
2038
+ },
2039
+ });
2040
+ changes = await runUploadChangesPhase({
2041
+ output,
2042
+ label: 'Dashboards',
2043
+ changes,
2044
+ action: async () => {
2045
+ if (hasFilters && options.dashboards.length === 0) {
2046
+ globalState_1.default.log(styles.warning(`No dashboard filters provided, skipping`));
2047
+ return changes;
2048
+ }
2049
+ const result = await upsertResources('dashboards', projectId, changes, options.force, options.dashboards, uploadPermissions.dashboards, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.dashboards, spaceNames);
2050
+ dashboardTotal = result.total;
2051
+ return result.changes;
2052
+ },
2053
+ });
2054
+ if (!options.skipAgents) {
2055
+ if (hasFilters && options.agents.length === 0) {
2056
+ globalState_1.default.log(styles.warning(`No AI agent filters provided, skipping`));
2057
+ }
2058
+ else {
2059
+ try {
2060
+ changes = await runUploadChangesPhase({
2061
+ output,
2062
+ label: 'AI agents',
2063
+ changes,
2064
+ action: () => upsertAiAgents(projectId, options.agents, changes, options.force, options.path, options.agents.length === 0),
2065
+ });
2066
+ }
2067
+ catch (error) {
2068
+ throw new AiAgentAsCodeUploadError(error);
2069
+ }
2070
+ }
2071
+ }
2072
+ if (!options.skipAlerts) {
2073
+ if (hasFilters && options.alerts.length === 0) {
2074
+ globalState_1.default.log(styles.warning(`No alert filters provided, skipping`));
2075
+ }
2076
+ else {
2077
+ changes = await runUploadChangesPhase({
2078
+ output,
2079
+ label: 'Alerts',
2080
+ changes,
2081
+ action: () => upsertScheduledContent(projectId, options.alerts, changes, options.force, common_1.ContentAsCodeType.ALERT, uploadPermissions.alerts, options.path),
2082
+ });
2083
+ }
2084
+ }
2085
+ if (!options.skipScheduledDeliveries) {
2086
+ if (hasFilters && options.scheduledDeliveries.length === 0) {
2087
+ globalState_1.default.log(styles.warning(`No scheduled delivery filters provided, skipping`));
2088
+ }
2089
+ else {
2090
+ changes = await runUploadChangesPhase({
2091
+ output,
2092
+ label: 'Scheduled deliveries',
2093
+ changes,
2094
+ action: () => upsertScheduledContent(projectId, options.scheduledDeliveries, changes, options.force, common_1.ContentAsCodeType.SCHEDULED_DELIVERY, uploadPermissions.scheduledDeliveries, options.path),
2095
+ });
2096
+ }
2097
+ }
2098
+ if (!options.skipGoogleSheets) {
2099
+ if (hasFilters && options.googleSheets.length === 0) {
2100
+ globalState_1.default.log(styles.warning(`No Google Sheets sync filters provided, skipping`));
2101
+ }
2102
+ else {
2103
+ changes = await runUploadChangesPhase({
2104
+ output,
2105
+ label: 'Google Sheets syncs',
2106
+ changes,
2107
+ action: () => upsertScheduledContent(projectId, options.googleSheets, changes, options.force, common_1.ContentAsCodeType.GOOGLE_SHEETS_SYNC, uploadPermissions.googleSheets, options.path),
2108
+ });
2109
+ }
2110
+ }
1968
2111
  const end = Date.now();
1969
2112
  await analytics_1.LightdashAnalytics.track({
1970
2113
  event: 'upload.completed',
@@ -1999,12 +2142,15 @@ exports.uploadHandler = uploadHandler;
1999
2142
  exports.testHelpers = {
2000
2143
  assertUniqueSpacePaths: spacesAsCode_1.assertUniqueSpacePaths,
2001
2144
  downloadSpaces: spacesAsCode_1.downloadSpaces,
2145
+ extractAppSlugsFromDashboards,
2002
2146
  getFlatSpaceFileNames: spacesAsCode_1.getFlatSpaceFileNames,
2147
+ getDashboardAppSlugs,
2003
2148
  getDashboardChartSlugs,
2004
2149
  hasContentFilters,
2005
2150
  isAiAgentsUnavailableError,
2006
2151
  isExternalConnectionsUnavailableError,
2007
2152
  downloadAiAgents,
2153
+ isFilteredWithNoDashboards,
2008
2154
  readAiAgentFiles,
2009
2155
  readExternalConnectionFiles,
2010
2156
  readSpaceFiles: spacesAsCode_1.readSpaceFiles,