@lightdash/cli 0.3422.1 → 0.3423.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.
@@ -16,6 +16,7 @@ const config_1 = require("../config");
16
16
  const env_1 = require("../env");
17
17
  const globalState_1 = tslib_1.__importDefault(require("../globalState"));
18
18
  const styles = tslib_1.__importStar(require("../styles"));
19
+ const contentAsCodeOutput_1 = require("../terminal/contentAsCodeOutput");
19
20
  const appCodeFiles_1 = require("./apps/appCodeFiles");
20
21
  const appsDownload_1 = require("./apps/appsDownload");
21
22
  const scaffolding_1 = require("./apps/scaffolding");
@@ -184,7 +185,7 @@ const loadYamlFile = async (file, folder, metadata) => {
184
185
  };
185
186
  const readCodeFiles = async (folder, customPath) => {
186
187
  const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
187
- globalState_1.default.log(`Reading ${folder} from ${baseDir}`);
188
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Reading ${folder} from ${baseDir}`);
188
189
  const [major, minor] = process.versions.node.split('.').map(Number);
189
190
  if (major < 20 || (major === 20 && minor < 12)) {
190
191
  throw new Error(`Node.js v20.12.0 or later is required for this command (current: ${process.version}).`);
@@ -318,8 +319,7 @@ const extractChartSlugsFromDashboards = (dashboards) => dashboards.reduce((acc,
318
319
  .filter((slug) => slug !== undefined);
319
320
  return [...acc, ...slugs];
320
321
  }, []);
321
- const downloadContent = async (ids, type, projectId, projectName, customPath, languageMap = false, nested = false, skipSpaces = false, stripPivotSeries = false, rootSpaces = false) => {
322
- const spinner = globalState_1.default.getActiveSpinner();
322
+ const downloadContent = async (ids, type, projectId, projectName, customPath, languageMap = false, nested = false, skipSpaces = false, stripPivotSeries = false, rootSpaces = false, onProgress) => {
323
323
  const contentFilters = parseContentFilters(ids);
324
324
  const folderScheme = nested ? 'nested' : 'flat';
325
325
  const config = getContentTypeConfig(type, projectId);
@@ -341,7 +341,7 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
341
341
  url: `${config.endpoint}${queryParams}`,
342
342
  body: undefined,
343
343
  });
344
- spinner?.start(`Downloaded ${results.offset} of ${results.total} ${config.displayName}`);
344
+ onProgress?.(`${results.offset} of ${results.total} ${config.displayName} downloaded`);
345
345
  // For the same chart slug, we run the code for saved charts and sql chart
346
346
  // so we are going to get more false positives here, so we keep it on the debug log
347
347
  results.missingIds.forEach((missingId) => {
@@ -577,7 +577,7 @@ const upsertAiAgents = async (projectId, slugs, changes, force, customPath) => {
577
577
  }
578
578
  return changes;
579
579
  }
580
- globalState_1.default.log(`Found ${filteredAgents.length} AI agent files`);
580
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Found ${filteredAgents.length} AI agent files`);
581
581
  const results = await (0, apiClient_1.lightdashApi)({
582
582
  method: 'POST',
583
583
  url: `/api/v1/projects/${projectId}/code/aiAgents?force=${force}`,
@@ -644,7 +644,7 @@ const readScheduledContentFiles = async (contentType, customPath) => {
644
644
  const upsertScheduledContent = async (projectId, slugs, changes, force, contentType, canUpload, customPath) => {
645
645
  const config = getScheduledContentConfig(contentType, customPath);
646
646
  const scheduledContent = await readScheduledContentFiles(contentType, customPath);
647
- globalState_1.default.log(`Found ${scheduledContent.length} ${config.singular} files`);
647
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Found ${scheduledContent.length} ${config.singular} files`);
648
648
  const filteredContent = slugs.length
649
649
  ? scheduledContent.filter((item) => slugs.includes(item.slug))
650
650
  : scheduledContent;
@@ -785,9 +785,6 @@ const downloadHandler = async (options) => {
785
785
  const projectId = projectSelection.projectUuid;
786
786
  // Log current project info
787
787
  (0, selectProject_1.logSelectedProject)(projectSelection, config, 'Downloading from');
788
- const spinner = globalState_1.default.startSpinner(options.spacesOnly
789
- ? `Downloading spaces`
790
- : `Downloading content from project`);
791
788
  // Fetch project details to get project name for folder structure
792
789
  const project = await (0, apiClient_1.lightdashApi)({
793
790
  method: 'GET',
@@ -807,14 +804,17 @@ const downloadHandler = async (options) => {
807
804
  projectId,
808
805
  },
809
806
  });
807
+ const output = (0, contentAsCodeOutput_1.createContentAsCodeOutput)({
808
+ operation: 'download',
809
+ scope: 'project',
810
+ });
810
811
  try {
811
812
  let allMetadataEntries = [];
812
813
  if (shouldDownloadSpaces) {
813
- if (!options.spacesOnly)
814
- spinner.start(`Downloading spaces`);
814
+ output.startItem('Spaces');
815
815
  try {
816
816
  const spaceTotal = await (0, spacesAsCode_1.downloadSpaces)(projectId, projectName, options.path, options.nested, options.rootSpaces);
817
- spinner.succeed(`Downloaded ${spaceTotal} spaces`);
817
+ output.completeItem(`${spaceTotal} downloaded`);
818
818
  }
819
819
  catch (error) {
820
820
  if (!(0, spacesAsCode_1.shouldFallBackToEmbeddedSpaces)(error, options.spacesOnly)) {
@@ -823,16 +823,19 @@ const downloadHandler = async (options) => {
823
823
  : error;
824
824
  }
825
825
  skipEmbeddedSpaces = false;
826
- spinner.warn(styles.warning('Space access is unavailable; continuing with legacy space metadata where available.'));
826
+ output.completeItem('access unavailable; using legacy metadata', 'warning');
827
+ globalState_1.default.log(styles.warning('Space access is unavailable; continuing with legacy space metadata where available.'));
827
828
  globalState_1.default.debug(`Could not download access-aware spaces: ${(0, common_1.getErrorMessage)(error)}`);
828
829
  }
829
830
  }
830
831
  if (includeAllOptionalContent ||
831
832
  options.includeVirtualViews ||
832
833
  options.virtualViews.length > 0) {
833
- spinner.start(`Downloading virtual views`);
834
- const total = await downloadVirtualViews(projectId, options.virtualViews, options.path);
835
- spinner.succeed(`Downloaded ${total} virtual views`);
834
+ await output.runItem({
835
+ label: 'Virtual views',
836
+ action: () => downloadVirtualViews(projectId, options.virtualViews, options.path),
837
+ detail: (total) => `${total} downloaded`,
838
+ });
836
839
  }
837
840
  // Download regular charts and SQL charts
838
841
  if (!options.skipCharts) {
@@ -840,16 +843,20 @@ const downloadHandler = async (options) => {
840
843
  globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
841
844
  }
842
845
  else {
843
- const [regularChartTotal, , regularChartMeta] = await (0, exports.downloadContent)(options.charts, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces);
844
- spinner.succeed(`Downloaded ${regularChartTotal} charts`);
846
+ const [regularChartTotal, , regularChartMeta] = await output.runItem({
847
+ label: 'Charts',
848
+ action: () => (0, exports.downloadContent)(options.charts, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces, output.updateActive),
849
+ detail: ([total]) => `${total} downloaded`,
850
+ });
845
851
  allMetadataEntries = [
846
852
  ...allMetadataEntries,
847
853
  ...regularChartMeta,
848
854
  ];
849
- // Download SQL charts
850
- spinner.start(`Downloading SQL charts`);
851
- const [sqlChartTotal, , sqlChartMeta] = await (0, exports.downloadContent)(options.charts, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces);
852
- spinner.succeed(`Downloaded ${sqlChartTotal} SQL charts`);
855
+ const [sqlChartTotal, , sqlChartMeta] = await output.runItem({
856
+ label: 'SQL charts',
857
+ action: () => (0, exports.downloadContent)(options.charts, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive),
858
+ detail: ([total]) => `${total} downloaded`,
859
+ });
853
860
  allMetadataEntries = [...allMetadataEntries, ...sqlChartMeta];
854
861
  chartTotal = regularChartTotal + sqlChartTotal;
855
862
  }
@@ -862,52 +869,64 @@ const downloadHandler = async (options) => {
862
869
  else {
863
870
  let chartSlugs = [];
864
871
  let dashMeta;
865
- [dashboardTotal, chartSlugs, dashMeta] = await (0, exports.downloadContent)(options.dashboards, 'dashboards', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces);
872
+ [dashboardTotal, chartSlugs, dashMeta] = await output.runItem({
873
+ label: 'Dashboards',
874
+ action: () => (0, exports.downloadContent)(options.dashboards, 'dashboards', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive),
875
+ detail: ([total]) => `${total} downloaded`,
876
+ });
866
877
  allMetadataEntries = [...allMetadataEntries, ...dashMeta];
867
- spinner.succeed(`Downloaded ${dashboardTotal} dashboards`);
868
878
  if (hasFilters &&
869
879
  chartSlugs.length > 0 &&
870
880
  !options.skipCharts) {
871
- spinner.start(`Downloading ${chartSlugs.length} charts linked to dashboards`);
872
- const [regularCharts, , linkedChartMeta] = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces);
881
+ output.startItem('Linked charts');
882
+ output.updateActive(`${chartSlugs.length} dashboard dependencies`);
883
+ const [regularCharts, , linkedChartMeta] = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, options.stripPivotSeries, options.rootSpaces, output.updateActive);
873
884
  allMetadataEntries = [
874
885
  ...allMetadataEntries,
875
886
  ...linkedChartMeta,
876
887
  ];
877
- const [sqlCharts, , linkedSqlMeta] = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces);
888
+ const [sqlCharts, , linkedSqlMeta] = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipEmbeddedSpaces, false, options.rootSpaces, output.updateActive);
878
889
  allMetadataEntries = [
879
890
  ...allMetadataEntries,
880
891
  ...linkedSqlMeta,
881
892
  ];
882
- spinner.succeed(`Downloaded ${regularCharts + sqlCharts} charts linked to dashboards`);
893
+ output.completeItem(`${regularCharts + sqlCharts} downloaded`);
883
894
  }
884
895
  }
885
896
  }
886
897
  if (!options.spacesOnly && shouldDownloadAiAgents(options)) {
887
- spinner.start(`Downloading AI agents`);
888
- const agentTotal = await downloadAiAgents(projectId, options.agents, options.path);
889
- spinner.succeed(`Downloaded ${agentTotal} AI agents`);
898
+ await output.runItem({
899
+ label: 'AI agents',
900
+ action: () => downloadAiAgents(projectId, options.agents, options.path),
901
+ detail: (total) => `${total} downloaded`,
902
+ });
890
903
  }
891
904
  if (includeAllOptionalContent ||
892
905
  options.includeAlerts ||
893
906
  options.alerts.length > 0) {
894
- spinner.start(`Downloading alerts`);
895
- const alertTotal = await downloadScheduledContent(projectId, options.alerts, common_1.ContentAsCodeType.ALERT, options.path);
896
- spinner.succeed(`Downloaded ${alertTotal} alerts`);
907
+ await output.runItem({
908
+ label: 'Alerts',
909
+ action: () => downloadScheduledContent(projectId, options.alerts, common_1.ContentAsCodeType.ALERT, options.path),
910
+ detail: (total) => `${total} downloaded`,
911
+ });
897
912
  }
898
913
  if (includeAllOptionalContent ||
899
914
  options.includeScheduledDeliveries ||
900
915
  options.scheduledDeliveries.length > 0) {
901
- spinner.start(`Downloading scheduled deliveries`);
902
- const scheduledDeliveryTotal = await downloadScheduledContent(projectId, options.scheduledDeliveries, common_1.ContentAsCodeType.SCHEDULED_DELIVERY, options.path);
903
- spinner.succeed(`Downloaded ${scheduledDeliveryTotal} scheduled deliveries`);
916
+ await output.runItem({
917
+ label: 'Scheduled deliveries',
918
+ action: () => downloadScheduledContent(projectId, options.scheduledDeliveries, common_1.ContentAsCodeType.SCHEDULED_DELIVERY, options.path),
919
+ detail: (total) => `${total} downloaded`,
920
+ });
904
921
  }
905
922
  if (includeAllOptionalContent ||
906
923
  options.includeGoogleSheets ||
907
924
  options.googleSheets.length > 0) {
908
- spinner.start(`Downloading Google Sheets syncs`);
909
- const googleSheetsTotal = await downloadScheduledContent(projectId, options.googleSheets, common_1.ContentAsCodeType.GOOGLE_SHEETS_SYNC, options.path);
910
- spinner.succeed(`Downloaded ${googleSheetsTotal} Google Sheets ${googleSheetsTotal === 1 ? 'sync' : 'syncs'}`);
925
+ await output.runItem({
926
+ label: 'Google Sheets syncs',
927
+ action: () => downloadScheduledContent(projectId, options.googleSheets, common_1.ContentAsCodeType.GOOGLE_SHEETS_SYNC, options.path),
928
+ detail: (total) => `${total} downloaded`,
929
+ });
911
930
  }
912
931
  // Download data apps (enterprise, opt-in via --apps / --include-apps / --include-all)
913
932
  const appsSelection = (0, appsDownload_1.selectAppsToDownload)({
@@ -915,6 +934,7 @@ const downloadHandler = async (options) => {
915
934
  includeApps,
916
935
  });
917
936
  if (appsSelection.mode !== 'none') {
937
+ output.startItem('Data apps');
918
938
  let appUuidsToDownload;
919
939
  let appListingError = null;
920
940
  if (appsSelection.mode === 'explicit') {
@@ -922,7 +942,7 @@ const downloadHandler = async (options) => {
922
942
  }
923
943
  else {
924
944
  // List every app in the project (includes apps not in any space)
925
- spinner.start(`Listing data apps in project`);
945
+ output.updateActive('listing project apps…');
926
946
  let listedAppUuids;
927
947
  try {
928
948
  const projectApps = await (0, apiClient_1.lightdashApi)({
@@ -938,7 +958,6 @@ const downloadHandler = async (options) => {
938
958
  throw listErr;
939
959
  }
940
960
  appListingError = (0, common_1.getErrorMessage)(listErr);
941
- spinner.fail(`Error downloading data apps: ${appListingError}`);
942
961
  listedAppUuids = [];
943
962
  }
944
963
  else {
@@ -960,11 +979,14 @@ const downloadHandler = async (options) => {
960
979
  }
961
980
  if (appUuidsToDownload.length === 0) {
962
981
  if (appListingError === null) {
963
- spinner.succeed(`No data apps found in project`);
982
+ output.completeItem('0 found');
983
+ }
984
+ else {
985
+ output.completeItem(`listing failed: ${appListingError}`, 'warning');
964
986
  }
965
987
  }
966
988
  else {
967
- spinner.start(`Downloading ${appUuidsToDownload.length} data app(s)…`);
989
+ output.updateActive(`0 of ${appUuidsToDownload.length} downloaded`);
968
990
  const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(options.path);
969
991
  const appsDir = path.join(baseDir, 'apps');
970
992
  const takenFolders = new Set();
@@ -1017,13 +1039,17 @@ const downloadHandler = async (options) => {
1017
1039
  globalState_1.default.log(styles.error(`Failed to download app ${appUuid}: ${outcome.message}`));
1018
1040
  }
1019
1041
  }
1042
+ output.updateActive(`${appSuccessCount +
1043
+ appSkippedNotBuiltCount +
1044
+ appFailures.length} of ${appUuidsToDownload.length} processed`);
1020
1045
  }
1021
1046
  const summary = (0, appsDownload_1.appsDownloadSummary)(appSuccessCount, appUuidsToDownload.length, appFailures, appsDir, appSkippedNotBuiltCount);
1022
- if (summary.ok) {
1023
- spinner.succeed(summary.message);
1024
- }
1025
- else {
1026
- spinner.warn(styles.warning(summary.message));
1047
+ output.completeItem(`${appSuccessCount} downloaded${appSkippedNotBuiltCount > 0
1048
+ ? `, ${appSkippedNotBuiltCount} skipped`
1049
+ : ''}${appFailures.length > 0
1050
+ ? `, ${appFailures.length} failed`
1051
+ : ''}`, summary.ok ? undefined : 'warning');
1052
+ if (!summary.ok) {
1027
1053
  summary.failureLines.forEach((line) => globalState_1.default.log(styles.warning(line)));
1028
1054
  }
1029
1055
  }
@@ -1041,13 +1067,18 @@ const downloadHandler = async (options) => {
1041
1067
  const downloadRoot = options.nested
1042
1068
  ? path.join(baseDir, projectName)
1043
1069
  : baseDir;
1070
+ output.startItem('Metadata');
1044
1071
  await (0, metadataFile_1.writeMetadataFile)(baseDir, metadataToWrite);
1045
1072
  if (!config.answers?.metadataFileGitignoreNoticeShown) {
1046
1073
  globalState_1.default.log(styles.warning(`\nNote: ${metadataFile_1.METADATA_FILENAME} was written to ${baseDir}. Consider adding it to your .gitignore.`));
1047
1074
  await (0, config_1.setAnswer)({ metadataFileGitignoreNoticeShown: true });
1048
1075
  }
1049
- globalState_1.default.log(styles.success(`Downloaded content saved to ${downloadRoot}`));
1076
+ output.completeItem('timestamps written');
1050
1077
  const end = Date.now();
1078
+ const renderedSummary = output.complete(downloadRoot, (end - start) / 1000);
1079
+ if (!renderedSummary) {
1080
+ globalState_1.default.log(styles.success(`Downloaded content saved to ${downloadRoot}`));
1081
+ }
1051
1082
  await analytics_1.LightdashAnalytics.track({
1052
1083
  event: 'download.completed',
1053
1084
  properties: {
@@ -1061,7 +1092,7 @@ const downloadHandler = async (options) => {
1061
1092
  });
1062
1093
  }
1063
1094
  catch (error) {
1064
- console.error(styles.error(`\nError downloading ${error}`));
1095
+ output.fail((0, common_1.getErrorMessage)(error), (Date.now() - start) / 1000, true);
1065
1096
  await analytics_1.LightdashAnalytics.track({
1066
1097
  event: 'download.error',
1067
1098
  properties: {
@@ -1097,6 +1128,42 @@ const storeUploadChanges = (changes, promoteChanges) => {
1097
1128
  });
1098
1129
  return updatedChanges;
1099
1130
  };
1131
+ const UPLOAD_CHANGE_SUFFIXES = [
1132
+ 'dependency skipped',
1133
+ 'with errors',
1134
+ 'unchanged',
1135
+ 'created',
1136
+ 'updated',
1137
+ 'deleted',
1138
+ 'skipped',
1139
+ 'failed',
1140
+ ];
1141
+ const summarizeUploadChanges = (before, after) => {
1142
+ const totals = new Map();
1143
+ Object.entries(after).forEach(([key, value]) => {
1144
+ const difference = value - (before[key] ?? 0);
1145
+ if (difference <= 0)
1146
+ return;
1147
+ const suffix = UPLOAD_CHANGE_SUFFIXES.find((candidate) => key.endsWith(candidate));
1148
+ const label = suffix ?? 'processed';
1149
+ totals.set(label, (totals.get(label) ?? 0) + difference);
1150
+ });
1151
+ if (totals.size === 0)
1152
+ return { detail: 'no changes' };
1153
+ const detail = [...totals]
1154
+ .map(([label, total]) => `${total} ${label}`)
1155
+ .join(', ');
1156
+ const hasFailures = [...totals.keys()].some((label) => label === 'with errors' || label === 'failed');
1157
+ return { detail, variant: hasFailures ? 'warning' : undefined };
1158
+ };
1159
+ const runUploadChangesPhase = async ({ output, label, changes, action, }) => {
1160
+ const before = { ...changes };
1161
+ output.startItem(label);
1162
+ const updatedChanges = await action();
1163
+ const summary = summarizeUploadChanges(before, updatedChanges);
1164
+ output.completeItem(summary.detail, summary.variant);
1165
+ return updatedChanges;
1166
+ };
1100
1167
  // SQL charts have 'sql' field instead of 'tableName'/'metricQuery'
1101
1168
  const isSqlChart = (item) => 'sql' in item && !('tableName' in item);
1102
1169
  const upsertSingleItem = async (item, type, projectId, changes, force, config, skipSpaceCreate, publicSpaceCreate, validate, spaceNames) => {
@@ -1204,7 +1271,7 @@ const upsertResources = async (type, projectId, changes, force, slugs, canUpload
1204
1271
  const config = await (0, config_1.getConfig)();
1205
1272
  const folderItems = await readCodeFiles(type, customPath);
1206
1273
  const items = [...folderItems, ...extraItems];
1207
- globalState_1.default.log(`Found ${items.length} ${type} files`);
1274
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Found ${items.length} ${type} files`);
1208
1275
  const hasFilter = slugs.length > 0;
1209
1276
  const filteredItems = hasFilter
1210
1277
  ? items.filter((item) => slugs.includes(item.slug))
@@ -1365,18 +1432,33 @@ const uploadHandler = async (options) => {
1365
1432
  projectId,
1366
1433
  },
1367
1434
  });
1368
- let uploadResource = 'content as code';
1435
+ const output = (0, contentAsCodeOutput_1.createContentAsCodeOutput)({
1436
+ operation: 'upload',
1437
+ scope: 'project',
1438
+ });
1439
+ const uploadRoot = (0, contentAsCodePaths_1.getDownloadFolder)(options.path);
1440
+ const completeUpload = () => {
1441
+ const renderedSummary = output.complete(uploadRoot, (Date.now() - start) / 1000);
1442
+ if (!renderedSummary) {
1443
+ (0, spacesAsCode_1.logUploadChanges)(changes);
1444
+ globalState_1.default.log(styles.success(`Uploaded content from ${uploadRoot}`));
1445
+ }
1446
+ };
1369
1447
  try {
1370
- uploadResource = 'space definitions';
1371
1448
  const spaceFiles = preflightSpaceFiles;
1372
1449
  const spaceNames = shouldReconcileSpaces
1373
1450
  ? (0, spacesAsCode_1.getSpaceNames)(spaceFiles)
1374
1451
  : await (0, spacesAsCode_1.readSpaceNames)(options.path);
1375
1452
  if (spaceFiles.length > 0) {
1376
- globalState_1.default.log(`Found ${spaceFiles.length} space definition(s)`);
1453
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Found ${spaceFiles.length} space definition(s)`);
1377
1454
  }
1378
1455
  if (shouldReconcileSpaces) {
1379
- changes = await (0, spacesAsCode_1.upsertSpaces)(projectId, spaceFiles, changes, options.skipSpaceCreate, options.public);
1456
+ changes = await runUploadChangesPhase({
1457
+ output,
1458
+ label: 'Spaces',
1459
+ changes,
1460
+ action: () => (0, spacesAsCode_1.upsertSpaces)(projectId, spaceFiles, changes, options.skipSpaceCreate, options.public),
1461
+ });
1380
1462
  }
1381
1463
  else if (hasFilters) {
1382
1464
  globalState_1.default.debug('Skipping space access reconciliation for a filtered content upload');
@@ -1391,27 +1473,22 @@ const uploadHandler = async (options) => {
1391
1473
  timeToCompleted: (Date.now() - start) / 1000,
1392
1474
  },
1393
1475
  });
1394
- (0, spacesAsCode_1.logUploadChanges)(changes);
1476
+ completeUpload();
1395
1477
  return;
1396
1478
  }
1397
1479
  const uploadPermissions = await (0, apiClient_1.getContentAsCodeUploadPermissions)(projectId);
1398
1480
  // Discover loose YAML files (outside charts/ and dashboards/) classified by contentType
1399
- uploadResource = 'content files';
1400
- const looseFiles = await readLooseCodeFiles(options.path);
1481
+ const looseFiles = await output.runItem({
1482
+ label: 'Content files',
1483
+ action: () => readLooseCodeFiles(options.path),
1484
+ detail: ({ charts, dashboards }) => `${charts.length + dashboards.length} discovered`,
1485
+ });
1401
1486
  if (looseFiles.charts.length > 0) {
1402
- globalState_1.default.log(`Found ${looseFiles.charts.length} chart(s) outside charts/ directory (classified by contentType)`);
1487
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Found ${looseFiles.charts.length} chart(s) outside charts/ directory (classified by contentType)`);
1403
1488
  }
1404
1489
  if (looseFiles.dashboards.length > 0) {
1405
- globalState_1.default.log(`Found ${looseFiles.dashboards.length} dashboard(s) outside dashboards/ directory (classified by contentType)`);
1490
+ (0, contentAsCodeOutput_1.logContentAsCodeDiscovery)(`Found ${looseFiles.dashboards.length} dashboard(s) outside dashboards/ directory (classified by contentType)`);
1406
1491
  }
1407
- // Always include the charts from dashboards if includeCharts is true regardless of the charts filters
1408
- uploadResource = 'dashboard chart dependencies';
1409
- const chartSlugs = options.includeCharts
1410
- ? Array.from(new Set([
1411
- ...options.charts,
1412
- ...(await getDashboardChartSlugs(options.dashboards, options.path, looseFiles.dashboards)),
1413
- ]))
1414
- : options.charts;
1415
1492
  const concurrency = Math.min(Math.max(1, parseInt(String(options.concurrency), 10) || 1), 1000);
1416
1493
  if (parseInt(String(options.concurrency), 10) > 1000) {
1417
1494
  globalState_1.default.log(styles.warning(`Concurrency limit exceeded. Using maximum of 1000 instead of ${options.concurrency}`));
@@ -1421,36 +1498,60 @@ const uploadHandler = async (options) => {
1421
1498
  globalState_1.default.log(styles.warning(`No virtual view filters provided, skipping`));
1422
1499
  }
1423
1500
  else {
1424
- uploadResource = 'virtual views';
1425
- changes = await upsertVirtualViews(projectId, options.virtualViews, changes, options.force, uploadPermissions.virtualViews, options.path);
1501
+ changes = await runUploadChangesPhase({
1502
+ output,
1503
+ label: 'Virtual views',
1504
+ changes,
1505
+ action: () => upsertVirtualViews(projectId, options.virtualViews, changes, options.force, uploadPermissions.virtualViews, options.path),
1506
+ });
1426
1507
  }
1427
1508
  }
1428
- if (hasFilters && chartSlugs.length === 0) {
1429
- globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
1430
- }
1431
- else {
1432
- uploadResource = 'charts';
1433
- const { changes: chartChanges, total } = await upsertResources('charts', projectId, changes, options.force, chartSlugs, uploadPermissions.charts, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.charts, spaceNames);
1434
- changes = chartChanges;
1435
- chartTotal = total;
1436
- }
1437
- if (hasFilters && options.dashboards.length === 0) {
1438
- globalState_1.default.log(styles.warning(`No dashboard filters provided, skipping`));
1439
- }
1440
- else {
1441
- uploadResource = 'dashboards';
1442
- const { changes: dashboardChanges, total } = await upsertResources('dashboards', projectId, changes, options.force, options.dashboards, uploadPermissions.dashboards, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.dashboards, spaceNames);
1443
- changes = dashboardChanges;
1444
- dashboardTotal = total;
1445
- }
1509
+ changes = await runUploadChangesPhase({
1510
+ output,
1511
+ label: 'Charts',
1512
+ changes,
1513
+ action: async () => {
1514
+ const chartSlugs = options.includeCharts
1515
+ ? Array.from(new Set([
1516
+ ...options.charts,
1517
+ ...(await getDashboardChartSlugs(options.dashboards, options.path, looseFiles.dashboards)),
1518
+ ]))
1519
+ : options.charts;
1520
+ if (hasFilters && chartSlugs.length === 0) {
1521
+ globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
1522
+ return changes;
1523
+ }
1524
+ const result = await upsertResources('charts', projectId, changes, options.force, chartSlugs, uploadPermissions.charts, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.charts, spaceNames);
1525
+ chartTotal = result.total;
1526
+ return result.changes;
1527
+ },
1528
+ });
1529
+ changes = await runUploadChangesPhase({
1530
+ output,
1531
+ label: 'Dashboards',
1532
+ changes,
1533
+ action: async () => {
1534
+ if (hasFilters && options.dashboards.length === 0) {
1535
+ globalState_1.default.log(styles.warning(`No dashboard filters provided, skipping`));
1536
+ return changes;
1537
+ }
1538
+ 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);
1539
+ dashboardTotal = result.total;
1540
+ return result.changes;
1541
+ },
1542
+ });
1446
1543
  if (!options.skipAgents) {
1447
1544
  if (hasFilters && options.agents.length === 0) {
1448
1545
  globalState_1.default.log(styles.warning(`No AI agent filters provided, skipping`));
1449
1546
  }
1450
1547
  else {
1451
- uploadResource = 'AI agents';
1452
1548
  try {
1453
- changes = await upsertAiAgents(projectId, options.agents, changes, options.force, options.path);
1549
+ changes = await runUploadChangesPhase({
1550
+ output,
1551
+ label: 'AI agents',
1552
+ changes,
1553
+ action: () => upsertAiAgents(projectId, options.agents, changes, options.force, options.path),
1554
+ });
1454
1555
  }
1455
1556
  catch (error) {
1456
1557
  throw new AiAgentAsCodeUploadError(error);
@@ -1462,8 +1563,12 @@ const uploadHandler = async (options) => {
1462
1563
  globalState_1.default.log(styles.warning(`No alert filters provided, skipping`));
1463
1564
  }
1464
1565
  else {
1465
- uploadResource = 'alerts';
1466
- changes = await upsertScheduledContent(projectId, options.alerts, changes, options.force, common_1.ContentAsCodeType.ALERT, uploadPermissions.alerts, options.path);
1566
+ changes = await runUploadChangesPhase({
1567
+ output,
1568
+ label: 'Alerts',
1569
+ changes,
1570
+ action: () => upsertScheduledContent(projectId, options.alerts, changes, options.force, common_1.ContentAsCodeType.ALERT, uploadPermissions.alerts, options.path),
1571
+ });
1467
1572
  }
1468
1573
  }
1469
1574
  if (!options.skipScheduledDeliveries) {
@@ -1471,8 +1576,12 @@ const uploadHandler = async (options) => {
1471
1576
  globalState_1.default.log(styles.warning(`No scheduled delivery filters provided, skipping`));
1472
1577
  }
1473
1578
  else {
1474
- uploadResource = 'scheduled deliveries';
1475
- changes = await upsertScheduledContent(projectId, options.scheduledDeliveries, changes, options.force, common_1.ContentAsCodeType.SCHEDULED_DELIVERY, uploadPermissions.scheduledDeliveries, options.path);
1579
+ changes = await runUploadChangesPhase({
1580
+ output,
1581
+ label: 'Scheduled deliveries',
1582
+ changes,
1583
+ action: () => upsertScheduledContent(projectId, options.scheduledDeliveries, changes, options.force, common_1.ContentAsCodeType.SCHEDULED_DELIVERY, uploadPermissions.scheduledDeliveries, options.path),
1584
+ });
1476
1585
  }
1477
1586
  }
1478
1587
  if (!options.skipGoogleSheets) {
@@ -1480,8 +1589,12 @@ const uploadHandler = async (options) => {
1480
1589
  globalState_1.default.log(styles.warning(`No Google Sheets sync filters provided, skipping`));
1481
1590
  }
1482
1591
  else {
1483
- uploadResource = 'Google Sheets syncs';
1484
- changes = await upsertScheduledContent(projectId, options.googleSheets, changes, options.force, common_1.ContentAsCodeType.GOOGLE_SHEETS_SYNC, uploadPermissions.googleSheets, options.path);
1592
+ changes = await runUploadChangesPhase({
1593
+ output,
1594
+ label: 'Google Sheets syncs',
1595
+ changes,
1596
+ action: () => upsertScheduledContent(projectId, options.googleSheets, changes, options.force, common_1.ContentAsCodeType.GOOGLE_SHEETS_SYNC, uploadPermissions.googleSheets, options.path),
1597
+ });
1485
1598
  }
1486
1599
  }
1487
1600
  // Upload data apps (enterprise, opt-in via --apps <uuids...> or
@@ -1494,11 +1607,14 @@ const uploadHandler = async (options) => {
1494
1607
  let appsUpdated = 0;
1495
1608
  let appsFailed = 0;
1496
1609
  let appsSkipped = 0;
1610
+ const changesBeforeApps = { ...changes };
1497
1611
  if (shouldUploadApps && !uploadPermissions.dataApps) {
1612
+ output.startItem('Data apps');
1498
1613
  globalState_1.default.log(styles.error(`Error uploading data apps: create:DataApp or manage:DataApp permission is required`));
1614
+ output.completeItem('permission denied', 'warning');
1499
1615
  }
1500
1616
  else if (shouldUploadApps) {
1501
- uploadResource = 'data apps';
1617
+ output.startItem('Data apps');
1502
1618
  // --include-apps uploads every folder on disk; explicit UUIDs
1503
1619
  // filter folders by their manifest appUuid
1504
1620
  const filterUuids = options.includeApps === true ? null : new Set(explicitAppUuids);
@@ -1698,6 +1814,8 @@ const uploadHandler = async (options) => {
1698
1814
  changes['data apps failed'] = appsFailed;
1699
1815
  if (appsSkipped > 0)
1700
1816
  changes['data apps skipped'] = appsSkipped;
1817
+ const appSummary = summarizeUploadChanges(changesBeforeApps, changes);
1818
+ output.completeItem(appSummary.detail, appSummary.variant);
1701
1819
  }
1702
1820
  const end = Date.now();
1703
1821
  await analytics_1.LightdashAnalytics.track({
@@ -1711,12 +1829,12 @@ const uploadHandler = async (options) => {
1711
1829
  timeToCompleted: (end - start) / 1000, // in seconds
1712
1830
  },
1713
1831
  });
1714
- (0, spacesAsCode_1.logUploadChanges)(changes);
1832
+ completeUpload();
1715
1833
  }
1716
1834
  catch (error) {
1717
- globalState_1.default.log(styles.error(`\nError uploading ${uploadResource}: ${(0, common_1.getErrorMessage)(error)}`));
1835
+ output.fail((0, common_1.getErrorMessage)(error), (Date.now() - start) / 1000, true);
1718
1836
  await analytics_1.LightdashAnalytics.track({
1719
- event: 'download.error',
1837
+ event: 'upload.error',
1720
1838
  properties: {
1721
1839
  userId: config.user?.userUuid,
1722
1840
  organizationId: config.user?.organizationUuid,
@@ -1743,6 +1861,7 @@ exports.testHelpers = {
1743
1861
  shouldFallBackToEmbeddedSpaces: spacesAsCode_1.shouldFallBackToEmbeddedSpaces,
1744
1862
  shouldDownloadAiAgents,
1745
1863
  sortSpaceFilesParentFirst: spacesAsCode_1.sortSpaceFilesParentFirst,
1864
+ summarizeUploadChanges,
1746
1865
  upsertSpaces: spacesAsCode_1.upsertSpaces,
1747
1866
  upsertVirtualViews,
1748
1867
  validateSpaceIdentity: spacesAsCode_1.validateSpaceIdentity,