@lightdash/cli 1.191.2 → 1.193.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/handlers/slugUpdate.d.ts +36 -0
- package/dist/handlers/slugUpdate.d.ts.map +1 -0
- package/dist/handlers/slugUpdate.js +333 -0
- package/dist/handlers/slugUpdate.test.d.ts +2 -0
- package/dist/handlers/slugUpdate.test.d.ts.map +1 -0
- package/dist/handlers/slugUpdate.test.js +208 -0
- package/dist/index.js +11 -0
- package/package.json +3 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ContentSlugRenameRequest } from '@lightdash/common';
|
|
2
|
+
type SlugUpdateOptions = {
|
|
3
|
+
verbose: boolean;
|
|
4
|
+
project?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
dryRun: boolean;
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
};
|
|
10
|
+
type LocalSlugUpdatePlan = {
|
|
11
|
+
fileUpdates: Array<{
|
|
12
|
+
filePath: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}>;
|
|
15
|
+
fileMoves: Array<{
|
|
16
|
+
source: string;
|
|
17
|
+
target: string;
|
|
18
|
+
}>;
|
|
19
|
+
metadata: {
|
|
20
|
+
root: string;
|
|
21
|
+
charts: Record<string, string>;
|
|
22
|
+
} | undefined;
|
|
23
|
+
referencesUpdated: number;
|
|
24
|
+
};
|
|
25
|
+
export type LocalSlugUpdateFileChange = {
|
|
26
|
+
source: string;
|
|
27
|
+
target?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const planLocalChartSlugUpdate: (customPath: string | undefined, from: string, to: string) => Promise<LocalSlugUpdatePlan>;
|
|
30
|
+
export declare const getLocalSlugUpdateFileChanges: (plan: LocalSlugUpdatePlan, customPath: string | undefined) => LocalSlugUpdateFileChange[];
|
|
31
|
+
export declare const applyLocalChartSlugUpdate: (plan: LocalSlugUpdatePlan) => Promise<() => Promise<void>>;
|
|
32
|
+
export declare const requestSlugUpdate: (projectUuid: string, request: ContentSlugRenameRequest) => Promise<void>;
|
|
33
|
+
export declare const executeChartSlugUpdate: (projectUuid: string, customPath: string | undefined, from: string, to: string) => Promise<LocalSlugUpdatePlan>;
|
|
34
|
+
export declare const slugUpdateHandler: (options: SlugUpdateOptions) => Promise<void>;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=slugUpdate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slugUpdate.d.ts","sourceRoot":"","sources":["../../src/handlers/slugUpdate.ts"],"names":[],"mappings":"AAAA,OAAO,EAMH,KAAK,wBAAwB,EAChC,MAAM,mBAAmB,CAAC;AAY3B,KAAK,iBAAiB,GAAG;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACvB,WAAW,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,SAAS,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrD,QAAQ,EACF;QACI,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KAClC,GACD,SAAS,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC7B,CAAC;AAIF,MAAM,MAAM,yBAAyB,GAAG;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAwDF,eAAO,MAAM,wBAAwB,eACrB,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,KACX,OAAO,CAAC,mBAAmB,CAyL7B,CAAC;AAEF,eAAO,MAAM,6BAA6B,SAChC,mBAAmB,cACb,MAAM,GAAG,SAAS,KAC/B,yBAAyB,EAuB3B,CAAC;AAuDF,eAAO,MAAM,yBAAyB,SAC5B,mBAAmB,KAC1B,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAkD7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,WACV,wBAAwB,KAClC,OAAO,CAAC,IAAI,CAmBd,CAAC;AAEF,eAAO,MAAM,sBAAsB,gBAClB,MAAM,cACP,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,KACX,OAAO,CAAC,mBAAmB,CAkB7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,YACjB,iBAAiB,KAC3B,OAAO,CAAC,IAAI,CAsDd,CAAC"}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.slugUpdateHandler = exports.executeChartSlugUpdate = exports.requestSlugUpdate = exports.applyLocalChartSlugUpdate = exports.getLocalSlugUpdateFileChanges = exports.planLocalChartSlugUpdate = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@lightdash/common");
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
8
|
+
const yaml_1 = require("yaml");
|
|
9
|
+
const config_1 = require("../config");
|
|
10
|
+
const globalState_1 = tslib_1.__importDefault(require("../globalState"));
|
|
11
|
+
const styles = tslib_1.__importStar(require("../styles"));
|
|
12
|
+
const contentAsCodePaths_1 = require("./contentAsCodePaths");
|
|
13
|
+
const apiClient_1 = require("./dbt/apiClient");
|
|
14
|
+
const metadataFile_1 = require("./metadataFile");
|
|
15
|
+
const selectProject_1 = require("./selectProject");
|
|
16
|
+
const getYamlFiles = async (root) => {
|
|
17
|
+
try {
|
|
18
|
+
const entries = await fs_1.promises.readdir(root, {
|
|
19
|
+
recursive: true,
|
|
20
|
+
withFileTypes: true,
|
|
21
|
+
});
|
|
22
|
+
return entries
|
|
23
|
+
.filter((entry) => entry.isFile() &&
|
|
24
|
+
(entry.name.endsWith('.yml') ||
|
|
25
|
+
entry.name.endsWith('.yaml')) &&
|
|
26
|
+
!entry.name.endsWith('.language.map.yml') &&
|
|
27
|
+
!entry.name.endsWith('.language.map.yaml'))
|
|
28
|
+
.map((entry) => path.join(entry.parentPath, entry.name));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const assertMovesAreSafe = async (moves) => {
|
|
38
|
+
const sources = new Set(moves.map(({ source }) => source));
|
|
39
|
+
const targets = new Set();
|
|
40
|
+
for (const { target } of moves) {
|
|
41
|
+
if (targets.has(target)) {
|
|
42
|
+
throw new common_1.ParameterError(`Multiple content files would be renamed to "${target}"`);
|
|
43
|
+
}
|
|
44
|
+
targets.add(target);
|
|
45
|
+
try {
|
|
46
|
+
// eslint-disable-next-line no-await-in-loop
|
|
47
|
+
await fs_1.promises.access(target);
|
|
48
|
+
if (!sources.has(target)) {
|
|
49
|
+
throw new common_1.ParameterError(`Cannot rename content file because "${target}" already exists`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== 'ENOENT') {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const planLocalChartSlugUpdate = async (customPath, from, to) => {
|
|
60
|
+
const root = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
|
|
61
|
+
const fileUpdates = [];
|
|
62
|
+
const fileMoves = [];
|
|
63
|
+
let referencesUpdated = 0;
|
|
64
|
+
if (!from || from.length > 255) {
|
|
65
|
+
throw new common_1.ParameterError('The source slug must contain between 1 and 255 characters');
|
|
66
|
+
}
|
|
67
|
+
if (!to || to.length > 255 || (0, common_1.generateSlug)(to) !== to) {
|
|
68
|
+
throw new common_1.ParameterError('The target slug must contain between 1 and 255 lowercase letters, numbers, or hyphen-separated words');
|
|
69
|
+
}
|
|
70
|
+
if (from === to) {
|
|
71
|
+
return {
|
|
72
|
+
fileUpdates,
|
|
73
|
+
fileMoves,
|
|
74
|
+
metadata: undefined,
|
|
75
|
+
referencesUpdated,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
for (const filePath of await getYamlFiles(root)) {
|
|
79
|
+
// eslint-disable-next-line no-await-in-loop
|
|
80
|
+
const source = await fs_1.promises.readFile(filePath, 'utf8');
|
|
81
|
+
const document = (0, yaml_1.parseDocument)(source);
|
|
82
|
+
if (document.errors.length > 0) {
|
|
83
|
+
throw new common_1.ParameterError(`Could not parse "${filePath}": ${document.errors
|
|
84
|
+
.map(({ message }) => message)
|
|
85
|
+
.join('; ')}`);
|
|
86
|
+
}
|
|
87
|
+
let changed = false;
|
|
88
|
+
const parsed = document.toJS();
|
|
89
|
+
const isChart = parsed.contentType === common_1.ContentType.CHART ||
|
|
90
|
+
parsed.metricQuery !== undefined;
|
|
91
|
+
if (isChart && parsed.slug === from) {
|
|
92
|
+
document.set('slug', to);
|
|
93
|
+
referencesUpdated += 1;
|
|
94
|
+
changed = true;
|
|
95
|
+
const extension = path.extname(filePath);
|
|
96
|
+
if (path.basename(filePath) === `${from}${extension}`) {
|
|
97
|
+
fileMoves.push({
|
|
98
|
+
source: filePath,
|
|
99
|
+
target: path.join(path.dirname(filePath), `${to}${extension}`),
|
|
100
|
+
});
|
|
101
|
+
for (const languageMapExtension of ['.yml', '.yaml']) {
|
|
102
|
+
const sourceLanguageMap = path.join(path.dirname(filePath), `${from}.language.map${languageMapExtension}`);
|
|
103
|
+
try {
|
|
104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
105
|
+
await fs_1.promises.access(sourceLanguageMap);
|
|
106
|
+
// eslint-disable-next-line no-await-in-loop
|
|
107
|
+
const languageMapSource = await fs_1.promises.readFile(sourceLanguageMap, 'utf8');
|
|
108
|
+
const languageMapDocument = (0, yaml_1.parseDocument)(languageMapSource);
|
|
109
|
+
if (languageMapDocument.errors.length > 0) {
|
|
110
|
+
throw new common_1.ParameterError(`Could not parse "${sourceLanguageMap}": ${languageMapDocument.errors
|
|
111
|
+
.map(({ message }) => message)
|
|
112
|
+
.join('; ')}`);
|
|
113
|
+
}
|
|
114
|
+
const languageMapEntry = languageMapDocument.getIn([
|
|
115
|
+
'chart',
|
|
116
|
+
from,
|
|
117
|
+
]);
|
|
118
|
+
if (languageMapEntry !== undefined) {
|
|
119
|
+
if (languageMapDocument.getIn(['chart', to]) !==
|
|
120
|
+
undefined) {
|
|
121
|
+
throw new common_1.ParameterError(`Cannot update language map because chart slug "${to}" already exists in "${sourceLanguageMap}"`);
|
|
122
|
+
}
|
|
123
|
+
languageMapDocument.setIn(['chart', to], languageMapEntry);
|
|
124
|
+
languageMapDocument.deleteIn(['chart', from]);
|
|
125
|
+
fileUpdates.push({
|
|
126
|
+
filePath: sourceLanguageMap,
|
|
127
|
+
content: languageMapDocument.toString(),
|
|
128
|
+
});
|
|
129
|
+
referencesUpdated += 1;
|
|
130
|
+
}
|
|
131
|
+
fileMoves.push({
|
|
132
|
+
source: sourceLanguageMap,
|
|
133
|
+
target: path.join(path.dirname(filePath), `${to}.language.map${languageMapExtension}`),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code !== 'ENOENT') {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(parsed.tiles)) {
|
|
145
|
+
for (const [index, tile] of parsed.tiles.entries()) {
|
|
146
|
+
if (tile.type === 'saved_chart' &&
|
|
147
|
+
tile.properties?.chartSlug === from) {
|
|
148
|
+
document.setIn(['tiles', index, 'properties', 'chartSlug'], to);
|
|
149
|
+
referencesUpdated += 1;
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (parsed.resource?.type === common_1.ContentType.CHART &&
|
|
155
|
+
parsed.resource.slug === from) {
|
|
156
|
+
document.setIn(['resource', 'slug'], to);
|
|
157
|
+
referencesUpdated += 1;
|
|
158
|
+
changed = true;
|
|
159
|
+
}
|
|
160
|
+
if (changed) {
|
|
161
|
+
fileUpdates.push({ filePath, content: document.toString() });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const metadata = await (0, metadataFile_1.readMetadataFile)(root);
|
|
165
|
+
let updatedMetadata;
|
|
166
|
+
if (from in metadata.charts) {
|
|
167
|
+
if (to !== from && to in metadata.charts) {
|
|
168
|
+
throw new common_1.ParameterError(`Cannot update local metadata because chart slug "${to}" already exists`);
|
|
169
|
+
}
|
|
170
|
+
const charts = { ...metadata.charts };
|
|
171
|
+
charts[to] = charts[from];
|
|
172
|
+
delete charts[from];
|
|
173
|
+
updatedMetadata = { root, charts };
|
|
174
|
+
}
|
|
175
|
+
await assertMovesAreSafe(fileMoves);
|
|
176
|
+
return {
|
|
177
|
+
fileUpdates,
|
|
178
|
+
fileMoves,
|
|
179
|
+
metadata: updatedMetadata,
|
|
180
|
+
referencesUpdated,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
exports.planLocalChartSlugUpdate = planLocalChartSlugUpdate;
|
|
184
|
+
const getLocalSlugUpdateFileChanges = (plan, customPath) => {
|
|
185
|
+
const root = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
|
|
186
|
+
const movedSources = new Set(plan.fileMoves.map(({ source }) => source));
|
|
187
|
+
const relative = (filePath) => path.relative(root, filePath);
|
|
188
|
+
return [
|
|
189
|
+
...plan.fileMoves.map(({ source, target }) => ({
|
|
190
|
+
source: relative(source),
|
|
191
|
+
target: relative(target),
|
|
192
|
+
})),
|
|
193
|
+
...plan.fileUpdates
|
|
194
|
+
.filter(({ filePath }) => !movedSources.has(filePath))
|
|
195
|
+
.map(({ filePath }) => ({ source: relative(filePath) })),
|
|
196
|
+
...(plan.metadata
|
|
197
|
+
? [
|
|
198
|
+
{
|
|
199
|
+
source: relative(path.join(plan.metadata.root, metadataFile_1.METADATA_FILENAME)),
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
: []),
|
|
203
|
+
].sort((first, second) => first.source.localeCompare(second.source));
|
|
204
|
+
};
|
|
205
|
+
exports.getLocalSlugUpdateFileChanges = getLocalSlugUpdateFileChanges;
|
|
206
|
+
const logLocalSlugUpdateFileChanges = (plan, customPath, dryRun) => {
|
|
207
|
+
const changes = (0, exports.getLocalSlugUpdateFileChanges)(plan, customPath);
|
|
208
|
+
if (changes.length === 0) {
|
|
209
|
+
console.info(styles.secondary('No local files need updating.'));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.info(styles.secondary(dryRun ? 'Local files that would change:' : 'Updated local files:'));
|
|
213
|
+
for (const change of changes) {
|
|
214
|
+
console.info(styles.secondary(` ${change.source}${change.target ? ` -> ${change.target}` : ''}`));
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const captureFiles = async (filePaths) => {
|
|
218
|
+
const snapshots = new Map();
|
|
219
|
+
for (const filePath of filePaths) {
|
|
220
|
+
try {
|
|
221
|
+
// eslint-disable-next-line no-await-in-loop
|
|
222
|
+
snapshots.set(filePath, await fs_1.promises.readFile(filePath));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error.code !== 'ENOENT') {
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
snapshots.set(filePath, undefined);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return snapshots;
|
|
232
|
+
};
|
|
233
|
+
const restoreFiles = async (snapshots) => {
|
|
234
|
+
await Promise.all([...snapshots].map(([filePath, content]) => content === undefined
|
|
235
|
+
? fs_1.promises.rm(filePath, { force: true })
|
|
236
|
+
: fs_1.promises.writeFile(filePath, content)));
|
|
237
|
+
};
|
|
238
|
+
const applyLocalChartSlugUpdate = async (plan) => {
|
|
239
|
+
const metadataPath = plan.metadata
|
|
240
|
+
? path.join(plan.metadata.root, metadataFile_1.METADATA_FILENAME)
|
|
241
|
+
: undefined;
|
|
242
|
+
const affectedPaths = new Set([
|
|
243
|
+
...plan.fileUpdates.map(({ filePath }) => filePath),
|
|
244
|
+
...plan.fileMoves.flatMap(({ source, target }) => [source, target]),
|
|
245
|
+
...(metadataPath ? [metadataPath] : []),
|
|
246
|
+
]);
|
|
247
|
+
const snapshots = await captureFiles(affectedPaths);
|
|
248
|
+
const stagedMoves = [];
|
|
249
|
+
try {
|
|
250
|
+
await Promise.all(plan.fileUpdates.map(({ filePath, content }) => fs_1.promises.writeFile(filePath, content)));
|
|
251
|
+
for (const [index, move] of plan.fileMoves.entries()) {
|
|
252
|
+
const temporary = `${move.source}.slug-update-${process.pid}-${index}`;
|
|
253
|
+
// eslint-disable-next-line no-await-in-loop
|
|
254
|
+
await fs_1.promises.rename(move.source, temporary);
|
|
255
|
+
stagedMoves.push(temporary);
|
|
256
|
+
}
|
|
257
|
+
for (const [index, move] of plan.fileMoves.entries()) {
|
|
258
|
+
// eslint-disable-next-line no-await-in-loop
|
|
259
|
+
await fs_1.promises.rename(stagedMoves[index], move.target);
|
|
260
|
+
}
|
|
261
|
+
if (plan.metadata && metadataPath) {
|
|
262
|
+
const existing = await (0, metadataFile_1.readMetadataFile)(plan.metadata.root);
|
|
263
|
+
await fs_1.promises.writeFile(metadataPath, JSON.stringify({ ...existing, charts: plan.metadata.charts }, null, 2));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
await Promise.all(stagedMoves.map((temporary) => fs_1.promises.rm(temporary, { force: true })));
|
|
268
|
+
await restoreFiles(snapshots);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
return () => restoreFiles(snapshots);
|
|
272
|
+
};
|
|
273
|
+
exports.applyLocalChartSlugUpdate = applyLocalChartSlugUpdate;
|
|
274
|
+
const requestSlugUpdate = async (projectUuid, request) => {
|
|
275
|
+
try {
|
|
276
|
+
await (0, apiClient_1.lightdashApi)({
|
|
277
|
+
method: 'POST',
|
|
278
|
+
url: `/api/v1/projects/${projectUuid}/slugs/rename`,
|
|
279
|
+
body: JSON.stringify(request),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error instanceof common_1.LightdashError &&
|
|
284
|
+
error.statusCode === 404 &&
|
|
285
|
+
error.message === 'API endpoint not found') {
|
|
286
|
+
throw new common_1.ParameterError('This Lightdash server does not support slug-update yet. Upgrade the server before using this command.');
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
exports.requestSlugUpdate = requestSlugUpdate;
|
|
292
|
+
const executeChartSlugUpdate = async (projectUuid, customPath, from, to) => {
|
|
293
|
+
const localPlan = await (0, exports.planLocalChartSlugUpdate)(customPath, from, to);
|
|
294
|
+
await (0, exports.requestSlugUpdate)(projectUuid, {
|
|
295
|
+
resourceType: common_1.ContentType.CHART,
|
|
296
|
+
from,
|
|
297
|
+
to,
|
|
298
|
+
});
|
|
299
|
+
try {
|
|
300
|
+
await (0, exports.applyLocalChartSlugUpdate)(localPlan);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new Error('The chart slug was renamed in Lightdash, but the local files could not be updated. Fix the local file error and rerun the same command; the rename is idempotent.', { cause: error });
|
|
304
|
+
}
|
|
305
|
+
return localPlan;
|
|
306
|
+
};
|
|
307
|
+
exports.executeChartSlugUpdate = executeChartSlugUpdate;
|
|
308
|
+
const slugUpdateHandler = async (options) => {
|
|
309
|
+
globalState_1.default.setVerbose(options.verbose);
|
|
310
|
+
await (0, apiClient_1.checkLightdashVersion)();
|
|
311
|
+
const config = await (0, config_1.getConfig)();
|
|
312
|
+
if (!config.context?.apiKey || !config.context.serverUrl) {
|
|
313
|
+
throw new common_1.AuthorizationError(`Not logged in. Run 'lightdash login --help'`);
|
|
314
|
+
}
|
|
315
|
+
const selection = await (0, selectProject_1.selectProject)(config, options.project);
|
|
316
|
+
if (!selection) {
|
|
317
|
+
throw new common_1.ParameterError('No project selected. Run lightdash config set-project');
|
|
318
|
+
}
|
|
319
|
+
(0, selectProject_1.logSelectedProject)(selection, config, options.dryRun
|
|
320
|
+
? 'Previewing chart slug update in'
|
|
321
|
+
: 'Updating chart slug in');
|
|
322
|
+
if (options.dryRun) {
|
|
323
|
+
const localPlan = await (0, exports.planLocalChartSlugUpdate)(options.path, options.from, options.to);
|
|
324
|
+
console.info(styles.success(`Would update chart slug "${options.from}" -> "${options.to}".`));
|
|
325
|
+
logLocalSlugUpdateFileChanges(localPlan, options.path, true);
|
|
326
|
+
console.info(styles.secondary('No changes were made.'));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const localPlan = await (0, exports.executeChartSlugUpdate)(selection.projectUuid, options.path, options.from, options.to);
|
|
330
|
+
console.info(styles.success(`Updated chart slug "${options.from}" -> "${options.to}".`));
|
|
331
|
+
logLocalSlugUpdateFileChanges(localPlan, options.path, false);
|
|
332
|
+
};
|
|
333
|
+
exports.slugUpdateHandler = slugUpdateHandler;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slugUpdate.test.d.ts","sourceRoot":"","sources":["../../src/handlers/slugUpdate.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,208 @@
|
|
|
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 os_1 = require("os");
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
8
|
+
const vitest_1 = require("vitest");
|
|
9
|
+
const yaml_1 = require("yaml");
|
|
10
|
+
const apiClient = tslib_1.__importStar(require("./dbt/apiClient"));
|
|
11
|
+
const slugUpdate_1 = require("./slugUpdate");
|
|
12
|
+
const temporaryDirectories = [];
|
|
13
|
+
const createTemporaryContent = async () => {
|
|
14
|
+
const root = await fs_1.promises.mkdtemp(path.join((0, os_1.tmpdir)(), 'slug-update-'));
|
|
15
|
+
temporaryDirectories.push(root);
|
|
16
|
+
await Promise.all(['charts', 'dashboards', 'alerts'].map((folder) => fs_1.promises.mkdir(path.join(root, folder), { recursive: true })));
|
|
17
|
+
return root;
|
|
18
|
+
};
|
|
19
|
+
(0, vitest_1.afterEach)(async () => {
|
|
20
|
+
vitest_1.vi.restoreAllMocks();
|
|
21
|
+
await Promise.all(temporaryDirectories
|
|
22
|
+
.splice(0)
|
|
23
|
+
.map((directory) => fs_1.promises.rm(directory, { recursive: true, force: true })));
|
|
24
|
+
});
|
|
25
|
+
(0, vitest_1.describe)('local chart slug updates', () => {
|
|
26
|
+
(0, vitest_1.test)('updates chart files, language maps, references, filenames, and metadata', async () => {
|
|
27
|
+
const root = await createTemporaryContent();
|
|
28
|
+
const chartFile = path.join(root, 'charts', 'copy-of-orders.yml');
|
|
29
|
+
const languageMapFile = path.join(root, 'charts', 'copy-of-orders.language.map.yml');
|
|
30
|
+
const dashboardFile = path.join(root, 'dashboards', 'overview.yml');
|
|
31
|
+
const alertFile = path.join(root, 'alerts', 'orders-alert.yml');
|
|
32
|
+
const unrelatedFile = path.join(root, 'dashboards', 'unrelated.yml');
|
|
33
|
+
const unrelatedSource = '# Keep this file byte-identical\ncontentType: dashboard\nslug: unrelated\ntiles: []\n';
|
|
34
|
+
await Promise.all([
|
|
35
|
+
fs_1.promises.writeFile(chartFile, '# Chart comment\ncontentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n'),
|
|
36
|
+
fs_1.promises.writeFile(languageMapFile, 'chart:\n copy-of-orders:\n name: Orders\n'),
|
|
37
|
+
fs_1.promises.writeFile(dashboardFile, [
|
|
38
|
+
'contentType: dashboard',
|
|
39
|
+
'slug: overview',
|
|
40
|
+
'tiles:',
|
|
41
|
+
' - type: saved_chart',
|
|
42
|
+
' tileSlug: copy-of-orders',
|
|
43
|
+
' properties:',
|
|
44
|
+
' chartSlug: copy-of-orders',
|
|
45
|
+
' - type: sql_chart',
|
|
46
|
+
' properties:',
|
|
47
|
+
' chartSlug: copy-of-orders',
|
|
48
|
+
'',
|
|
49
|
+
].join('\n')),
|
|
50
|
+
fs_1.promises.writeFile(alertFile, 'contentType: alert\nslug: orders-alert\nresource:\n type: chart\n slug: copy-of-orders\n'),
|
|
51
|
+
fs_1.promises.writeFile(unrelatedFile, unrelatedSource),
|
|
52
|
+
fs_1.promises.writeFile(path.join(root, '.lightdash-metadata.json'), JSON.stringify({
|
|
53
|
+
version: 1,
|
|
54
|
+
charts: { 'copy-of-orders': 'timestamp' },
|
|
55
|
+
dashboards: { overview: 'dashboard-timestamp' },
|
|
56
|
+
})),
|
|
57
|
+
]);
|
|
58
|
+
const plan = await (0, slugUpdate_1.planLocalChartSlugUpdate)(root, 'copy-of-orders', 'orders');
|
|
59
|
+
(0, vitest_1.expect)(plan.referencesUpdated).toBe(4);
|
|
60
|
+
(0, vitest_1.expect)(plan.fileMoves).toHaveLength(2);
|
|
61
|
+
(0, vitest_1.expect)((0, slugUpdate_1.getLocalSlugUpdateFileChanges)(plan, root)).toEqual([
|
|
62
|
+
{ source: '.lightdash-metadata.json' },
|
|
63
|
+
{ source: path.join('alerts', 'orders-alert.yml') },
|
|
64
|
+
{
|
|
65
|
+
source: path.join('charts', 'copy-of-orders.language.map.yml'),
|
|
66
|
+
target: path.join('charts', 'orders.language.map.yml'),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
source: path.join('charts', 'copy-of-orders.yml'),
|
|
70
|
+
target: path.join('charts', 'orders.yml'),
|
|
71
|
+
},
|
|
72
|
+
{ source: path.join('dashboards', 'overview.yml') },
|
|
73
|
+
]);
|
|
74
|
+
await (0, slugUpdate_1.applyLocalChartSlugUpdate)(plan);
|
|
75
|
+
await (0, vitest_1.expect)(fs_1.promises.access(chartFile)).rejects.toThrow();
|
|
76
|
+
await (0, vitest_1.expect)(fs_1.promises.access(languageMapFile)).rejects.toThrow();
|
|
77
|
+
const renamedChart = (0, yaml_1.parse)(await fs_1.promises.readFile(path.join(root, 'charts', 'orders.yml'), 'utf8'));
|
|
78
|
+
(0, vitest_1.expect)(renamedChart.slug).toBe('orders');
|
|
79
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(path.join(root, 'charts', 'orders.yml'), 'utf8')).toContain('# Chart comment');
|
|
80
|
+
const languageMap = (0, yaml_1.parse)(await fs_1.promises.readFile(path.join(root, 'charts', 'orders.language.map.yml'), 'utf8'));
|
|
81
|
+
(0, vitest_1.expect)(languageMap.chart.orders.name).toBe('Orders');
|
|
82
|
+
(0, vitest_1.expect)(languageMap.chart['copy-of-orders']).toBeUndefined();
|
|
83
|
+
const dashboard = (0, yaml_1.parse)(await fs_1.promises.readFile(dashboardFile, 'utf8'));
|
|
84
|
+
(0, vitest_1.expect)(dashboard.tiles[0]).toMatchObject({
|
|
85
|
+
tileSlug: 'copy-of-orders',
|
|
86
|
+
properties: { chartSlug: 'orders' },
|
|
87
|
+
});
|
|
88
|
+
(0, vitest_1.expect)(dashboard.tiles[1].properties.chartSlug).toBe('copy-of-orders');
|
|
89
|
+
(0, vitest_1.expect)((0, yaml_1.parse)(await fs_1.promises.readFile(alertFile, 'utf8')).resource.slug).toBe('orders');
|
|
90
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(unrelatedFile, 'utf8')).toBe(unrelatedSource);
|
|
91
|
+
const metadata = JSON.parse(await fs_1.promises.readFile(path.join(root, '.lightdash-metadata.json'), 'utf8'));
|
|
92
|
+
(0, vitest_1.expect)(metadata).toEqual({
|
|
93
|
+
version: 1,
|
|
94
|
+
charts: { orders: 'timestamp' },
|
|
95
|
+
dashboards: { overview: 'dashboard-timestamp' },
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
(0, vitest_1.test)('is idempotent after local files have already been updated', async () => {
|
|
99
|
+
const root = await createTemporaryContent();
|
|
100
|
+
await fs_1.promises.writeFile(path.join(root, 'charts', 'copy-of-orders.yml'), 'contentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n');
|
|
101
|
+
await (0, slugUpdate_1.applyLocalChartSlugUpdate)(await (0, slugUpdate_1.planLocalChartSlugUpdate)(root, 'copy-of-orders', 'orders'));
|
|
102
|
+
const secondPlan = await (0, slugUpdate_1.planLocalChartSlugUpdate)(root, 'copy-of-orders', 'orders');
|
|
103
|
+
(0, vitest_1.expect)(secondPlan).toEqual({
|
|
104
|
+
fileUpdates: [],
|
|
105
|
+
fileMoves: [],
|
|
106
|
+
metadata: undefined,
|
|
107
|
+
referencesUpdated: 0,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
(0, vitest_1.test)('planning a dry run does not change local files or call the API', async () => {
|
|
111
|
+
const root = await createTemporaryContent();
|
|
112
|
+
const chartFile = path.join(root, 'charts', 'copy-of-orders.yml');
|
|
113
|
+
const source = 'contentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n';
|
|
114
|
+
await fs_1.promises.writeFile(chartFile, source);
|
|
115
|
+
const api = vitest_1.vi.spyOn(apiClient, 'lightdashApi');
|
|
116
|
+
const plan = await (0, slugUpdate_1.planLocalChartSlugUpdate)(root, 'copy-of-orders', 'orders');
|
|
117
|
+
(0, vitest_1.expect)((0, slugUpdate_1.getLocalSlugUpdateFileChanges)(plan, root)).toEqual([
|
|
118
|
+
{
|
|
119
|
+
source: path.join('charts', 'copy-of-orders.yml'),
|
|
120
|
+
target: path.join('charts', 'orders.yml'),
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
(0, vitest_1.expect)(api).not.toHaveBeenCalled();
|
|
124
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(chartFile, 'utf8')).toBe(source);
|
|
125
|
+
await (0, vitest_1.expect)(fs_1.promises.access(path.join(root, 'charts', 'orders.yml'))).rejects.toThrow();
|
|
126
|
+
});
|
|
127
|
+
(0, vitest_1.test)('rejects a local target collision before changing files', async () => {
|
|
128
|
+
const root = await createTemporaryContent();
|
|
129
|
+
const sourceFile = path.join(root, 'charts', 'copy-of-orders.yml');
|
|
130
|
+
await Promise.all([
|
|
131
|
+
fs_1.promises.writeFile(sourceFile, 'contentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n'),
|
|
132
|
+
fs_1.promises.writeFile(path.join(root, 'charts', 'orders.yml'), 'contentType: chart\nslug: another-chart\nname: Another\nmetricQuery: {}\n'),
|
|
133
|
+
]);
|
|
134
|
+
await (0, vitest_1.expect)((0, slugUpdate_1.planLocalChartSlugUpdate)(root, 'copy-of-orders', 'orders')).rejects.toThrow('already exists');
|
|
135
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(sourceFile, 'utf8')).toContain('slug: copy-of-orders');
|
|
136
|
+
});
|
|
137
|
+
(0, vitest_1.test)('rejects malformed target slugs before constructing file paths', async () => {
|
|
138
|
+
const root = await createTemporaryContent();
|
|
139
|
+
const chartFile = path.join(root, 'charts', 'copy-of-orders.yml');
|
|
140
|
+
const source = 'contentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n';
|
|
141
|
+
await fs_1.promises.writeFile(chartFile, source);
|
|
142
|
+
await (0, vitest_1.expect)((0, slugUpdate_1.planLocalChartSlugUpdate)(root, 'copy-of-orders', '../../orders')).rejects.toThrow('target slug must contain');
|
|
143
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(chartFile, 'utf8')).toBe(source);
|
|
144
|
+
});
|
|
145
|
+
(0, vitest_1.test)('does not change local files when the server rejects the rename', async () => {
|
|
146
|
+
const root = await createTemporaryContent();
|
|
147
|
+
const chartFile = path.join(root, 'charts', 'copy-of-orders.yml');
|
|
148
|
+
const source = 'contentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n';
|
|
149
|
+
await fs_1.promises.writeFile(chartFile, source);
|
|
150
|
+
vitest_1.vi.spyOn(apiClient, 'lightdashApi').mockRejectedValue(new common_1.LightdashError({
|
|
151
|
+
message: 'Slug already in use',
|
|
152
|
+
name: 'ConflictError',
|
|
153
|
+
statusCode: 409,
|
|
154
|
+
data: {},
|
|
155
|
+
}));
|
|
156
|
+
await (0, vitest_1.expect)((0, slugUpdate_1.executeChartSlugUpdate)('project-uuid', root, 'copy-of-orders', 'orders')).rejects.toThrow('Slug already in use');
|
|
157
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(chartFile, 'utf8')).toBe(source);
|
|
158
|
+
await (0, vitest_1.expect)(fs_1.promises.access(path.join(root, 'charts', 'orders.yml'))).rejects.toThrow();
|
|
159
|
+
(0, vitest_1.expect)((await fs_1.promises.readdir(path.join(root, 'charts'))).some((file) => file.includes('.slug-update-'))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
(0, vitest_1.test)('reserves the new slug on the server before changing local files', async () => {
|
|
162
|
+
const root = await createTemporaryContent();
|
|
163
|
+
const chartFile = path.join(root, 'charts', 'copy-of-orders.yml');
|
|
164
|
+
await fs_1.promises.writeFile(chartFile, 'contentType: chart\nslug: copy-of-orders\nname: Orders\nmetricQuery: {}\n');
|
|
165
|
+
vitest_1.vi.spyOn(apiClient, 'lightdashApi').mockImplementation(async () => {
|
|
166
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(chartFile, 'utf8')).toContain('slug: copy-of-orders');
|
|
167
|
+
await (0, vitest_1.expect)(fs_1.promises.access(path.join(root, 'charts', 'orders.yml'))).rejects.toThrow();
|
|
168
|
+
return undefined;
|
|
169
|
+
});
|
|
170
|
+
await (0, slugUpdate_1.executeChartSlugUpdate)('project-uuid', root, 'copy-of-orders', 'orders');
|
|
171
|
+
await (0, vitest_1.expect)(fs_1.promises.access(chartFile)).rejects.toThrow();
|
|
172
|
+
(0, vitest_1.expect)(await fs_1.promises.readFile(path.join(root, 'charts', 'orders.yml'), 'utf8')).toContain('slug: orders');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
(0, vitest_1.describe)('content slug rename API', () => {
|
|
176
|
+
(0, vitest_1.test)('calls the generic rename endpoint once', async () => {
|
|
177
|
+
const api = vitest_1.vi
|
|
178
|
+
.spyOn(apiClient, 'lightdashApi')
|
|
179
|
+
.mockResolvedValue(undefined);
|
|
180
|
+
await (0, slugUpdate_1.requestSlugUpdate)('project-uuid', {
|
|
181
|
+
resourceType: common_1.ContentType.CHART,
|
|
182
|
+
from: 'old-chart',
|
|
183
|
+
to: 'new-chart',
|
|
184
|
+
});
|
|
185
|
+
(0, vitest_1.expect)(api).toHaveBeenCalledExactlyOnceWith({
|
|
186
|
+
method: 'POST',
|
|
187
|
+
url: '/api/v1/projects/project-uuid/slugs/rename',
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
resourceType: common_1.ContentType.CHART,
|
|
190
|
+
from: 'old-chart',
|
|
191
|
+
to: 'new-chart',
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
(0, vitest_1.test)('explains when the connected server does not support slug-update', async () => {
|
|
196
|
+
vitest_1.vi.spyOn(apiClient, 'lightdashApi').mockRejectedValue(new common_1.LightdashError({
|
|
197
|
+
message: 'API endpoint not found',
|
|
198
|
+
name: 'NotFoundError',
|
|
199
|
+
statusCode: 404,
|
|
200
|
+
data: {},
|
|
201
|
+
}));
|
|
202
|
+
await (0, vitest_1.expect)((0, slugUpdate_1.requestSlugUpdate)('project-uuid', {
|
|
203
|
+
resourceType: common_1.ContentType.CHART,
|
|
204
|
+
from: 'old-chart',
|
|
205
|
+
to: 'new-chart',
|
|
206
|
+
})).rejects.toThrow('This Lightdash server does not support slug-update yet');
|
|
207
|
+
});
|
|
208
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,7 @@ const renameProject_1 = require("./handlers/renameProject");
|
|
|
34
34
|
const runChart_1 = require("./handlers/runChart");
|
|
35
35
|
const setProject_1 = require("./handlers/setProject");
|
|
36
36
|
const setWarehouse_1 = require("./handlers/setWarehouse");
|
|
37
|
+
const slugUpdate_1 = require("./handlers/slugUpdate");
|
|
37
38
|
const sql_1 = require("./handlers/sql");
|
|
38
39
|
const upgradeCheck_1 = require("./handlers/upgradeCheck");
|
|
39
40
|
const validate_2 = require("./handlers/validate");
|
|
@@ -583,6 +584,16 @@ commander_1.program
|
|
|
583
584
|
.option('--dry-run', 'Test the rename, no changes will be made', false)
|
|
584
585
|
.option('--list', 'List all charts and dashboards that are renamed', false)
|
|
585
586
|
.action(renameHandler_1.renameHandler);
|
|
587
|
+
commander_1.program
|
|
588
|
+
.command('slug-update')
|
|
589
|
+
.description('Rename a chart slug and update its local references')
|
|
590
|
+
.option('--verbose', 'show verbose output', false)
|
|
591
|
+
.option('-p, --project <project uuid>', 'specify a project UUID', parseProjectArgument, undefined)
|
|
592
|
+
.option('--path <path>', 'specify the local content-as-code path to update', undefined)
|
|
593
|
+
.option('--dry-run', 'preview the chart rename and local file changes without making changes', false)
|
|
594
|
+
.requiredOption('--from <slug>', 'current content slug')
|
|
595
|
+
.requiredOption('--to <slug>', 'new content slug')
|
|
596
|
+
.action(slugUpdate_1.slugUpdateHandler);
|
|
586
597
|
commander_1.program
|
|
587
598
|
.command('generate-exposures')
|
|
588
599
|
.description('[Experimental command] Generates a .yml file for Lightdash exposures')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.193.0",
|
|
4
4
|
"description": "Lightdash CLI tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"unique-names-generator": "4.7.1",
|
|
46
46
|
"uuid": "11.1.1",
|
|
47
47
|
"yaml": "2.8.3",
|
|
48
|
-
"@lightdash/common": "1.
|
|
49
|
-
"@lightdash/warehouses": "1.
|
|
48
|
+
"@lightdash/common": "1.193.0",
|
|
49
|
+
"@lightdash/warehouses": "1.193.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/inquirer": "8.2.4",
|