@lightdash/cli 0.3408.1 → 0.3409.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAyB3C,KAAK,0BAA0B,GAAG;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAkBF,eAAO,MAAM,4BAA4B,GAAI,aAAa,MAAM,KAAG,MAClC,CAAC;AAElC,eAAO,MAAM,2BAA2B,GAAU,yBAG/C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CAqF3C,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAU,sCAI7C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CAkI3C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AA0B3C,KAAK,0BAA0B,GAAG;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AA6BF,eAAO,MAAM,4BAA4B,GAAI,aAAa,MAAM,KAAG,MAClC,CAAC;AAElC,eAAO,MAAM,2BAA2B,GAAU,yBAG/C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CA0H3C,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAU,sCAI7C,0BAA0B,KAAG,OAAO,CAAC,IAAI,CAgK3C,CAAC"}
@@ -6,6 +6,7 @@ const common_1 = require("@lightdash/common");
6
6
  const analytics_1 = require("../../analytics/analytics");
7
7
  const globalState_1 = tslib_1.__importDefault(require("../../globalState"));
8
8
  const styles = tslib_1.__importStar(require("../../styles"));
9
+ const contentAsCodeOutput_1 = require("../../terminal/contentAsCodeOutput");
9
10
  const registry_1 = require("../contentAsCode/registry");
10
11
  const resource_1 = require("../contentAsCode/resource");
11
12
  const contentAsCodePaths_1 = require("../contentAsCodePaths");
@@ -19,6 +20,12 @@ const isPartialUploadError = resource_1.isCodeResourcePhaseError;
19
20
  const isGroupServiceDisabledError = (error) => error instanceof common_1.LightdashError &&
20
21
  error.statusCode === 403 &&
21
22
  error.message === 'Group service is not enabled';
23
+ const getElapsedMilliseconds = (startedAt) => Date.now() - startedAt;
24
+ const measureAction = async (action) => {
25
+ const startedAt = Date.now();
26
+ const value = await action();
27
+ return { value, durationMs: getElapsedMilliseconds(startedAt) };
28
+ };
22
29
  const getOrganizationContentFolder = (customPath) => (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
23
30
  exports.getOrganizationContentFolder = getOrganizationContentFolder;
24
31
  const downloadOrganizationContent = async ({ customPath, config, }) => {
@@ -36,27 +43,53 @@ const downloadOrganizationContent = async ({ customPath, config, }) => {
36
43
  scope: 'organization',
37
44
  },
38
45
  });
39
- const spinner = globalState_1.default.startSpinner(`Downloading custom roles`);
46
+ const output = (0, contentAsCodeOutput_1.createContentAsCodeOutput)({
47
+ operation: 'download',
48
+ scope: 'organization',
49
+ initialLabel: 'Custom roles',
50
+ });
40
51
  try {
41
- const customRolesTotal = await (0, customRoles_1.downloadCustomRoles)(organizationUuid, organizationContentPath);
42
- spinner.succeed(`Downloaded ${customRolesTotal} custom roles`);
43
- spinner.start(`Downloading users`);
44
- const usersTotal = await (0, users_1.downloadUsers)(organizationUuid, organizationContentPath);
45
- spinner.succeed(`Downloaded ${usersTotal} users`);
46
- spinner.start(`Downloading groups`);
52
+ const { value: customRolesTotal, durationMs: customRolesDurationMs } = await measureAction(() => (0, customRoles_1.downloadCustomRoles)(organizationUuid, organizationContentPath));
53
+ output.completeItem({
54
+ label: 'Custom roles',
55
+ detail: `${customRolesTotal} downloaded`,
56
+ durationMs: customRolesDurationMs,
57
+ }, 'Users');
58
+ const { value: usersTotal, durationMs: usersDurationMs } = await measureAction(() => (0, users_1.downloadUsers)(organizationUuid, organizationContentPath));
59
+ output.completeItem({
60
+ label: 'Users',
61
+ detail: `${usersTotal} downloaded`,
62
+ durationMs: usersDurationMs,
63
+ }, 'Groups');
64
+ const groupsStartedAt = Date.now();
47
65
  let groupsTotal = 0;
66
+ let groupsDurationMs;
48
67
  try {
49
68
  groupsTotal = await (0, groups_1.downloadGroups)(organizationUuid, organizationContentPath);
50
- spinner.succeed(`Downloaded ${groupsTotal} groups`);
69
+ groupsDurationMs = getElapsedMilliseconds(groupsStartedAt);
70
+ output.completeItem({
71
+ label: 'Groups',
72
+ detail: `${groupsTotal} downloaded`,
73
+ durationMs: groupsDurationMs,
74
+ }, null);
51
75
  }
52
76
  catch (error) {
77
+ groupsDurationMs = getElapsedMilliseconds(groupsStartedAt);
53
78
  if (!isGroupServiceDisabledError(error)) {
54
79
  throw error;
55
80
  }
56
- spinner.stop();
81
+ output.completeItem({
82
+ label: 'Groups',
83
+ detail: 'service unavailable',
84
+ durationMs: groupsDurationMs,
85
+ variant: 'warning',
86
+ }, null);
57
87
  globalState_1.default.debug('> Warning: groups were not downloaded because the group service is not enabled');
58
88
  }
59
- globalState_1.default.log(styles.success(`Downloaded organization content saved to ${organizationContentPath}`));
89
+ const renderedSummary = output.complete(organizationContentPath, (Date.now() - start) / 1000);
90
+ if (!renderedSummary) {
91
+ globalState_1.default.log(styles.success(`Downloaded organization content saved to ${organizationContentPath}`));
92
+ }
60
93
  await analytics_1.LightdashAnalytics.track({
61
94
  event: 'download.completed',
62
95
  properties: {
@@ -71,7 +104,7 @@ const downloadOrganizationContent = async ({ customPath, config, }) => {
71
104
  });
72
105
  }
73
106
  catch (error) {
74
- spinner.fail(`Failed to download organization content: ${(0, common_1.getErrorMessage)(error)}`);
107
+ output.fail((0, common_1.getErrorMessage)(error), (Date.now() - start) / 1000, true);
75
108
  await analytics_1.LightdashAnalytics.track({
76
109
  event: 'download.error',
77
110
  properties: {
@@ -101,39 +134,56 @@ const uploadOrganizationContent = async ({ customPath, config, sendInvites = fal
101
134
  scope: 'organization',
102
135
  },
103
136
  });
104
- const spinner = globalState_1.default.startSpinner(`Uploading custom roles`);
137
+ const output = (0, contentAsCodeOutput_1.createContentAsCodeOutput)({
138
+ operation: 'upload',
139
+ scope: 'organization',
140
+ initialLabel: 'Custom roles',
141
+ });
105
142
  try {
106
- const summary = await (0, customRoles_1.uploadCustomRoles)(organizationUuid, organizationContentPath);
143
+ const { value: summary, durationMs: customRolesDurationMs } = await measureAction(() => (0, customRoles_1.uploadCustomRoles)(organizationUuid, organizationContentPath));
107
144
  const summaryMessage = (0, customRoles_1.formatCustomRoleUploadSummary)(summary);
108
145
  if (summary.failed > 0) {
109
146
  summary.failures.forEach(({ message }) => globalState_1.default.log(styles.error(message)));
110
- spinner.stop();
147
+ output.prepareForFailureDetails();
111
148
  const skippedGroups = await (0, groups_1.countDependencySkippedGroups)(organizationContentPath);
112
149
  globalState_1.default.log(styles.warning(`Skipped users and ${skippedGroups} groups because custom roles failed`));
113
150
  throw createCustomRolePartialUploadError(`Processed custom roles: ${summaryMessage}`);
114
151
  }
115
- spinner.succeed(`Uploaded custom roles: ${summaryMessage}`);
116
- spinner.start(`Uploading users`);
117
- const userSummary = await (0, users_1.uploadUsers)(organizationUuid, organizationContentPath, sendInvites);
152
+ output.completeItem({
153
+ label: 'Custom roles',
154
+ detail: summaryMessage,
155
+ durationMs: customRolesDurationMs,
156
+ }, 'Users');
157
+ const { value: userSummary, durationMs: usersDurationMs } = await measureAction(() => (0, users_1.uploadUsers)(organizationUuid, organizationContentPath, sendInvites));
118
158
  const userSummaryMessage = (0, users_1.formatUserUploadSummary)(userSummary);
119
159
  if (userSummary.failed > 0) {
120
160
  userSummary.failures.forEach(({ message }) => globalState_1.default.log(styles.error(message)));
121
- spinner.stop();
161
+ output.prepareForFailureDetails();
122
162
  const skippedGroups = await (0, groups_1.countDependencySkippedGroups)(organizationContentPath);
123
163
  globalState_1.default.log(styles.warning(`Skipped ${skippedGroups} groups because users failed`));
124
164
  throw createUserPartialUploadError(`Processed users: ${userSummaryMessage}`);
125
165
  }
126
- spinner.succeed(`Uploaded users: ${userSummaryMessage}`);
127
- spinner.start(`Uploading groups`);
128
- const groupSummary = await (0, groups_1.uploadGroups)(organizationUuid, organizationContentPath);
166
+ output.completeItem({
167
+ label: 'Users',
168
+ detail: userSummaryMessage,
169
+ durationMs: usersDurationMs,
170
+ }, 'Groups');
171
+ const { value: groupSummary, durationMs: groupsDurationMs } = await measureAction(() => (0, groups_1.uploadGroups)(organizationUuid, organizationContentPath));
129
172
  const groupSummaryMessage = (0, groups_1.formatGroupUploadSummary)(groupSummary);
130
173
  if (groupSummary.failed > 0) {
131
174
  groupSummary.failures.forEach(({ message }) => globalState_1.default.log(styles.error(message)));
132
- spinner.stop();
175
+ output.prepareForFailureDetails();
133
176
  throw createGroupPartialUploadError(`Processed groups: ${groupSummaryMessage}`);
134
177
  }
135
- spinner.succeed(`Uploaded groups: ${groupSummaryMessage}`);
136
- globalState_1.default.log(styles.success(`Uploaded organization content from ${organizationContentPath}`));
178
+ output.completeItem({
179
+ label: 'Groups',
180
+ detail: groupSummaryMessage,
181
+ durationMs: groupsDurationMs,
182
+ }, null);
183
+ const renderedSummary = output.complete(organizationContentPath, (Date.now() - start) / 1000);
184
+ if (!renderedSummary) {
185
+ globalState_1.default.log(styles.success(`Uploaded organization content from ${organizationContentPath}`));
186
+ }
137
187
  await analytics_1.LightdashAnalytics.track({
138
188
  event: 'upload.completed',
139
189
  properties: {
@@ -156,9 +206,7 @@ const uploadOrganizationContent = async ({ customPath, config, sendInvites = fal
156
206
  });
157
207
  }
158
208
  catch (error) {
159
- if (!isPartialUploadError(error)) {
160
- spinner.fail(`Failed to upload organization content: ${(0, common_1.getErrorMessage)(error)}`);
161
- }
209
+ output.fail((0, common_1.getErrorMessage)(error), (Date.now() - start) / 1000, !isPartialUploadError(error));
162
210
  await analytics_1.LightdashAnalytics.track({
163
211
  event: 'upload.error',
164
212
  properties: {
@@ -59,6 +59,11 @@ describe('organization content download', () => {
59
59
  expect(spinner.fail).not.toHaveBeenCalled();
60
60
  expect(globalState_1.default.debug).toHaveBeenCalledWith('> Warning: groups were not downloaded because the group service is not enabled');
61
61
  });
62
+ it('reports a duration for every downloaded resource', async () => {
63
+ await (0, index_1.downloadOrganizationContent)({ config });
64
+ expect(spinner.succeed).toHaveBeenCalledTimes(3);
65
+ spinner.succeed.mock.calls.forEach(([message]) => expect(message).toMatch(/\(\d+ms\)$/));
66
+ });
62
67
  it('still fails for other group download errors', async () => {
63
68
  vi.mocked(groups_1.downloadGroups).mockRejectedValue(new Error('Network error'));
64
69
  await expect((0, index_1.downloadOrganizationContent)({ config })).rejects.toThrow('Network error');
@@ -139,6 +144,11 @@ describe('organization content upload sequencing', () => {
139
144
  'groups:end',
140
145
  ]);
141
146
  });
147
+ it('reports a duration for every uploaded resource', async () => {
148
+ await (0, index_1.uploadOrganizationContent)({ config });
149
+ expect(spinner.succeed).toHaveBeenCalledTimes(3);
150
+ spinner.succeed.mock.calls.forEach(([message]) => expect(message).toMatch(/\(\d+ms\)$/));
151
+ });
142
152
  it('does not start users or groups when custom roles fail', async () => {
143
153
  vi.mocked(customRoles_1.uploadCustomRoles).mockResolvedValue(customRoleSummary(1));
144
154
  await expect((0, index_1.uploadOrganizationContent)({ config })).rejects.toThrow('Processed custom roles');
@@ -1 +1 @@
1
- {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/users.ts"],"names":[],"mappings":"AACA,OAAO,EAUH,KAAK,UAAU,EAClB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAIH,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC9B,MAAM,2BAA2B,CAAC;AAGnC,MAAM,MAAM,iBAAiB,GAAG,eAAe,CAAC;AAEhD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CACjC,CAAC;AAEF,eAAO,MAAM,kBAAkB,EAAE,sBAAsB,CAAC,UAAU,CAkBjE,CAAC;AAIF,eAAO,MAAM,cAAc,GAAI,yBAAyB,MAAM,KAAG,MACI,CAAC;AAEtE,eAAO,MAAM,uBAAuB,GAAI,SAAS,iBAAiB,KAAG,MAiBpE,CAAC;AAEF,eAAO,MAAM,aAAa,GACtB,yBAAyB,MAAM,KAChC,OAAO,CAAC,OAAO,EAAE,CAOnB,CAAC;AAkCF,eAAO,MAAM,WAAW,GACpB,kBAAkB,MAAM,EACxB,yBAAyB,MAAM,EAC/B,cAAa,OAAe,KAC7B,OAAO,CAAC,iBAAiB,CAgG3B,CAAC;AAEF,eAAO,MAAM,aAAa,GACtB,kBAAkB,MAAM,EACxB,yBAAyB,MAAM,KAChC,OAAO,CAAC,MAAM,CAcX,CAAC"}
1
+ {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../../src/handlers/organizationContent/users.ts"],"names":[],"mappings":"AACA,OAAO,EAUH,KAAK,UAAU,EAClB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAIH,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC9B,MAAM,2BAA2B,CAAC;AAGnC,MAAM,MAAM,iBAAiB,GAAG,eAAe,CAAC;AAEhD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CACjC,CAAC;AAEF,eAAO,MAAM,kBAAkB,EAAE,sBAAsB,CAAC,UAAU,CAkBjE,CAAC;AAIF,eAAO,MAAM,cAAc,GAAI,yBAAyB,MAAM,KAAG,MACI,CAAC;AAEtE,eAAO,MAAM,uBAAuB,GAAI,SAAS,iBAAiB,KAAG,MAMrD,CAAC;AAEjB,eAAO,MAAM,aAAa,GACtB,yBAAyB,MAAM,KAChC,OAAO,CAAC,OAAO,EAAE,CAOnB,CAAC;AAkCF,eAAO,MAAM,WAAW,GACpB,kBAAkB,MAAM,EACxB,yBAAyB,MAAM,EAC/B,cAAa,OAAe,KAC7B,OAAO,CAAC,iBAAiB,CAgG3B,CAAC;AAEF,eAAO,MAAM,aAAa,GACtB,kBAAkB,MAAM,EACxB,yBAAyB,MAAM,KAChC,OAAO,CAAC,MAAM,CAcX,CAAC"}
@@ -28,22 +28,12 @@ exports.USER_CODE_RESOURCE = {
28
28
  };
29
29
  const getUsersFolder = (organizationContentPath) => path.join(organizationContentPath, exports.USER_CODE_RESOURCE.folderName);
30
30
  exports.getUsersFolder = getUsersFolder;
31
- const formatUserUploadSummary = (summary) => {
32
- const invitationSummary = [
33
- `${summary.invited} invited`,
34
- `${summary.skippedAuthenticated +
35
- summary.skippedDisabled +
36
- summary.skippedValidInvite} invitation-skipped`,
37
- ];
38
- return [
39
- `${summary.created} created`,
40
- `${summary.updated} updated`,
41
- `${summary.unchanged} unchanged`,
42
- `${summary.awaitingAuthentication} awaiting authentication`,
43
- invitationSummary.join(', '),
44
- `${summary.failed} failed`,
45
- ].join(', ');
46
- };
31
+ const formatUserUploadSummary = (summary) => [
32
+ `${summary.created} created`,
33
+ `${summary.updated} updated`,
34
+ `${summary.unchanged} unchanged`,
35
+ `${summary.failed} failed`,
36
+ ].join(', ');
47
37
  exports.formatUserUploadSummary = formatUserUploadSummary;
48
38
  const readUserFiles = async (organizationContentPath) => {
49
39
  const result = await (0, resource_1.readCodeResourceFiles)({
@@ -35,6 +35,20 @@ describe('users as code', () => {
35
35
  vitest_1.vi.restoreAllMocks();
36
36
  await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
37
37
  });
38
+ it('formats upload results using CRUD statuses only', () => {
39
+ expect((0, users_1.formatUserUploadSummary)({
40
+ created: 1,
41
+ updated: 2,
42
+ unchanged: 3,
43
+ awaitingAuthentication: 4,
44
+ invited: 5,
45
+ skippedAuthenticated: 6,
46
+ skippedDisabled: 7,
47
+ skippedValidInvite: 8,
48
+ failed: 9,
49
+ failures: [],
50
+ })).toBe('1 created, 2 updated, 3 unchanged, 9 failed');
51
+ });
38
52
  it('downloads deterministic portable users and preserves unknown YAML', async () => {
39
53
  const folder = (0, users_1.getUsersFolder)(tmpDir);
40
54
  await fs_1.promises.mkdir(folder, { recursive: true });
@@ -0,0 +1,38 @@
1
+ export type ContentAsCodeOutputVariant = 'success' | 'warning';
2
+ export type ContentAsCodeOutputItem = {
3
+ label: string;
4
+ detail: string;
5
+ durationMs: number;
6
+ variant?: ContentAsCodeOutputVariant;
7
+ };
8
+ export type ContentAsCodeOutputProps = {
9
+ operation: 'download' | 'upload';
10
+ scope: 'organization' | 'project';
11
+ path: string;
12
+ elapsedSeconds: number;
13
+ items: ContentAsCodeOutputItem[];
14
+ };
15
+ type ContentAsCodeProgressProps = Pick<ContentAsCodeOutputProps, 'operation' | 'scope' | 'items'> & {
16
+ activeLabel: string;
17
+ };
18
+ type ContentAsCodeFailureProps = Pick<ContentAsCodeOutputProps, 'operation' | 'scope' | 'elapsedSeconds' | 'items'> & {
19
+ failedItem: Omit<ContentAsCodeOutputItem, 'variant'>;
20
+ };
21
+ export declare const formatDurationTag: (durationMs: number) => string;
22
+ export declare const formatContentAsCodeAction: ({ label, detail, durationMs, }: Pick<ContentAsCodeOutputItem, "label" | "detail" | "durationMs">) => string;
23
+ export declare const formatContentAsCodeProgress: ({ operation, scope, items, activeLabel, }: ContentAsCodeProgressProps) => string;
24
+ export declare const formatContentAsCodeFailure: ({ operation, scope, elapsedSeconds, items, failedItem, }: ContentAsCodeFailureProps) => string;
25
+ export declare const formatContentAsCodeComplete: ({ operation, scope, path, elapsedSeconds, items, }: ContentAsCodeOutputProps) => string;
26
+ export declare const canRenderContentAsCodeTree: () => boolean;
27
+ export declare const renderContentAsCodeComplete: (props: ContentAsCodeOutputProps) => boolean;
28
+ type CreateContentAsCodeOutputProps = Pick<ContentAsCodeOutputProps, 'operation' | 'scope'> & {
29
+ initialLabel: string;
30
+ };
31
+ export declare const createContentAsCodeOutput: ({ operation, scope, initialLabel, }: CreateContentAsCodeOutputProps) => {
32
+ completeItem: (item: ContentAsCodeOutputItem, nextLabel: string | null) => void;
33
+ prepareForFailureDetails: () => void;
34
+ complete: (path: string, elapsedSeconds: number) => boolean;
35
+ fail: (detail: string, elapsedSeconds: number, showFallback: boolean) => void;
36
+ };
37
+ export {};
38
+ //# sourceMappingURL=contentAsCodeOutput.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contentAsCodeOutput.d.ts","sourceRoot":"","sources":["../../src/terminal/contentAsCodeOutput.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,0BAA0B,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,MAAM,uBAAuB,GAAG;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,0BAA0B,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACnC,SAAS,EAAE,UAAU,GAAG,QAAQ,CAAC;IACjC,KAAK,EAAE,cAAc,GAAG,SAAS,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,uBAAuB,EAAE,CAAC;CACpC,CAAC;AAEF,KAAK,0BAA0B,GAAG,IAAI,CAClC,wBAAwB,EACxB,WAAW,GAAG,OAAO,GAAG,OAAO,CAClC,GAAG;IACA,WAAW,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,KAAK,yBAAyB,GAAG,IAAI,CACjC,wBAAwB,EACxB,WAAW,GAAG,OAAO,GAAG,gBAAgB,GAAG,OAAO,CACrD,GAAG;IACA,UAAU,EAAE,IAAI,CAAC,uBAAuB,EAAE,SAAS,CAAC,CAAC;CACxD,CAAC;AAkBF,eAAO,MAAM,iBAAiB,GAAI,YAAY,MAAM,WACR,CAAC;AAE7C,eAAO,MAAM,yBAAyB,GAAI,gCAIvC,IAAI,CAAC,uBAAuB,EAAE,OAAO,GAAG,QAAQ,GAAG,YAAY,CAAC,WAG3B,CAAC;AAczC,eAAO,MAAM,2BAA2B,GAAI,2CAKzC,0BAA0B,WAU5B,CAAC;AAEF,eAAO,MAAM,0BAA0B,GAAI,0DAMxC,yBAAyB,WAY3B,CAAC;AAEF,eAAO,MAAM,2BAA2B,GAAI,oDAMzC,wBAAwB,WAW1B,CAAC;AAEF,eAAO,MAAM,0BAA0B,QAAO,OAOzC,CAAC;AAEN,eAAO,MAAM,2BAA2B,GACpC,OAAO,wBAAwB,KAChC,OAOF,CAAC;AAEF,KAAK,8BAA8B,GAAG,IAAI,CACtC,wBAAwB,EACxB,WAAW,GAAG,OAAO,CACxB,GAAG;IACA,YAAY,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAI,qCAIvC,8BAA8B;yBAkCf,uBAAuB,aAClB,MAAM,GAAG,IAAI;;qBAmBX,MAAM,kBAAkB,MAAM,KAAG,OAAO;mBAc7C,MAAM,kBACE,MAAM,gBACR,OAAO;CAuBhC,CAAC"}
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createContentAsCodeOutput = exports.renderContentAsCodeComplete = exports.canRenderContentAsCodeTree = exports.formatContentAsCodeComplete = exports.formatContentAsCodeFailure = exports.formatContentAsCodeProgress = exports.formatContentAsCodeAction = exports.formatDurationTag = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const chalk_1 = tslib_1.__importDefault(require("chalk"));
6
+ const globalState_1 = tslib_1.__importDefault(require("../globalState"));
7
+ const ACTION_LABEL_WIDTH = 14;
8
+ const TREE_CONNECTOR = chalk_1.default.gray('└');
9
+ const getDurationLabel = (durationMs) => `(${Math.round(durationMs)}ms)`;
10
+ const getOperationLabel = (operation, tense) => {
11
+ if (operation === 'download') {
12
+ return tense === 'running' ? 'Downloading' : 'Downloaded';
13
+ }
14
+ return tense === 'running' ? 'Uploading' : 'Uploaded';
15
+ };
16
+ const formatDurationTag = (durationMs) => chalk_1.default.gray(getDurationLabel(durationMs));
17
+ exports.formatDurationTag = formatDurationTag;
18
+ const formatContentAsCodeAction = ({ label, detail, durationMs, }) => `${chalk_1.default.bold(label.padEnd(ACTION_LABEL_WIDTH))} ${chalk_1.default.gray(detail)} ${(0, exports.formatDurationTag)(durationMs)}`;
19
+ exports.formatContentAsCodeAction = formatContentAsCodeAction;
20
+ const formatTreeItem = ({ label, detail, durationMs, variant = 'success', }) => {
21
+ const status = variant === 'success' ? chalk_1.default.green('✓') : chalk_1.default.yellow('⚠');
22
+ return ` ${TREE_CONNECTOR} ${status} ${chalk_1.default.bold(label)} ${chalk_1.default.gray(`· ${detail}`)} ${(0, exports.formatDurationTag)(durationMs)}`;
23
+ };
24
+ const formatContentAsCodeProgress = ({ operation, scope, items, activeLabel, }) => {
25
+ const header = `${chalk_1.default.bold(getOperationLabel(operation, 'running'))} content as code ${chalk_1.default.gray(`(${scope})`)}`;
26
+ const completedItems = items.map(formatTreeItem);
27
+ const activeItem = ` ${TREE_CONNECTOR} ${chalk_1.default.yellow('◐')} ${chalk_1.default.bold(activeLabel)} ${chalk_1.default.gray(`· ${getOperationLabel(operation, 'running').toLowerCase()}…`)}`;
28
+ return [header, ...completedItems, activeItem].join('\n');
29
+ };
30
+ exports.formatContentAsCodeProgress = formatContentAsCodeProgress;
31
+ const formatContentAsCodeFailure = ({ operation, scope, elapsedSeconds, items, failedItem, }) => {
32
+ const operationLabel = operation === 'download' ? 'download' : 'upload';
33
+ const header = `${chalk_1.default.bold(`Failed to ${operationLabel}`)} content as code ${chalk_1.default.gray(`(${scope}) · ${elapsedSeconds.toFixed(1)}s`)}`;
34
+ const failed = ` ${TREE_CONNECTOR} ${chalk_1.default.red('×')} ${chalk_1.default.bold(failedItem.label)} ${chalk_1.default.red(`· ${failedItem.detail}`)} ${chalk_1.default.red(getDurationLabel(failedItem.durationMs))}`;
35
+ return [header, ...items.map(formatTreeItem), failed].join('\n');
36
+ };
37
+ exports.formatContentAsCodeFailure = formatContentAsCodeFailure;
38
+ const formatContentAsCodeComplete = ({ operation, scope, path, elapsedSeconds, items, }) => {
39
+ const operationLabel = getOperationLabel(operation, 'complete');
40
+ const pathLabel = operation === 'download' ? 'Saved to' : 'Read from';
41
+ const header = `${chalk_1.default.green('✓')} ${chalk_1.default.bold(operationLabel)} content as code ${chalk_1.default.gray(`(${scope}) · ${elapsedSeconds.toFixed(1)}s`)}`;
42
+ const pathItem = ` ${TREE_CONNECTOR} ${chalk_1.default.gray(pathLabel)} ${chalk_1.default.bold(path)}`;
43
+ return [header, ...items.map(formatTreeItem), pathItem].join('\n');
44
+ };
45
+ exports.formatContentAsCodeComplete = formatContentAsCodeComplete;
46
+ const canRenderContentAsCodeTree = () => Boolean(process.stderr.isTTY &&
47
+ process.env.CI !== 'true' &&
48
+ process.env.TERM !== 'dumb' &&
49
+ process.env.NO_UNICODE !== '1' &&
50
+ process.env.NO_UNICODE !== 'true');
51
+ exports.canRenderContentAsCodeTree = canRenderContentAsCodeTree;
52
+ const renderContentAsCodeComplete = (props) => {
53
+ if (!(0, exports.canRenderContentAsCodeTree)()) {
54
+ return false;
55
+ }
56
+ process.stderr.write(`${(0, exports.formatContentAsCodeComplete)(props)}\n`);
57
+ return true;
58
+ };
59
+ exports.renderContentAsCodeComplete = renderContentAsCodeComplete;
60
+ const createContentAsCodeOutput = ({ operation, scope, initialLabel, }) => {
61
+ const useTree = (0, exports.canRenderContentAsCodeTree)();
62
+ const items = [];
63
+ let activeLabel = initialLabel;
64
+ let activeStartedAt = Date.now();
65
+ const runningLabel = getOperationLabel(operation, 'running');
66
+ const spinner = globalState_1.default.startSpinner(useTree
67
+ ? (0, exports.formatContentAsCodeProgress)({
68
+ operation,
69
+ scope,
70
+ items,
71
+ activeLabel,
72
+ })
73
+ : `${runningLabel} ${activeLabel.toLowerCase()}`);
74
+ const setActive = (label) => {
75
+ activeLabel = label;
76
+ activeStartedAt = Date.now();
77
+ if (useTree) {
78
+ spinner.text = (0, exports.formatContentAsCodeProgress)({
79
+ operation,
80
+ scope,
81
+ items,
82
+ activeLabel,
83
+ });
84
+ }
85
+ else {
86
+ spinner.start(`${runningLabel} ${activeLabel.toLowerCase()}`);
87
+ }
88
+ };
89
+ return {
90
+ completeItem: (item, nextLabel) => {
91
+ items.push(item);
92
+ if (!useTree) {
93
+ if (item.variant === 'warning') {
94
+ spinner.stop();
95
+ }
96
+ else {
97
+ spinner.succeed((0, exports.formatContentAsCodeAction)(item));
98
+ }
99
+ }
100
+ if (nextLabel !== null) {
101
+ setActive(nextLabel);
102
+ }
103
+ },
104
+ prepareForFailureDetails: () => {
105
+ if (!useTree) {
106
+ spinner.stop();
107
+ }
108
+ },
109
+ complete: (path, elapsedSeconds) => {
110
+ if (!useTree) {
111
+ return false;
112
+ }
113
+ spinner.stop();
114
+ return (0, exports.renderContentAsCodeComplete)({
115
+ operation,
116
+ scope,
117
+ path,
118
+ elapsedSeconds,
119
+ items,
120
+ });
121
+ },
122
+ fail: (detail, elapsedSeconds, showFallback) => {
123
+ if (useTree) {
124
+ spinner.fail((0, exports.formatContentAsCodeFailure)({
125
+ operation,
126
+ scope,
127
+ elapsedSeconds,
128
+ items,
129
+ failedItem: {
130
+ label: activeLabel,
131
+ detail,
132
+ durationMs: Date.now() - activeStartedAt,
133
+ },
134
+ }));
135
+ }
136
+ else if (showFallback) {
137
+ spinner.fail(`Failed to ${operation} ${scope} content: ${detail}`);
138
+ }
139
+ },
140
+ };
141
+ };
142
+ exports.createContentAsCodeOutput = createContentAsCodeOutput;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=contentAsCodeOutput.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contentAsCodeOutput.test.d.ts","sourceRoot":"","sources":["../../src/terminal/contentAsCodeOutput.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const globalState_1 = tslib_1.__importDefault(require("../globalState"));
5
+ const contentAsCodeOutput_1 = require("./contentAsCodeOutput");
6
+ describe('content as code terminal output', () => {
7
+ it('formats fallback actions with aligned labels and muted metadata', () => {
8
+ const output = (0, contentAsCodeOutput_1.formatContentAsCodeAction)({
9
+ label: 'Custom roles',
10
+ detail: '34 downloaded',
11
+ durationMs: 245,
12
+ });
13
+ expect(output).toContain('Custom roles 34 downloaded (245ms)');
14
+ });
15
+ it('formats running progress as a tree', () => {
16
+ const output = (0, contentAsCodeOutput_1.formatContentAsCodeProgress)({
17
+ operation: 'download',
18
+ scope: 'organization',
19
+ activeLabel: 'Groups',
20
+ items: [
21
+ {
22
+ label: 'Custom roles',
23
+ detail: '34 downloaded',
24
+ durationMs: 85,
25
+ },
26
+ {
27
+ label: 'Users',
28
+ detail: '9 downloaded',
29
+ durationMs: 107,
30
+ },
31
+ ],
32
+ });
33
+ expect(output).toContain('Downloading content as code (organization)');
34
+ expect(output).toContain('└ ✓ Custom roles · 34 downloaded (85ms)');
35
+ expect(output).toContain('└ ✓ Users · 9 downloaded (107ms)');
36
+ expect(output).toContain('└ ◐ Groups · downloading…');
37
+ });
38
+ it('renders one borderless completion tree', () => {
39
+ const output = (0, contentAsCodeOutput_1.formatContentAsCodeComplete)({
40
+ operation: 'upload',
41
+ scope: 'organization',
42
+ path: '/tmp/lightdash',
43
+ elapsedSeconds: 1.2,
44
+ items: [
45
+ {
46
+ label: 'Custom roles',
47
+ detail: '1 created, 2 unchanged',
48
+ durationMs: 245,
49
+ },
50
+ { label: 'Users', detail: '4 updated', durationMs: 31 },
51
+ {
52
+ label: 'Groups',
53
+ detail: 'service unavailable',
54
+ durationMs: 12,
55
+ variant: 'warning',
56
+ },
57
+ ],
58
+ });
59
+ expect(output).toContain('✓ Uploaded content as code (organization) · 1.2s');
60
+ expect(output).toContain('└ ✓ Custom roles');
61
+ expect(output).toContain('(245ms)');
62
+ expect(output).toContain('└ ⚠ Groups');
63
+ expect(output).toContain('└ Read from /tmp/lightdash');
64
+ expect(output).not.toMatch(/[╭╮╰╯│─]/);
65
+ });
66
+ it('formats failures with completed and failed tree rows', () => {
67
+ const output = (0, contentAsCodeOutput_1.formatContentAsCodeFailure)({
68
+ operation: 'download',
69
+ scope: 'organization',
70
+ elapsedSeconds: 0.4,
71
+ items: [
72
+ {
73
+ label: 'Custom roles',
74
+ detail: '34 downloaded',
75
+ durationMs: 85,
76
+ },
77
+ ],
78
+ failedItem: {
79
+ label: 'Users',
80
+ detail: '403 forbidden',
81
+ durationMs: 52,
82
+ },
83
+ });
84
+ expect(output).toContain('Failed to download content as code (organization) · 0.4s');
85
+ expect(output).toContain('└ ✓ Custom roles · 34 downloaded (85ms)');
86
+ expect(output).toContain('└ × Users · 403 forbidden (52ms)');
87
+ });
88
+ it('replaces running progress with one persistent completion tree', () => {
89
+ const originalIsTTY = process.stderr.isTTY;
90
+ Object.defineProperty(process.stderr, 'isTTY', {
91
+ configurable: true,
92
+ value: true,
93
+ });
94
+ vi.stubEnv('CI', 'false');
95
+ vi.stubEnv('TERM', 'xterm');
96
+ vi.stubEnv('NO_UNICODE', 'false');
97
+ const spinner = {
98
+ text: '',
99
+ start: vi.fn(),
100
+ succeed: vi.fn(),
101
+ stop: vi.fn(),
102
+ fail: vi.fn(),
103
+ };
104
+ vi.spyOn(globalState_1.default, 'startSpinner').mockReturnValue(spinner);
105
+ const write = vi
106
+ .spyOn(process.stderr, 'write')
107
+ .mockImplementation(() => true);
108
+ const output = (0, contentAsCodeOutput_1.createContentAsCodeOutput)({
109
+ operation: 'download',
110
+ scope: 'organization',
111
+ initialLabel: 'Custom roles',
112
+ });
113
+ output.completeItem({
114
+ label: 'Custom roles',
115
+ detail: '34 downloaded',
116
+ durationMs: 85,
117
+ }, 'Users');
118
+ expect(spinner.succeed).not.toHaveBeenCalled();
119
+ expect(spinner.text).toContain('└ ◐ Users · downloading…');
120
+ expect(output.complete('/tmp/lightdash', 0.6)).toBe(true);
121
+ expect(spinner.stop).toHaveBeenCalledOnce();
122
+ expect(write).toHaveBeenCalledOnce();
123
+ Object.defineProperty(process.stderr, 'isTTY', {
124
+ configurable: true,
125
+ value: originalIsTTY,
126
+ });
127
+ vi.unstubAllEnvs();
128
+ vi.restoreAllMocks();
129
+ });
130
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "0.3408.1",
3
+ "version": "0.3409.1",
4
4
  "description": "Lightdash CLI tool",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,8 +41,8 @@
41
41
  "unique-names-generator": "4.7.1",
42
42
  "uuid": "11.1.1",
43
43
  "yaml": "2.8.3",
44
- "@lightdash/common": "0.3408.1",
45
- "@lightdash/warehouses": "0.3408.1"
44
+ "@lightdash/common": "0.3409.1",
45
+ "@lightdash/warehouses": "0.3409.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/inquirer": "8.2.4",