@lightdash/cli 1.137.1 → 1.139.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.
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const common_1 = require("@lightdash/common");
5
+ const fs_1 = require("fs");
6
+ const node_fetch_1 = require("node-fetch");
7
+ const os = tslib_1.__importStar(require("os"));
8
+ const path = tslib_1.__importStar(require("path"));
9
+ const yaml_1 = require("yaml");
10
+ const apiClient_1 = require("../dbt/apiClient");
11
+ const themes_1 = require("./themes");
12
+ vi.mock('../dbt/apiClient', () => ({
13
+ lightdashApi: vi.fn(),
14
+ lightdashRawApi: vi.fn(),
15
+ }));
16
+ const manifest = (slug) => ({
17
+ codeVersion: 1,
18
+ slug,
19
+ name: 'Acme Brand',
20
+ description: null,
21
+ extraInstructions: null,
22
+ });
23
+ const createThemeDirectory = async (contentRoot, slug) => {
24
+ const directory = path.join(contentRoot, 'themes', slug);
25
+ await Promise.all(['css', 'fonts', 'images', 'instructions'].map((folder) => fs_1.promises.mkdir(path.join(directory, folder), { recursive: true })));
26
+ await fs_1.promises.writeFile(path.join(directory, 'lightdash-theme.yml'), (0, yaml_1.stringify)(manifest(slug)));
27
+ await fs_1.promises.writeFile(path.join(directory, 'css', 'theme.css'), ':root { --brand: #123456; }');
28
+ return directory;
29
+ };
30
+ describe('organization theme content-as-code', () => {
31
+ let temporaryRoot;
32
+ beforeEach(async () => {
33
+ vi.clearAllMocks();
34
+ vi.mocked(apiClient_1.lightdashApi).mockResolvedValue([]);
35
+ temporaryRoot = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'themes-test-'));
36
+ });
37
+ afterEach(async () => {
38
+ await fs_1.promises.rm(temporaryRoot, { recursive: true, force: true });
39
+ });
40
+ it('treats a missing themes directory as an empty upload', async () => {
41
+ await expect((0, themes_1.prepareThemeUploads)(temporaryRoot)).resolves.toEqual([]);
42
+ });
43
+ it('rejects nested paths during preflight', async () => {
44
+ const themeDirectory = await createThemeDirectory(temporaryRoot, 'acme-brand');
45
+ await fs_1.promises.mkdir(path.join(themeDirectory, 'css', 'nested'));
46
+ await expect((0, themes_1.prepareThemeUploads)(temporaryRoot)).rejects.toThrow('without symlinks or nested directories');
47
+ });
48
+ it('rejects symlinked files during preflight', async () => {
49
+ const themeDirectory = await createThemeDirectory(temporaryRoot, 'acme-brand');
50
+ await fs_1.promises.symlink(path.join(themeDirectory, 'css', 'theme.css'), path.join(themeDirectory, 'css', 'linked.css'));
51
+ await expect((0, themes_1.prepareThemeUploads)(temporaryRoot)).rejects.toThrow('without symlinks or nested directories');
52
+ });
53
+ it('accepts Git-dropped empty directories and ignores local filesystem artifacts', async () => {
54
+ const themeDirectory = await createThemeDirectory(temporaryRoot, 'acme-brand');
55
+ await Promise.all(['fonts', 'images', 'instructions'].map((folder) => fs_1.promises.rmdir(path.join(themeDirectory, folder))));
56
+ await Promise.all([
57
+ fs_1.promises.writeFile(path.join(temporaryRoot, 'themes', '.DS_Store'), 'metadata'),
58
+ fs_1.promises.writeFile(path.join(themeDirectory, '.DS_Store'), 'metadata'),
59
+ fs_1.promises.writeFile(path.join(themeDirectory, 'css', '.DS_Store'), 'metadata'),
60
+ fs_1.promises.mkdir(path.join(temporaryRoot, 'themes', '.lightdash-theme-ABC123')),
61
+ fs_1.promises.mkdir(path.join(temporaryRoot, 'themes', 'acme-brand.backup-00000000-0000-4000-8000-000000000001')),
62
+ ]);
63
+ const prepared = await (0, themes_1.prepareThemeUploads)(temporaryRoot);
64
+ expect(prepared).toHaveLength(1);
65
+ expect(prepared[0].manifest.slug).toBe('acme-brand');
66
+ });
67
+ it('round-trips a downloaded package and replaces stale theme files', async () => {
68
+ const sourceRoot = path.join(temporaryRoot, 'source');
69
+ const destinationRoot = path.join(temporaryRoot, 'destination');
70
+ await createThemeDirectory(sourceRoot, 'acme-brand');
71
+ const [prepared] = await (0, themes_1.prepareThemeUploads)(sourceRoot);
72
+ const staleDirectory = await createThemeDirectory(destinationRoot, 'acme-brand');
73
+ await createThemeDirectory(destinationRoot, 'remotely-deleted');
74
+ await fs_1.promises.writeFile(path.join(staleDirectory, 'stale.txt'), 'stale');
75
+ vi.mocked(apiClient_1.lightdashApi).mockResolvedValue([
76
+ { slug: 'acme-brand' },
77
+ ]);
78
+ vi.mocked(apiClient_1.lightdashRawApi).mockResolvedValue(new node_fetch_1.Response(prepared.archive));
79
+ await expect((0, themes_1.downloadThemes)(destinationRoot)).resolves.toBe(1);
80
+ await expect(fs_1.promises.readFile(path.join(destinationRoot, 'themes', 'acme-brand', 'css', 'theme.css'), 'utf8')).resolves.toContain('--brand');
81
+ await expect(fs_1.promises.stat(path.join(destinationRoot, 'themes', 'acme-brand', 'stale.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
82
+ await expect(fs_1.promises.stat(path.join(destinationRoot, 'themes', 'remotely-deleted'))).rejects.toMatchObject({ code: 'ENOENT' });
83
+ const [downloaded] = await (0, themes_1.prepareThemeUploads)(destinationRoot);
84
+ expect(downloaded.archive).toEqual(prepared.archive);
85
+ });
86
+ it('keeps the previous local theme set when a package download fails', async () => {
87
+ const sourceRoot = path.join(temporaryRoot, 'source');
88
+ const destinationRoot = path.join(temporaryRoot, 'destination');
89
+ await createThemeDirectory(sourceRoot, 'first-theme');
90
+ const [prepared] = await (0, themes_1.prepareThemeUploads)(sourceRoot);
91
+ const existingDirectory = await createThemeDirectory(destinationRoot, 'existing-theme');
92
+ await fs_1.promises.writeFile(path.join(existingDirectory, 'local-marker.txt'), 'keep me');
93
+ vi.mocked(apiClient_1.lightdashApi).mockResolvedValue([
94
+ { slug: 'first-theme' },
95
+ { slug: 'second-theme' },
96
+ ]);
97
+ vi.mocked(apiClient_1.lightdashRawApi)
98
+ .mockResolvedValueOnce(new node_fetch_1.Response(prepared.archive))
99
+ .mockResolvedValueOnce(new node_fetch_1.Response(Buffer.from('not a tar')));
100
+ await expect((0, themes_1.downloadThemes)(destinationRoot)).rejects.toThrow();
101
+ await expect(fs_1.promises.readFile(path.join(existingDirectory, 'local-marker.txt'), 'utf8')).resolves.toBe('keep me');
102
+ await expect(fs_1.promises.stat(path.join(destinationRoot, 'themes', 'first-theme'))).rejects.toMatchObject({ code: 'ENOENT' });
103
+ });
104
+ it('reports completed and failed imports without claiming full success', async () => {
105
+ await createThemeDirectory(temporaryRoot, 'first-theme');
106
+ await createThemeDirectory(temporaryRoot, 'second-theme');
107
+ const prepared = await (0, themes_1.prepareThemeUploads)(temporaryRoot);
108
+ vi.mocked(apiClient_1.lightdashRawApi)
109
+ .mockResolvedValueOnce(new node_fetch_1.Response(JSON.stringify({
110
+ status: 'ok',
111
+ results: {
112
+ slug: 'first-theme',
113
+ action: common_1.PromotionAction.CREATE,
114
+ },
115
+ })))
116
+ .mockRejectedValueOnce(new Error('remote failure'));
117
+ const summary = await (0, themes_1.uploadThemes)(prepared);
118
+ expect(summary).toEqual({
119
+ created: 1,
120
+ updated: 0,
121
+ unchanged: 0,
122
+ failed: 1,
123
+ completedSlugs: ['first-theme'],
124
+ failures: [
125
+ {
126
+ message: 'Failed to upload theme "second-theme": remote failure',
127
+ },
128
+ ],
129
+ });
130
+ });
131
+ it('reports the action returned for every successful import', async () => {
132
+ await createThemeDirectory(temporaryRoot, 'created-theme');
133
+ await createThemeDirectory(temporaryRoot, 'updated-theme');
134
+ await createThemeDirectory(temporaryRoot, 'unchanged-theme');
135
+ const prepared = await (0, themes_1.prepareThemeUploads)(temporaryRoot);
136
+ for (const theme of prepared) {
137
+ const actionBySlug = {
138
+ 'created-theme': common_1.PromotionAction.CREATE,
139
+ 'updated-theme': common_1.PromotionAction.UPDATE,
140
+ 'unchanged-theme': common_1.PromotionAction.NO_CHANGES,
141
+ };
142
+ vi.mocked(apiClient_1.lightdashRawApi).mockResolvedValueOnce(new node_fetch_1.Response(JSON.stringify({
143
+ status: 'ok',
144
+ results: {
145
+ slug: theme.manifest.slug,
146
+ action: actionBySlug[theme.manifest.slug],
147
+ },
148
+ })));
149
+ }
150
+ await expect((0, themes_1.uploadThemes)(prepared)).resolves.toMatchObject({
151
+ created: 1,
152
+ updated: 1,
153
+ unchanged: 1,
154
+ failed: 0,
155
+ });
156
+ });
157
+ it('classifies successful imports returned by older servers', async () => {
158
+ await createThemeDirectory(temporaryRoot, 'existing-theme');
159
+ await createThemeDirectory(temporaryRoot, 'new-theme');
160
+ const prepared = await (0, themes_1.prepareThemeUploads)(temporaryRoot);
161
+ vi.mocked(apiClient_1.lightdashApi).mockResolvedValue([
162
+ { slug: 'existing-theme' },
163
+ ]);
164
+ vi.mocked(apiClient_1.lightdashRawApi)
165
+ .mockResolvedValueOnce(new node_fetch_1.Response(JSON.stringify({
166
+ status: 'ok',
167
+ results: { slug: 'existing-theme' },
168
+ })))
169
+ .mockResolvedValueOnce(new node_fetch_1.Response(JSON.stringify({
170
+ status: 'ok',
171
+ results: { slug: 'new-theme' },
172
+ })));
173
+ await expect((0, themes_1.uploadThemes)(prepared)).resolves.toMatchObject({
174
+ created: 1,
175
+ updated: 1,
176
+ unchanged: 0,
177
+ failed: 0,
178
+ });
179
+ });
180
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "1.137.1",
3
+ "version": "1.139.0",
4
4
  "description": "Lightdash CLI tool",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,11 +38,12 @@
38
38
  "ora": "5.4.1",
39
39
  "p-limit": "3.1.0",
40
40
  "parse-node-version": "2.0.0",
41
+ "tar-stream": "3.1.8",
41
42
  "unique-names-generator": "4.7.1",
42
43
  "uuid": "11.1.1",
43
44
  "yaml": "2.8.3",
44
- "@lightdash/common": "1.137.1",
45
- "@lightdash/warehouses": "1.137.1"
45
+ "@lightdash/common": "1.139.0",
46
+ "@lightdash/warehouses": "1.139.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/inquirer": "8.2.4",
@@ -53,6 +54,7 @@
53
54
  "@types/node-fetch": "2.6.13",
54
55
  "@types/nunjucks": "3.2.1",
55
56
  "@types/parse-node-version": "1.0.0",
57
+ "@types/tar-stream": "3.1.4",
56
58
  "@types/uuid": "10.0.0",
57
59
  "@vercel/ncc": "0.38.4",
58
60
  "@yao-pkg/pkg": "6.7.0",