@lightdash/cli 0.3399.0 → 0.3400.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,603 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.upsertSpaces = exports.logUploadChanges = exports.isSpaceAsCodeUploadError = exports.createSpaceAsCodeUploadError = exports.downloadSpaces = exports.shouldFallBackToEmbeddedSpaces = exports.isSpaceAsCodeFetchError = exports.isSpaceAsCodeDownloadError = exports.createSpaceAsCodeDownloadError = exports.getSpaceNames = exports.readSpaceNames = exports.readSpaceFiles = exports.writeSpaceFiles = exports.getUniqueExistingSpaceFilePathsBySlug = exports.sortSpaceFilesParentFirst = exports.assertUniqueSpacePaths = exports.validateSpaceIdentity = exports.getFlatSpaceFileNames = void 0;
4
+ const tslib_1 = require("tslib");
5
+ /* eslint-disable no-await-in-loop */
6
+ /* eslint-disable no-param-reassign */
7
+ const common_1 = require("@lightdash/common");
8
+ const crypto_1 = require("crypto");
9
+ const fs_1 = require("fs");
10
+ const yaml = tslib_1.__importStar(require("js-yaml"));
11
+ const path = tslib_1.__importStar(require("path"));
12
+ const globalState_1 = tslib_1.__importDefault(require("../globalState"));
13
+ const styles = tslib_1.__importStar(require("../styles"));
14
+ const appsDownload_1 = require("./apps/appsDownload");
15
+ const contentAsCodePaths_1 = require("./contentAsCodePaths");
16
+ const apiClient_1 = require("./dbt/apiClient");
17
+ const SPACE_FILENAME_MAX_LENGTH = 200;
18
+ const SPACE_FILENAME_HASH_LENGTH = 8;
19
+ const isSpaceIdentity = (value) => typeof value === 'object' &&
20
+ value !== null &&
21
+ !Array.isArray(value) &&
22
+ 'contentType' in value &&
23
+ value.contentType === common_1.ContentAsCodeType.SPACE &&
24
+ 'slug' in value &&
25
+ typeof value.slug === 'string';
26
+ const listSpaceFilePaths = async (baseDir) => (await fs_1.promises.readdir(baseDir, {
27
+ recursive: true,
28
+ withFileTypes: true,
29
+ }))
30
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.space.yml'))
31
+ .map((entry) => path.join(entry.parentPath, entry.name))
32
+ .sort((left, right) => left.localeCompare(right));
33
+ const hasRootSpaceFile = async (baseDir) => {
34
+ try {
35
+ return (await fs_1.promises.readdir(baseDir, { withFileTypes: true })).some((entry) => entry.isFile() && entry.name.endsWith('.space.yml'));
36
+ }
37
+ catch (error) {
38
+ if (error.code === 'ENOENT')
39
+ return false;
40
+ throw error;
41
+ }
42
+ };
43
+ const getStableSpaceHash = (value, length = SPACE_FILENAME_HASH_LENGTH) => (0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, length);
44
+ const getSpaceFilenameBase = (space) => {
45
+ const normalizedName = space.spaceName
46
+ .normalize('NFKD')
47
+ .replace(/[\u0300-\u036f]/g, '');
48
+ const base = /[a-z0-9]/i.test(normalizedName)
49
+ ? (0, common_1.generateSlug)(normalizedName)
50
+ : `space-${getStableSpaceHash(space.slug)}`;
51
+ return base.slice(0, SPACE_FILENAME_MAX_LENGTH);
52
+ };
53
+ const getFlatSpaceFileNames = (spaces) => {
54
+ const bases = spaces.map(getSpaceFilenameBase);
55
+ const counts = bases.reduce((result, base) => result.set(base, (result.get(base) ?? 0) + 1), new Map());
56
+ return spaces.map((space, index) => {
57
+ const base = bases[index];
58
+ if ((counts.get(base) ?? 0) === 1)
59
+ return `${base}.space.yml`;
60
+ const suffix = `-${getStableSpaceHash(space.slug)}`;
61
+ return `${base.slice(0, SPACE_FILENAME_MAX_LENGTH - suffix.length)}${suffix}.space.yml`;
62
+ });
63
+ };
64
+ exports.getFlatSpaceFileNames = getFlatSpaceFileNames;
65
+ const assertKnownKeys = (value, allowedKeys, field, filePath) => {
66
+ const unknownKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
67
+ if (unknownKeys.length > 0) {
68
+ throw new common_1.ParameterError(`Invalid ${field} in space file "${filePath}": unknown ${unknownKeys.length === 1 ? 'property' : 'properties'} ${unknownKeys.map((key) => `"${key}"`).join(', ')}`);
69
+ }
70
+ };
71
+ const isSpaceMemberRole = (value) => Object.values(common_1.SpaceMemberRole).includes(value);
72
+ const validateSpaceIdentity = (parsed, filePath) => {
73
+ if (typeof parsed !== 'object' ||
74
+ parsed === null ||
75
+ Array.isArray(parsed)) {
76
+ throw new common_1.ParameterError(`Invalid space file "${filePath}": expected a YAML object`);
77
+ }
78
+ assertKnownKeys(parsed, ['contentType', 'version', 'spaceName', 'slug', 'access'], 'space document', filePath);
79
+ if (!('contentType' in parsed) ||
80
+ parsed.contentType !== common_1.ContentAsCodeType.SPACE) {
81
+ throw new common_1.ParameterError(`Invalid contentType in space file "${filePath}": expected "${common_1.ContentAsCodeType.SPACE}"`);
82
+ }
83
+ if (!('spaceName' in parsed) ||
84
+ typeof parsed.spaceName !== 'string' ||
85
+ parsed.spaceName.trim().length === 0) {
86
+ throw new common_1.ParameterError(`Invalid spaceName in space file "${filePath}": expected a non-empty string`);
87
+ }
88
+ if (!('slug' in parsed) ||
89
+ typeof parsed.slug !== 'string' ||
90
+ parsed.slug.trim().length === 0 ||
91
+ parsed.slug !== parsed.slug.trim() ||
92
+ !/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(parsed.slug) ||
93
+ (0, common_1.getContentAsCodePathFromLtreePath)((0, common_1.getLtreePathFromContentAsCodePath)(parsed.slug)) !== parsed.slug ||
94
+ parsed.slug
95
+ .split('/')
96
+ .some((part) => part.length === 0 || part === '.' || part === '..')) {
97
+ throw new common_1.ParameterError(`Invalid slug in space file "${filePath}": expected a canonical lowercase slash-separated path`);
98
+ }
99
+ if ('version' in parsed && parsed.version !== 1) {
100
+ throw new common_1.ParameterError(`Invalid version in space file "${filePath}": expected 1`);
101
+ }
102
+ if ('access' in parsed && parsed.access !== undefined) {
103
+ if (!('version' in parsed) || parsed.version !== 1) {
104
+ throw new common_1.ParameterError(`Invalid version in space file "${filePath}": version 1 is required when access is present`);
105
+ }
106
+ if (typeof parsed.access !== 'object' ||
107
+ parsed.access === null ||
108
+ Array.isArray(parsed.access)) {
109
+ throw new common_1.ParameterError(`Invalid access in space file "${filePath}": expected an object`);
110
+ }
111
+ const access = parsed.access;
112
+ assertKnownKeys(access, [
113
+ 'inheritParentPermissions',
114
+ 'projectMemberAccessRole',
115
+ 'users',
116
+ 'groups',
117
+ ], 'access', filePath);
118
+ if (typeof access.inheritParentPermissions !== 'boolean') {
119
+ throw new common_1.ParameterError(`Invalid access.inheritParentPermissions in space file "${filePath}": expected a boolean`);
120
+ }
121
+ if (access.projectMemberAccessRole !== null &&
122
+ !isSpaceMemberRole(access.projectMemberAccessRole)) {
123
+ throw new common_1.ParameterError(`Invalid access.projectMemberAccessRole in space file "${filePath}": expected viewer, editor, admin, or null`);
124
+ }
125
+ if (!Array.isArray(access.users) || !Array.isArray(access.groups)) {
126
+ throw new common_1.ParameterError(`Invalid access in space file "${filePath}": users and groups must be arrays`);
127
+ }
128
+ const userEmails = new Set();
129
+ const users = access.users.map((item, index) => {
130
+ if (typeof item !== 'object' ||
131
+ item === null ||
132
+ Array.isArray(item)) {
133
+ throw new common_1.ParameterError(`Invalid access.users[${index}] in space file "${filePath}": expected an object`);
134
+ }
135
+ const user = item;
136
+ assertKnownKeys(user, ['email', 'role'], `access.users[${index}]`, filePath);
137
+ if (typeof user.email !== 'string' ||
138
+ user.email.trim().length === 0 ||
139
+ !(0, common_1.validateEmail)(user.email.trim()) ||
140
+ !isSpaceMemberRole(user.role)) {
141
+ throw new common_1.ParameterError(`Invalid access.users[${index}] in space file "${filePath}": expected a valid email and a viewer, editor, or admin role`);
142
+ }
143
+ const email = user.email.trim().toLowerCase();
144
+ if (userEmails.has(email)) {
145
+ throw new common_1.ParameterError(`Duplicate user email "${email}" in space file "${filePath}"`);
146
+ }
147
+ userEmails.add(email);
148
+ return { email, role: user.role };
149
+ });
150
+ const groupNames = new Set();
151
+ const groups = access.groups.map((item, index) => {
152
+ if (typeof item !== 'object' ||
153
+ item === null ||
154
+ Array.isArray(item)) {
155
+ throw new common_1.ParameterError(`Invalid access.groups[${index}] in space file "${filePath}": expected an object`);
156
+ }
157
+ const group = item;
158
+ assertKnownKeys(group, ['name', 'role'], `access.groups[${index}]`, filePath);
159
+ if (typeof group.name !== 'string' ||
160
+ group.name.trim().length === 0 ||
161
+ !isSpaceMemberRole(group.role)) {
162
+ throw new common_1.ParameterError(`Invalid access.groups[${index}] in space file "${filePath}": expected a non-empty name and a viewer, editor, or admin role`);
163
+ }
164
+ if (groupNames.has(group.name)) {
165
+ throw new common_1.ParameterError(`Duplicate group name "${group.name}" in space file "${filePath}"`);
166
+ }
167
+ groupNames.add(group.name);
168
+ return { name: group.name, role: group.role };
169
+ });
170
+ return {
171
+ ...parsed,
172
+ access: {
173
+ inheritParentPermissions: access.inheritParentPermissions,
174
+ projectMemberAccessRole: access.projectMemberAccessRole,
175
+ users,
176
+ groups,
177
+ },
178
+ };
179
+ }
180
+ return parsed;
181
+ };
182
+ exports.validateSpaceIdentity = validateSpaceIdentity;
183
+ const assertUniqueSpacePaths = (files) => {
184
+ const exactPaths = new Map();
185
+ const normalizedPaths = new Map();
186
+ files.forEach(({ filePath, space }) => {
187
+ const existingExact = exactPaths.get(space.slug);
188
+ if (existingExact) {
189
+ throw new common_1.ParameterError(`Duplicate space slug "${space.slug}" in "${existingExact}" and "${filePath}"`);
190
+ }
191
+ exactPaths.set(space.slug, filePath);
192
+ const normalizedPath = (0, common_1.getLtreePathFromContentAsCodePath)(space.slug);
193
+ const existingNormalized = normalizedPaths.get(normalizedPath);
194
+ if (existingNormalized) {
195
+ throw new common_1.ParameterError(`Space paths "${existingNormalized.slug}" (${existingNormalized.filePath}) and "${space.slug}" (${filePath}) resolve to the same normalized path "${normalizedPath}"`);
196
+ }
197
+ normalizedPaths.set(normalizedPath, { slug: space.slug, filePath });
198
+ });
199
+ };
200
+ exports.assertUniqueSpacePaths = assertUniqueSpacePaths;
201
+ const sortSpaceFilesParentFirst = (files) => [...files].sort((left, right) => {
202
+ const depthDifference = left.space.slug.split('/').length -
203
+ right.space.slug.split('/').length;
204
+ return (depthDifference || left.space.slug.localeCompare(right.space.slug));
205
+ });
206
+ exports.sortSpaceFilesParentFirst = sortSpaceFilesParentFirst;
207
+ const getExistingSpaceFileNames = async (outputDir) => {
208
+ let entries;
209
+ try {
210
+ entries = await fs_1.promises.readdir(outputDir, { withFileTypes: true });
211
+ }
212
+ catch (error) {
213
+ if (error.code === 'ENOENT') {
214
+ return new Map();
215
+ }
216
+ throw error;
217
+ }
218
+ const filesBySlug = new Map();
219
+ for (const entry of entries.filter(({ name }) => name.endsWith('.space.yml'))) {
220
+ let slug = null;
221
+ if (entry.isFile()) {
222
+ try {
223
+ const parsed = yaml.load(await fs_1.promises.readFile(path.join(outputDir, entry.name), 'utf-8'));
224
+ if (isSpaceIdentity(parsed)) {
225
+ slug = parsed.slug;
226
+ }
227
+ }
228
+ catch {
229
+ // An unreadable existing file still reserves its filename.
230
+ }
231
+ }
232
+ const ownerKey = slug ?? `\0${entry.name}`;
233
+ filesBySlug.set(ownerKey, [
234
+ ...(filesBySlug.get(ownerKey) ?? []),
235
+ entry.name,
236
+ ]);
237
+ }
238
+ return filesBySlug;
239
+ };
240
+ const getExistingSpaceFilePathsBySlug = async (baseDir) => {
241
+ let filePaths;
242
+ try {
243
+ filePaths = await listSpaceFilePaths(baseDir);
244
+ }
245
+ catch (error) {
246
+ if (error.code === 'ENOENT') {
247
+ return new Map();
248
+ }
249
+ throw error;
250
+ }
251
+ const filesBySlug = new Map();
252
+ for (const filePath of filePaths) {
253
+ try {
254
+ const parsed = yaml.load(await fs_1.promises.readFile(filePath, 'utf-8'));
255
+ if (isSpaceIdentity(parsed)) {
256
+ filesBySlug.set(parsed.slug, [
257
+ ...(filesBySlug.get(parsed.slug) ?? []),
258
+ filePath,
259
+ ]);
260
+ }
261
+ }
262
+ catch {
263
+ // Filename allocation still reserves unreadable files in their directory.
264
+ }
265
+ }
266
+ return filesBySlug;
267
+ };
268
+ const getUniqueExistingSpaceFilePathsBySlug = async (baseDir) => {
269
+ const filesBySlug = await getExistingSpaceFilePathsBySlug(baseDir);
270
+ for (const [slug, filePaths] of filesBySlug) {
271
+ if (filePaths.length > 1) {
272
+ throw new common_1.ParameterError(`Multiple existing space files use slug "${slug}": ${filePaths
273
+ .map((filePath) => path.relative(baseDir, filePath))
274
+ .join(', ')}`);
275
+ }
276
+ }
277
+ return filesBySlug;
278
+ };
279
+ exports.getUniqueExistingSpaceFilePathsBySlug = getUniqueExistingSpaceFilePathsBySlug;
280
+ const getCollisionSafeSpaceFileName = (space, usedFileNames) => {
281
+ const base = getSpaceFilenameBase(space);
282
+ const getCandidate = (suffix) => `${base.slice(0, SPACE_FILENAME_MAX_LENGTH - suffix.length)}${suffix}.space.yml`;
283
+ for (const hashLength of [8, 12, 16, 24, 32, 64]) {
284
+ const candidate = getCandidate(`-${getStableSpaceHash(space.slug, hashLength)}`);
285
+ if (!usedFileNames.has(candidate.toLowerCase()))
286
+ return candidate;
287
+ }
288
+ for (let index = 1; index <= 100; index += 1) {
289
+ const candidate = getCandidate(`-${getStableSpaceHash(space.slug, 64)}-${index}`);
290
+ if (!usedFileNames.has(candidate.toLowerCase()))
291
+ return candidate;
292
+ }
293
+ throw new common_1.ParameterError(`Could not allocate a safe filename for space "${space.slug}"`);
294
+ };
295
+ const allocateSpaceFileNames = async (spaces, outputDir, preferredFileNames) => {
296
+ const existingFilesBySlug = await getExistingSpaceFileNames(outputDir);
297
+ const usedFileNames = new Set([...existingFilesBySlug.values()]
298
+ .flat()
299
+ .map((fileName) => fileName.toLowerCase()));
300
+ return spaces.map((space, index) => {
301
+ const existingFileName = existingFilesBySlug.get(space.slug)?.[0];
302
+ if (existingFileName !== undefined)
303
+ return existingFileName;
304
+ const preferredFileName = preferredFileNames[index];
305
+ const fileName = !usedFileNames.has(preferredFileName.toLowerCase())
306
+ ? preferredFileName
307
+ : getCollisionSafeSpaceFileName(space, usedFileNames);
308
+ usedFileNames.add(fileName.toLowerCase());
309
+ return fileName;
310
+ });
311
+ };
312
+ /** Writes first-class space YAML files returned by GET /spaces/code. */
313
+ const writeSpaceFiles = async (spaces, projectName, customPath, folderScheme = 'flat', flatSpaceLayout = 'folder', preserveExistingPolicy = false) => {
314
+ const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
315
+ if (spaces.length === 0)
316
+ return;
317
+ const existingFilesBySlug = preserveExistingPolicy
318
+ ? await getExistingSpaceFilePathsBySlug(baseDir)
319
+ : await (0, exports.getUniqueExistingSpaceFilePathsBySlug)(baseDir);
320
+ if (preserveExistingPolicy) {
321
+ spaces.forEach(({ slug }) => {
322
+ const existingPaths = existingFilesBySlug.get(slug) ?? [];
323
+ if (existingPaths.length > 1) {
324
+ throw new common_1.ParameterError(`Multiple existing space files use slug "${slug}": ${existingPaths
325
+ .map((filePath) => path.relative(baseDir, filePath))
326
+ .join(', ')}`);
327
+ }
328
+ });
329
+ }
330
+ const files = spaces.map((space) => ({
331
+ filePath: `downloaded space "${space.slug}"`,
332
+ space,
333
+ }));
334
+ (0, exports.assertUniqueSpacePaths)(files);
335
+ const useRootFlatLayout = flatSpaceLayout === 'root' ||
336
+ (folderScheme === 'flat' && (await hasRootSpaceFile(baseDir)));
337
+ const plannedPathsBySlug = new Map();
338
+ const newSpacesByDirectory = new Map();
339
+ for (const space of spaces) {
340
+ const existingFilePath = existingFilesBySlug.get(space.slug)?.[0];
341
+ if (existingFilePath !== undefined) {
342
+ plannedPathsBySlug.set(space.slug, existingFilePath);
343
+ }
344
+ else {
345
+ let outputDir;
346
+ if (folderScheme === 'nested') {
347
+ outputDir = path.join(baseDir, projectName, space.slug);
348
+ }
349
+ else if (useRootFlatLayout) {
350
+ outputDir = baseDir;
351
+ }
352
+ else {
353
+ outputDir = path.join(baseDir, 'spaces');
354
+ }
355
+ newSpacesByDirectory.set(outputDir, [
356
+ ...(newSpacesByDirectory.get(outputDir) ?? []),
357
+ space,
358
+ ]);
359
+ }
360
+ }
361
+ for (const [outputDir, newSpaces] of [...newSpacesByDirectory].sort(([left], [right]) => left.localeCompare(right))) {
362
+ const fileNames = await allocateSpaceFileNames(newSpaces, outputDir, (0, exports.getFlatSpaceFileNames)(newSpaces));
363
+ newSpaces.forEach((space, index) => {
364
+ plannedPathsBySlug.set(space.slug, path.join(outputDir, fileNames[index]));
365
+ });
366
+ }
367
+ const plannedPaths = [...plannedPathsBySlug.values()].map((filePath) => filePath.toLowerCase());
368
+ if (new Set(plannedPaths).size !== plannedPaths.length) {
369
+ throw new common_1.ParameterError('Multiple spaces would be written to the same file path');
370
+ }
371
+ for (const space of spaces) {
372
+ const filePath = plannedPathsBySlug.get(space.slug);
373
+ if (filePath === undefined) {
374
+ throw new common_1.ParameterError(`Could not determine a filename for space "${space.slug}"`);
375
+ }
376
+ let spaceToWrite = space;
377
+ if (preserveExistingPolicy &&
378
+ space.access === undefined &&
379
+ existingFilesBySlug.has(space.slug)) {
380
+ const existing = yaml.load(await fs_1.promises.readFile(filePath, 'utf-8'));
381
+ if (typeof existing !== 'object' ||
382
+ existing === null ||
383
+ Array.isArray(existing) ||
384
+ !('contentType' in existing) ||
385
+ existing.contentType !== common_1.ContentAsCodeType.SPACE ||
386
+ !('slug' in existing) ||
387
+ existing.slug !== space.slug) {
388
+ throw new common_1.ParameterError(`Existing space file "${filePath}" changed while downloading`);
389
+ }
390
+ spaceToWrite = {
391
+ ...space,
392
+ ...('version' in existing ? { version: existing.version } : {}),
393
+ ...('access' in existing ? { access: existing.access } : {}),
394
+ };
395
+ }
396
+ await fs_1.promises.mkdir(path.dirname(filePath), { recursive: true });
397
+ await fs_1.promises.writeFile(filePath, yaml.dump(spaceToWrite, { quotingType: '"', sortKeys: true }));
398
+ globalState_1.default.debug(`Wrote space file: ${filePath}`);
399
+ }
400
+ };
401
+ exports.writeSpaceFiles = writeSpaceFiles;
402
+ const readSpaceFiles = async (customPath) => {
403
+ const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
404
+ try {
405
+ const files = [];
406
+ for (const filePath of await listSpaceFilePaths(baseDir)) {
407
+ let parsed;
408
+ try {
409
+ parsed = yaml.load(await fs_1.promises.readFile(filePath, 'utf-8'));
410
+ }
411
+ catch (error) {
412
+ throw new common_1.ParameterError(`Invalid YAML in space file "${filePath}": ${(0, common_1.getErrorMessage)(error)}`);
413
+ }
414
+ files.push({
415
+ filePath,
416
+ space: (0, exports.validateSpaceIdentity)(parsed, filePath),
417
+ });
418
+ }
419
+ (0, exports.assertUniqueSpacePaths)(files);
420
+ return (0, exports.sortSpaceFilesParentFirst)(files);
421
+ }
422
+ catch (error) {
423
+ if (error.code === 'ENOENT')
424
+ return [];
425
+ throw error;
426
+ }
427
+ };
428
+ exports.readSpaceFiles = readSpaceFiles;
429
+ /** Reads legacy identity fields without validating unrelated access policy. */
430
+ const readSpaceNames = async (customPath) => {
431
+ const baseDir = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
432
+ const spaceNames = [];
433
+ try {
434
+ for (const filePath of await listSpaceFilePaths(baseDir)) {
435
+ try {
436
+ const parsed = yaml.load(await fs_1.promises.readFile(filePath, 'utf-8'));
437
+ if (isSpaceIdentity(parsed) &&
438
+ 'spaceName' in parsed &&
439
+ typeof parsed.spaceName === 'string') {
440
+ spaceNames.push([parsed.slug, parsed.spaceName]);
441
+ }
442
+ }
443
+ catch (error) {
444
+ globalState_1.default.debug(`Skipping space file ${path.basename(filePath)}: ${(0, common_1.getErrorMessage)(error)}`);
445
+ }
446
+ }
447
+ }
448
+ catch (error) {
449
+ if (error.code !== 'ENOENT') {
450
+ globalState_1.default.debug(`Skipping space names in ${baseDir}: ${(0, common_1.getErrorMessage)(error)}`);
451
+ }
452
+ }
453
+ return Object.fromEntries(spaceNames);
454
+ };
455
+ exports.readSpaceNames = readSpaceNames;
456
+ const getSpaceNames = (files) => Object.fromEntries(files.map(({ space }) => [space.slug, space.spaceName]));
457
+ exports.getSpaceNames = getSpaceNames;
458
+ const getSpacePromoteAction = (action) => {
459
+ switch (action) {
460
+ case common_1.SpaceAsCodeAction.CREATE:
461
+ return 'created';
462
+ case common_1.SpaceAsCodeAction.UPDATE:
463
+ return 'updated';
464
+ case common_1.SpaceAsCodeAction.NO_CHANGES:
465
+ return 'unchanged';
466
+ default:
467
+ return (0, common_1.assertUnreachable)(action, `Unknown space promotion action: ${action}`);
468
+ }
469
+ };
470
+ const SPACE_AS_CODE_DOWNLOAD_ERROR_NAME = 'SpaceAsCodeDownloadError';
471
+ const createSpaceAsCodeDownloadError = (message) => {
472
+ const error = new common_1.ParameterError(message);
473
+ error.name = SPACE_AS_CODE_DOWNLOAD_ERROR_NAME;
474
+ return error;
475
+ };
476
+ exports.createSpaceAsCodeDownloadError = createSpaceAsCodeDownloadError;
477
+ const isSpaceAsCodeDownloadError = (error) => error instanceof Error && error.name === SPACE_AS_CODE_DOWNLOAD_ERROR_NAME;
478
+ exports.isSpaceAsCodeDownloadError = isSpaceAsCodeDownloadError;
479
+ const SPACE_AS_CODE_FETCH_ERROR_NAME = 'SpaceAsCodeFetchError';
480
+ const createSpaceAsCodeFetchError = (message) => {
481
+ const error = new common_1.ParameterError(message);
482
+ error.name = SPACE_AS_CODE_FETCH_ERROR_NAME;
483
+ return error;
484
+ };
485
+ const isSpaceAsCodeFetchError = (error) => error instanceof Error && error.name === SPACE_AS_CODE_FETCH_ERROR_NAME;
486
+ exports.isSpaceAsCodeFetchError = isSpaceAsCodeFetchError;
487
+ const shouldFallBackToEmbeddedSpaces = (error, spacesOnly) => (0, exports.isSpaceAsCodeFetchError)(error) && spacesOnly !== true;
488
+ exports.shouldFallBackToEmbeddedSpaces = shouldFallBackToEmbeddedSpaces;
489
+ const downloadSpaces = async (projectId, projectName, customPath, nested = false, rootSpaces = false) => {
490
+ try {
491
+ await (0, exports.getUniqueExistingSpaceFilePathsBySlug)((0, contentAsCodePaths_1.getDownloadFolder)(customPath));
492
+ let results;
493
+ try {
494
+ results = await (0, apiClient_1.lightdashApi)({
495
+ method: 'GET',
496
+ url: `/api/v1/projects/${projectId}/spaces/code`,
497
+ body: undefined,
498
+ });
499
+ }
500
+ catch (error) {
501
+ throw createSpaceAsCodeFetchError((0, common_1.getErrorMessage)(error));
502
+ }
503
+ const downloadedWithoutAccessSlugs = new Set(results.spaces
504
+ .filter((space) => space.access === undefined)
505
+ .map((space) => space.slug));
506
+ results.skipped.forEach((skipped) => globalState_1.default.log(styles.warning(downloadedWithoutAccessSlugs.has(skipped.slug)
507
+ ? `Downloaded space "${skipped.slug}" without access: ${skipped.reason}`
508
+ : `Skipped space "${skipped.slug}": ${skipped.reason}`)));
509
+ const spaces = (0, exports.sortSpaceFilesParentFirst)(results.spaces.map((space) => ({
510
+ filePath: `downloaded space "${space.slug}"`,
511
+ space,
512
+ }))).map(({ space }) => space);
513
+ await (0, exports.writeSpaceFiles)(spaces, projectName, customPath, nested ? 'nested' : 'flat', rootSpaces ? 'root' : 'folder');
514
+ return spaces.length;
515
+ }
516
+ catch (error) {
517
+ if ((0, exports.isSpaceAsCodeFetchError)(error))
518
+ throw error;
519
+ throw (0, exports.createSpaceAsCodeDownloadError)((0, common_1.getErrorMessage)(error));
520
+ }
521
+ };
522
+ exports.downloadSpaces = downloadSpaces;
523
+ const SPACE_AS_CODE_UPLOAD_ERROR_NAME = 'SpaceAsCodeUploadError';
524
+ const createSpaceAsCodeUploadError = (message) => {
525
+ const error = new common_1.ParameterError(message);
526
+ error.name = SPACE_AS_CODE_UPLOAD_ERROR_NAME;
527
+ return error;
528
+ };
529
+ exports.createSpaceAsCodeUploadError = createSpaceAsCodeUploadError;
530
+ const isSpaceAsCodeUploadError = (error) => error instanceof Error && error.name === SPACE_AS_CODE_UPLOAD_ERROR_NAME;
531
+ exports.isSpaceAsCodeUploadError = isSpaceAsCodeUploadError;
532
+ const logUploadChanges = (changes) => {
533
+ Object.entries(changes).forEach(([key, value]) => {
534
+ console.info(`Total ${key}: ${value} `);
535
+ });
536
+ if ((0, appsDownload_1.shouldWarnAllSkipped)(changes)) {
537
+ console.warn(styles.warning(`\nAll content was skipped (no local changes detected). Use --force to upload all content, e.g. when uploading to a new project.`));
538
+ }
539
+ };
540
+ exports.logUploadChanges = logUploadChanges;
541
+ const upsertSpaces = async (projectId, files, changes, skipSpaceCreate, publicSpaceCreate) => {
542
+ if (files.length === 0)
543
+ return changes;
544
+ const failedPaths = new Set();
545
+ const skippedPaths = new Set();
546
+ for (const { filePath, space } of (0, exports.sortSpaceFilesParentFirst)(files)) {
547
+ const failedParent = [...failedPaths].find((failedPath) => space.slug.startsWith(`${failedPath}/`));
548
+ const skippedParent = [...skippedPaths].find((skippedPath) => space.slug.startsWith(`${skippedPath}/`));
549
+ if (failedParent !== undefined) {
550
+ changes['spaces dependency skipped'] =
551
+ (changes['spaces dependency skipped'] ?? 0) + 1;
552
+ failedPaths.add(space.slug);
553
+ globalState_1.default.log(styles.warning(`Skipped space "${space.slug}" (${filePath}) because parent "${failedParent}" failed`));
554
+ }
555
+ else if (skippedParent !== undefined) {
556
+ changes['spaces skipped'] = (changes['spaces skipped'] ?? 0) + 1;
557
+ skippedPaths.add(space.slug);
558
+ globalState_1.default.log(styles.warning(`Skipped space "${space.slug}" (${filePath}) because parent "${skippedParent}" does not exist and --skip-space-create is true`));
559
+ }
560
+ else {
561
+ try {
562
+ const params = new URLSearchParams({
563
+ skipSpaceCreate: String(skipSpaceCreate),
564
+ publicSpaceCreate: String(publicSpaceCreate && space.access === undefined),
565
+ });
566
+ const result = await (0, apiClient_1.lightdashApi)({
567
+ method: 'POST',
568
+ url: `/api/v1/projects/${projectId}/spaces/code?${params.toString()}`,
569
+ body: JSON.stringify(space),
570
+ });
571
+ const action = getSpacePromoteAction(result.action);
572
+ const key = `spaces ${action}`;
573
+ changes[key] = (changes[key] ?? 0) + 1;
574
+ result.warnings?.forEach((warning) => globalState_1.default.log(styles.warning(`Space "${space.slug}" (${filePath}): ${warning}`)));
575
+ globalState_1.default.debug(`Space "${space.slug}" (${filePath}): ${result.action}`);
576
+ }
577
+ catch (error) {
578
+ if (skipSpaceCreate &&
579
+ error instanceof common_1.LightdashError &&
580
+ error.name === 'NotFoundError') {
581
+ skippedPaths.add(space.slug);
582
+ changes['spaces skipped'] =
583
+ (changes['spaces skipped'] ?? 0) + 1;
584
+ globalState_1.default.log(styles.warning(`Skipped space "${space.slug}" (${filePath}) because it does not exist and --skip-space-create is true`));
585
+ }
586
+ else {
587
+ failedPaths.add(space.slug);
588
+ changes['spaces with errors'] =
589
+ (changes['spaces with errors'] ?? 0) + 1;
590
+ globalState_1.default.log(styles.error(`Error upserting space "${space.slug}" (${filePath}): ${(0, common_1.getErrorMessage)(error)}`));
591
+ }
592
+ }
593
+ }
594
+ }
595
+ const failed = changes['spaces with errors'] ?? 0;
596
+ const dependencySkipped = changes['spaces dependency skipped'] ?? 0;
597
+ if (failed > 0 || dependencySkipped > 0) {
598
+ (0, exports.logUploadChanges)(changes);
599
+ throw (0, exports.createSpaceAsCodeUploadError)(`${failed} space definition(s) failed and ${dependencySkipped} dependent definition(s) were skipped; content upload was not started`);
600
+ }
601
+ return changes;
602
+ };
603
+ exports.upsertSpaces = upsertSpaces;
package/dist/index.js CHANGED
@@ -353,8 +353,10 @@ const downloadCommand = commander_1.program
353
353
  .option('-l, --language-map', 'generate a language maps for the downloaded charts and dashboards', false)
354
354
  .option('-p, --path <path>', 'specify a custom path to download content', undefined)
355
355
  .option('--nested', 'organize downloads in nested folders by project and space (default: flat structure)', false)
356
+ .option('--root-spaces', 'write new flat space definitions at the content root instead of spaces/ (legacy layout)', false)
356
357
  .option('--project <project uuid>', 'specify a project UUID to download', parseProjectArgument, undefined)
357
- .option('--skip-spaces', 'skip writing space metadata files during download', false)
358
+ .option('--skip-spaces', 'skip downloading space definitions and access', false)
359
+ .option('--spaces-only', 'download only space definitions and access', false)
358
360
  .option('--skip-charts', 'skip downloading charts', false)
359
361
  .option('--skip-dashboards', 'skip downloading dashboards', false)
360
362
  .option('--include-alerts', 'include all alerts in the download', false)
@@ -384,6 +386,8 @@ const uploadCommand = commander_1.program
384
386
  .option('-p, --path <path>', 'specify a custom path to upload content from', undefined)
385
387
  .option('--project <project uuid>', 'specify a project UUID to upload', parseProjectArgument, undefined)
386
388
  .option('--skip-space-create', 'Skip space creation if it does not exist', false)
389
+ .option('--skip-spaces', 'skip uploading space definitions and access', false)
390
+ .option('--spaces-only', 'upload only space definitions and access', false)
387
391
  .option('--public', 'Create new spaces as public instead of private', false)
388
392
  .option('--skip-agents', 'skip uploading AI agents', false)
389
393
  .option('--skip-alerts', 'skip uploading alerts', false)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "0.3399.0",
3
+ "version": "0.3400.0",
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.3399.0",
45
- "@lightdash/warehouses": "0.3399.0"
44
+ "@lightdash/common": "0.3400.0",
45
+ "@lightdash/warehouses": "0.3400.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/inquirer": "8.2.4",