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