@lightdash/cli 1.49.0 → 1.50.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/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/handlers/apps/createApp.d.ts +21 -0
- package/dist/handlers/apps/createApp.d.ts.map +1 -0
- package/dist/handlers/apps/createApp.js +261 -0
- package/dist/handlers/apps/createApp.test.d.ts +2 -0
- package/dist/handlers/apps/createApp.test.d.ts.map +1 -0
- package/dist/handlers/apps/createApp.test.js +264 -0
- package/dist/handlers/apps/scaffolding.d.ts +1 -0
- package/dist/handlers/apps/scaffolding.d.ts.map +1 -1
- package/dist/handlers/apps/scaffolding.js +6 -1
- package/dist/handlers/apps/scaffolding.test.js +22 -5
- package/dist/index.js +15 -0
- package/dist/vendor/authoring/AGENTS.md.tmpl +2 -2
- package/dist/vendor/authoring/README.md.tmpl +3 -3
- package/dist/vendor/authoring/developing-data-apps-locally/SKILL.md +5 -5
- package/package.json +3 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type DataAppManifest } from '@lightdash/common';
|
|
2
|
+
export type CreateAppHandlerOptions = {
|
|
3
|
+
assumeYes?: boolean;
|
|
4
|
+
description?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
project?: string;
|
|
7
|
+
slug?: string;
|
|
8
|
+
verbose: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare const SHADCN_VERSION = "2.3.0";
|
|
11
|
+
export declare const SHADCN_COMPONENTS: readonly ['button', 'badge', 'card', 'table', 'dialog', 'tabs', 'select', 'input', 'label', 'popover', 'tooltip', 'separator', 'skeleton', 'dropdown-menu', 'sheet', 'scroll-area', 'switch', 'checkbox', 'avatar', 'alert', 'progress', 'resizable'];
|
|
12
|
+
export declare const resolveLocalAppSlug: (name: string, explicitSlug?: string) => string;
|
|
13
|
+
export declare const buildLocalAppManifest: (args: {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
projectUuid: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
now: Date;
|
|
19
|
+
}) => DataAppManifest;
|
|
20
|
+
export declare const createAppHandler: (name: string, options: CreateAppHandlerOptions) => Promise<void>;
|
|
21
|
+
//# sourceMappingURL=createApp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createApp.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/createApp.ts"],"names":[],"mappings":"AAAA,OAAO,EAOH,KAAK,eAAe,EACvB,MAAM,mBAAmB,CAAC;AAuB3B,MAAM,MAAM,uBAAuB,GAAG;IAClC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,cAAc,UAAU,CAAC;AAEtC,eAAO,MAAM,iBAAiB,YAC1B,QAAQ,EACR,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,OAAO,EACP,OAAO,EACP,SAAS,EACT,SAAS,EACT,WAAW,EACX,UAAU,EACV,eAAe,EACf,OAAO,EACP,aAAa,EACb,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,OAAO,EACP,UAAU,EACV,WAAW,CACL,CAAC;AAEX,eAAO,MAAM,mBAAmB,SACtB,MAAM,iBACG,MAAM,KACtB,MAWF,CAAC;AAEF,eAAO,MAAM,qBAAqB,SAAU;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,IAAI,CAAC;CACb,KAAG,eAUF,CAAC;AAiKH,eAAO,MAAM,gBAAgB,SACnB,MAAM,WACH,uBAAuB,KACjC,OAAO,CAAC,IAAI,CAwGd,CAAC"}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAppHandler = exports.buildLocalAppManifest = exports.resolveLocalAppSlug = exports.SHADCN_COMPONENTS = exports.SHADCN_VERSION = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@lightdash/common");
|
|
6
|
+
const execa_1 = tslib_1.__importDefault(require("execa"));
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
|
|
9
|
+
const path = tslib_1.__importStar(require("path"));
|
|
10
|
+
const analytics_1 = require("../../analytics/analytics");
|
|
11
|
+
const config_1 = require("../../config");
|
|
12
|
+
const env_1 = require("../../env");
|
|
13
|
+
const globalState_1 = tslib_1.__importDefault(require("../../globalState"));
|
|
14
|
+
const styles = tslib_1.__importStar(require("../../styles"));
|
|
15
|
+
const contentAsCodePaths_1 = require("../contentAsCodePaths");
|
|
16
|
+
const apiClient_1 = require("../dbt/apiClient");
|
|
17
|
+
const selectProject_1 = require("../selectProject");
|
|
18
|
+
const appCodeFiles_1 = require("./appCodeFiles");
|
|
19
|
+
const scaffolding_1 = require("./scaffolding");
|
|
20
|
+
exports.SHADCN_VERSION = '2.3.0';
|
|
21
|
+
exports.SHADCN_COMPONENTS = [
|
|
22
|
+
'button',
|
|
23
|
+
'badge',
|
|
24
|
+
'card',
|
|
25
|
+
'table',
|
|
26
|
+
'dialog',
|
|
27
|
+
'tabs',
|
|
28
|
+
'select',
|
|
29
|
+
'input',
|
|
30
|
+
'label',
|
|
31
|
+
'popover',
|
|
32
|
+
'tooltip',
|
|
33
|
+
'separator',
|
|
34
|
+
'skeleton',
|
|
35
|
+
'dropdown-menu',
|
|
36
|
+
'sheet',
|
|
37
|
+
'scroll-area',
|
|
38
|
+
'switch',
|
|
39
|
+
'checkbox',
|
|
40
|
+
'avatar',
|
|
41
|
+
'alert',
|
|
42
|
+
'progress',
|
|
43
|
+
'resizable',
|
|
44
|
+
];
|
|
45
|
+
const resolveLocalAppSlug = (name, explicitSlug) => {
|
|
46
|
+
if (name.trim().length === 0) {
|
|
47
|
+
throw new common_1.ParameterError('Data app name cannot be empty.');
|
|
48
|
+
}
|
|
49
|
+
const slug = explicitSlug ?? (0, common_1.generateSlug)(name);
|
|
50
|
+
if (!(0, common_1.isValidDataAppSlug)(slug)) {
|
|
51
|
+
throw new common_1.ParameterError(`Invalid data app slug "${slug}". Slugs must start with a lowercase letter or digit and contain only lowercase letters, digits, and hyphens, up to 255 characters.`);
|
|
52
|
+
}
|
|
53
|
+
return slug;
|
|
54
|
+
};
|
|
55
|
+
exports.resolveLocalAppSlug = resolveLocalAppSlug;
|
|
56
|
+
const buildLocalAppManifest = (args) => ({
|
|
57
|
+
codeVersion: 1,
|
|
58
|
+
projectUuid: args.projectUuid,
|
|
59
|
+
slug: args.slug,
|
|
60
|
+
version: 1,
|
|
61
|
+
name: args.name,
|
|
62
|
+
description: args.description,
|
|
63
|
+
template: null,
|
|
64
|
+
downloadedAt: args.now.toISOString(),
|
|
65
|
+
scaffoldingVersion: env_1.CLI_VERSION,
|
|
66
|
+
});
|
|
67
|
+
exports.buildLocalAppManifest = buildLocalAppManifest;
|
|
68
|
+
const assertAppDirectoryAvailable = async (appDir) => {
|
|
69
|
+
try {
|
|
70
|
+
await fs_1.promises.access(appDir);
|
|
71
|
+
throw new common_1.ParameterError(`Cannot create data app: ${appDir} already exists. Choose another slug or download the existing app.`);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === 'ENOENT') {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const parseDirectPackages = (staticFiles) => {
|
|
81
|
+
const packageJson = staticFiles.find((file) => file.path === 'package.json');
|
|
82
|
+
if (!packageJson) {
|
|
83
|
+
throw new Error('Data app template is missing package.json.');
|
|
84
|
+
}
|
|
85
|
+
const parsed = JSON.parse(Buffer.from(packageJson.contentBase64, 'base64').toString('utf8'));
|
|
86
|
+
return Object.entries({
|
|
87
|
+
...parsed.dependencies,
|
|
88
|
+
...parsed.devDependencies,
|
|
89
|
+
})
|
|
90
|
+
.map(([packageName, version]) => `${packageName}@${version}`)
|
|
91
|
+
.sort();
|
|
92
|
+
};
|
|
93
|
+
const assertNpmAvailable = async () => {
|
|
94
|
+
try {
|
|
95
|
+
await (0, execa_1.default)('npm', ['--version']);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new common_1.ParameterError('npm is required to create a data app because Lightdash generates its shadcn UI locally. Install Node.js (which includes npm), then run this command again.');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const confirmLocalPackageTooling = async (assumeYes) => {
|
|
102
|
+
globalState_1.default.log(styles.warning([
|
|
103
|
+
'⚠ Local package installation',
|
|
104
|
+
`Creating a data app downloads third-party packages through npm into the new app folder and runs shadcn@${exports.SHADCN_VERSION} to generate UI files.`,
|
|
105
|
+
'Dependency lifecycle scripts are disabled, but npm and shadcn will access the network and write files on this machine.',
|
|
106
|
+
'Only continue if you trust these package sources and are comfortable running this tooling locally.',
|
|
107
|
+
].join('\n')));
|
|
108
|
+
if (assumeYes)
|
|
109
|
+
return;
|
|
110
|
+
if (globalState_1.default.isNonInteractive()) {
|
|
111
|
+
throw new common_1.ParameterError('Creating a data app requires approval to download and install npm packages. Rerun with --assume-yes to approve in non-interactive mode.');
|
|
112
|
+
}
|
|
113
|
+
const { confirmed } = await inquirer_1.default.prompt([
|
|
114
|
+
{
|
|
115
|
+
type: 'confirm',
|
|
116
|
+
name: 'confirmed',
|
|
117
|
+
message: 'Continue and review the packages to be installed?',
|
|
118
|
+
default: false,
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
if (!confirmed) {
|
|
122
|
+
throw new common_1.ParameterError('Data app creation cancelled.');
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const confirmDependencyInstall = async (directPackages, assumeYes) => {
|
|
126
|
+
globalState_1.default.log([
|
|
127
|
+
'Creating this data app will install these direct npm packages:',
|
|
128
|
+
...directPackages.map((packageSpec) => ` - ${packageSpec}`),
|
|
129
|
+
`\nIt will also run shadcn@${exports.SHADCN_VERSION} to generate:`,
|
|
130
|
+
` ${exports.SHADCN_COMPONENTS.join(', ')}`,
|
|
131
|
+
"\nDependency lifecycle scripts will be disabled. React 19 peer dependencies will use npm's legacy-peer-deps mode.",
|
|
132
|
+
].join('\n'));
|
|
133
|
+
if (assumeYes)
|
|
134
|
+
return;
|
|
135
|
+
if (globalState_1.default.isNonInteractive()) {
|
|
136
|
+
throw new common_1.ParameterError('Creating a data app requires approval to install npm packages. Rerun with --assume-yes to approve in non-interactive mode.');
|
|
137
|
+
}
|
|
138
|
+
const { confirmed } = await inquirer_1.default.prompt([
|
|
139
|
+
{
|
|
140
|
+
type: 'confirm',
|
|
141
|
+
name: 'confirmed',
|
|
142
|
+
message: 'Install these packages and create the app?',
|
|
143
|
+
default: false,
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
if (!confirmed) {
|
|
147
|
+
throw new common_1.ParameterError('Data app creation cancelled.');
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const installDependenciesAndShadcn = async (appDir) => {
|
|
151
|
+
const npmEnv = {
|
|
152
|
+
...process.env,
|
|
153
|
+
npm_config_ignore_scripts: 'true',
|
|
154
|
+
npm_config_package_lock: 'false',
|
|
155
|
+
};
|
|
156
|
+
const subprocessOptions = {
|
|
157
|
+
cwd: appDir,
|
|
158
|
+
env: npmEnv,
|
|
159
|
+
stdio: 'inherit',
|
|
160
|
+
};
|
|
161
|
+
const shadcnOptions = {
|
|
162
|
+
cwd: appDir,
|
|
163
|
+
env: npmEnv,
|
|
164
|
+
input: '\u001B[B\n',
|
|
165
|
+
stdin: 'pipe',
|
|
166
|
+
stdout: 'inherit',
|
|
167
|
+
stderr: 'inherit',
|
|
168
|
+
};
|
|
169
|
+
await (0, execa_1.default)('npm', ['install', '--include=dev', '--ignore-scripts', '--no-package-lock'], subprocessOptions);
|
|
170
|
+
await (0, execa_1.default)('npx', ['--yes', `shadcn@${exports.SHADCN_VERSION}`, 'init', '--defaults', '--force'], shadcnOptions);
|
|
171
|
+
await (0, execa_1.default)('npx', [
|
|
172
|
+
'--yes',
|
|
173
|
+
`shadcn@${exports.SHADCN_VERSION}`,
|
|
174
|
+
'add',
|
|
175
|
+
'--overwrite',
|
|
176
|
+
'--yes',
|
|
177
|
+
...exports.SHADCN_COMPONENTS,
|
|
178
|
+
], shadcnOptions);
|
|
179
|
+
};
|
|
180
|
+
const createAppHandler = async (name, options) => {
|
|
181
|
+
const startTime = Date.now();
|
|
182
|
+
let success = false;
|
|
183
|
+
globalState_1.default.setVerbose(options.verbose);
|
|
184
|
+
try {
|
|
185
|
+
const slug = (0, exports.resolveLocalAppSlug)(name, options.slug);
|
|
186
|
+
const appDir = path.join((0, contentAsCodePaths_1.getDownloadFolder)(options.path), 'apps', slug);
|
|
187
|
+
await assertAppDirectoryAvailable(appDir);
|
|
188
|
+
await assertNpmAvailable();
|
|
189
|
+
await confirmLocalPackageTooling(options.assumeYes ?? false);
|
|
190
|
+
const staticFiles = (0, scaffolding_1.buildStaticAuthoringFiles)({
|
|
191
|
+
appName: name,
|
|
192
|
+
sdkVersion: env_1.CLI_VERSION,
|
|
193
|
+
});
|
|
194
|
+
await confirmDependencyInstall(parseDirectPackages(staticFiles), options.assumeYes ?? false);
|
|
195
|
+
await (0, apiClient_1.checkLightdashVersion)();
|
|
196
|
+
const config = await (0, config_1.getConfig)();
|
|
197
|
+
if (!config.context?.apiKey || !config.context.serverUrl) {
|
|
198
|
+
throw new common_1.AuthorizationError(`Not logged in. Run 'lightdash login --help'`);
|
|
199
|
+
}
|
|
200
|
+
const projectSelection = await (0, selectProject_1.selectProject)(config, options.project);
|
|
201
|
+
if (!projectSelection) {
|
|
202
|
+
throw new common_1.LightdashError({
|
|
203
|
+
message: 'No project selected. Run lightdash config set-project',
|
|
204
|
+
name: 'Not Found',
|
|
205
|
+
statusCode: 404,
|
|
206
|
+
data: {},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const { projectUuid } = projectSelection;
|
|
210
|
+
(0, selectProject_1.logSelectedProject)(projectSelection, config, 'Creating app for');
|
|
211
|
+
const context = await (0, apiClient_1.lightdashApi)({
|
|
212
|
+
method: 'GET',
|
|
213
|
+
url: `/api/v1/ee/projects/${projectUuid}/apps/authoring-context?slug=${encodeURIComponent(slug)}`,
|
|
214
|
+
body: undefined,
|
|
215
|
+
});
|
|
216
|
+
const manifest = (0, exports.buildLocalAppManifest)({
|
|
217
|
+
name,
|
|
218
|
+
description: options.description ?? '',
|
|
219
|
+
projectUuid,
|
|
220
|
+
slug,
|
|
221
|
+
now: new Date(),
|
|
222
|
+
});
|
|
223
|
+
const appsDir = path.dirname(appDir);
|
|
224
|
+
await fs_1.promises.mkdir(appsDir, { recursive: true });
|
|
225
|
+
const temporaryAppDir = await fs_1.promises.mkdtemp(path.join(appsDir, `.${slug}-`));
|
|
226
|
+
try {
|
|
227
|
+
await (0, appCodeFiles_1.writeBundleToDir)(temporaryAppDir, {
|
|
228
|
+
manifest,
|
|
229
|
+
files: (0, scaffolding_1.loadVendoredStarterSource)(),
|
|
230
|
+
});
|
|
231
|
+
await (0, appCodeFiles_1.writeFilesToDir)(temporaryAppDir, staticFiles);
|
|
232
|
+
await (0, appCodeFiles_1.writeContextToDir)(temporaryAppDir, context);
|
|
233
|
+
await installDependenciesAndShadcn(temporaryAppDir);
|
|
234
|
+
// shadcn rewrites package versions and tailwind colors while it
|
|
235
|
+
// generates source. Restore the reviewed template declarations so
|
|
236
|
+
// the approval list stays exact and uploads do not mistake the
|
|
237
|
+
// generated app for one with custom dependencies.
|
|
238
|
+
await (0, appCodeFiles_1.writeFilesToDir)(temporaryAppDir, staticFiles.filter((file) => file.path === 'package.json' ||
|
|
239
|
+
file.path === 'tailwind.config.js'));
|
|
240
|
+
await fs_1.promises.rename(temporaryAppDir, appDir);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
await fs_1.promises.rm(temporaryAppDir, { recursive: true, force: true });
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
globalState_1.default.log(styles.success(`Created data app "${name}" at ${appDir}`));
|
|
247
|
+
globalState_1.default.log(`\nNext:\n cd ${JSON.stringify(appDir)}\n npm run build\n lightdash upload --apps ${slug}`);
|
|
248
|
+
success = true;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await analytics_1.LightdashAnalytics.track({
|
|
252
|
+
event: 'command.executed',
|
|
253
|
+
properties: {
|
|
254
|
+
command: 'create-app',
|
|
255
|
+
durationMs: Date.now() - startTime,
|
|
256
|
+
success,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
exports.createAppHandler = createAppHandler;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createApp.test.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/createApp.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const execa_1 = tslib_1.__importDefault(require("execa"));
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
|
|
7
|
+
const os = tslib_1.__importStar(require("os"));
|
|
8
|
+
const path = tslib_1.__importStar(require("path"));
|
|
9
|
+
const analytics_1 = require("../../analytics/analytics");
|
|
10
|
+
const config_1 = require("../../config");
|
|
11
|
+
const globalState_1 = tslib_1.__importDefault(require("../../globalState"));
|
|
12
|
+
const apiClient_1 = require("../dbt/apiClient");
|
|
13
|
+
const selectProject_1 = require("../selectProject");
|
|
14
|
+
const appCodeFiles_1 = require("./appCodeFiles");
|
|
15
|
+
const createApp_1 = require("./createApp");
|
|
16
|
+
vi.mock('../../config', () => ({ getConfig: vi.fn() }));
|
|
17
|
+
vi.mock('../dbt/apiClient', () => ({
|
|
18
|
+
checkLightdashVersion: vi.fn(),
|
|
19
|
+
lightdashApi: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('../selectProject', () => ({
|
|
22
|
+
selectProject: vi.fn(),
|
|
23
|
+
logSelectedProject: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('../../analytics/analytics', () => ({
|
|
26
|
+
LightdashAnalytics: { track: vi.fn() },
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('execa');
|
|
29
|
+
vi.mock('inquirer', () => ({
|
|
30
|
+
default: { prompt: vi.fn() },
|
|
31
|
+
}));
|
|
32
|
+
const execaMock = execa_1.default;
|
|
33
|
+
const promptMock = inquirer_1.default.prompt;
|
|
34
|
+
const PROJECT_UUID = '11111111-1111-4111-8111-111111111111';
|
|
35
|
+
const context = {
|
|
36
|
+
semanticLayer: {
|
|
37
|
+
path: '.lightdash/context/semantic-layer.yml',
|
|
38
|
+
contentBase64: Buffer.from('models: []\n').toString('base64'),
|
|
39
|
+
},
|
|
40
|
+
parameters: null,
|
|
41
|
+
promptHistory: {
|
|
42
|
+
path: '.lightdash/context/prompt-history.md',
|
|
43
|
+
contentBase64: Buffer.from('# Prompt history\n\n_No previous versions._\n').toString('base64'),
|
|
44
|
+
},
|
|
45
|
+
theme: { instructions: null, assets: [], skippedAssetCount: 0 },
|
|
46
|
+
};
|
|
47
|
+
describe('resolveLocalAppSlug', () => {
|
|
48
|
+
it('generates a slug from the app name', () => {
|
|
49
|
+
expect((0, createApp_1.resolveLocalAppSlug)('Revenue Explorer')).toBe('revenue-explorer');
|
|
50
|
+
});
|
|
51
|
+
it('accepts a valid explicit slug', () => {
|
|
52
|
+
expect((0, createApp_1.resolveLocalAppSlug)('Revenue Explorer', 'sales-overview')).toBe('sales-overview');
|
|
53
|
+
});
|
|
54
|
+
it('rejects an invalid explicit slug', () => {
|
|
55
|
+
expect(() => (0, createApp_1.resolveLocalAppSlug)('Revenue Explorer', '../invalid')).toThrow('Invalid data app slug');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('buildLocalAppManifest', () => {
|
|
59
|
+
it('builds a slug-identified manifest without an app uuid', () => {
|
|
60
|
+
const manifest = (0, createApp_1.buildLocalAppManifest)({
|
|
61
|
+
name: 'Revenue Explorer',
|
|
62
|
+
description: 'Revenue by customer',
|
|
63
|
+
projectUuid: PROJECT_UUID,
|
|
64
|
+
slug: 'revenue-explorer',
|
|
65
|
+
now: new Date('2026-07-30T10:00:00.000Z'),
|
|
66
|
+
});
|
|
67
|
+
expect(manifest).toMatchObject({
|
|
68
|
+
codeVersion: 1,
|
|
69
|
+
projectUuid: PROJECT_UUID,
|
|
70
|
+
slug: 'revenue-explorer',
|
|
71
|
+
version: 1,
|
|
72
|
+
name: 'Revenue Explorer',
|
|
73
|
+
description: 'Revenue by customer',
|
|
74
|
+
template: null,
|
|
75
|
+
downloadedAt: '2026-07-30T10:00:00.000Z',
|
|
76
|
+
});
|
|
77
|
+
expect(manifest.appUuid).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('createAppHandler', () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
execaMock.mockImplementation(async (command, args = [], options) => {
|
|
83
|
+
if (command === 'npx' &&
|
|
84
|
+
args.includes('init') &&
|
|
85
|
+
options?.cwd) {
|
|
86
|
+
await fs_1.promises.writeFile(path.join(options.cwd, 'package.json'), '{}\n');
|
|
87
|
+
await fs_1.promises.writeFile(path.join(options.cwd, 'tailwind.config.js'), '// rewritten by shadcn\n');
|
|
88
|
+
}
|
|
89
|
+
if (command === 'npx' && args.includes('add') && options?.cwd) {
|
|
90
|
+
const uiDir = path.join(options.cwd, 'src/components/ui');
|
|
91
|
+
await fs_1.promises.mkdir(uiDir, { recursive: true });
|
|
92
|
+
await fs_1.promises.writeFile(path.join(uiDir, 'button.tsx'), 'export const Button = () => null;\n');
|
|
93
|
+
}
|
|
94
|
+
return { stdout: '' };
|
|
95
|
+
});
|
|
96
|
+
promptMock.mockResolvedValue({ confirmed: true });
|
|
97
|
+
vi.mocked(config_1.getConfig).mockResolvedValue({
|
|
98
|
+
context: {
|
|
99
|
+
apiKey: 'token',
|
|
100
|
+
serverUrl: 'https://lightdash.example.com',
|
|
101
|
+
project: PROJECT_UUID,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
vi.mocked(selectProject_1.selectProject).mockResolvedValue({
|
|
105
|
+
projectUuid: PROJECT_UUID,
|
|
106
|
+
isPreview: false,
|
|
107
|
+
});
|
|
108
|
+
vi.mocked(apiClient_1.lightdashApi).mockResolvedValue(context);
|
|
109
|
+
vi.mocked(apiClient_1.checkLightdashVersion).mockResolvedValue(undefined);
|
|
110
|
+
vi.spyOn(globalState_1.default, 'log').mockImplementation(() => undefined);
|
|
111
|
+
vi.spyOn(globalState_1.default, 'isNonInteractive').mockReturnValue(false);
|
|
112
|
+
});
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
vi.restoreAllMocks();
|
|
115
|
+
vi.clearAllMocks();
|
|
116
|
+
});
|
|
117
|
+
it('creates a complete local app scaffold and project context', async () => {
|
|
118
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
119
|
+
await (0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
120
|
+
description: 'Revenue by customer',
|
|
121
|
+
path: root,
|
|
122
|
+
verbose: false,
|
|
123
|
+
});
|
|
124
|
+
const appDir = path.join(root, 'apps', 'revenue-explorer');
|
|
125
|
+
const bundle = await (0, appCodeFiles_1.readBundleFromDir)(appDir);
|
|
126
|
+
expect(bundle.manifest.slug).toBe('revenue-explorer');
|
|
127
|
+
expect(bundle.manifest.appUuid).toBeUndefined();
|
|
128
|
+
expect(bundle.files.map((file) => file.path)).toContain('src/App.jsx');
|
|
129
|
+
expect(bundle.files.map((file) => file.path)).toContain('src/components/ui/button.tsx');
|
|
130
|
+
expect(await fs_1.promises.readFile(path.join(appDir, 'package.json'), 'utf8')).toContain('@lightdash/query-sdk');
|
|
131
|
+
expect(await fs_1.promises.readFile(path.join(appDir, 'tailwind.config.js'), 'utf8')).not.toContain('rewritten by shadcn');
|
|
132
|
+
await expect(fs_1.promises.access(path.join(appDir, '.claude/skills/lightdash-data-app/SKILL.md'))).resolves.toBeUndefined();
|
|
133
|
+
await expect(fs_1.promises.access(path.join(appDir, '.claude/skills/developing-data-apps-locally/SKILL.md'))).resolves.toBeUndefined();
|
|
134
|
+
expect(await fs_1.promises.readFile(path.join(appDir, '.lightdash/context/semantic-layer.yml'), 'utf8')).toBe('models: []\n');
|
|
135
|
+
expect(apiClient_1.lightdashApi).toHaveBeenCalledWith({
|
|
136
|
+
method: 'GET',
|
|
137
|
+
url: `/api/v1/ee/projects/${PROJECT_UUID}/apps/authoring-context?slug=revenue-explorer`,
|
|
138
|
+
body: undefined,
|
|
139
|
+
});
|
|
140
|
+
expect(analytics_1.LightdashAnalytics.track).toHaveBeenCalledWith({
|
|
141
|
+
event: 'command.executed',
|
|
142
|
+
properties: expect.objectContaining({
|
|
143
|
+
command: 'create-app',
|
|
144
|
+
success: true,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('react@19.2.5'));
|
|
148
|
+
expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('⚠ Local package installation'));
|
|
149
|
+
expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('shadcn@2.3.0'));
|
|
150
|
+
expect(promptMock).toHaveBeenCalledTimes(2);
|
|
151
|
+
expect(promptMock).toHaveBeenNthCalledWith(1, [
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
type: 'confirm',
|
|
154
|
+
name: 'confirmed',
|
|
155
|
+
message: 'Continue and review the packages to be installed?',
|
|
156
|
+
default: false,
|
|
157
|
+
}),
|
|
158
|
+
]);
|
|
159
|
+
expect(promptMock).toHaveBeenNthCalledWith(2, [
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
type: 'confirm',
|
|
162
|
+
name: 'confirmed',
|
|
163
|
+
message: 'Install these packages and create the app?',
|
|
164
|
+
default: false,
|
|
165
|
+
}),
|
|
166
|
+
]);
|
|
167
|
+
expect(execaMock).toHaveBeenCalledWith('npm', ['--version']);
|
|
168
|
+
expect(execaMock).toHaveBeenCalledWith('npm', [
|
|
169
|
+
'install',
|
|
170
|
+
'--include=dev',
|
|
171
|
+
'--ignore-scripts',
|
|
172
|
+
'--no-package-lock',
|
|
173
|
+
], expect.objectContaining({
|
|
174
|
+
cwd: expect.any(String),
|
|
175
|
+
stdio: 'inherit',
|
|
176
|
+
}));
|
|
177
|
+
expect(execaMock).toHaveBeenCalledWith('npx', expect.arrayContaining(['shadcn@2.3.0', 'init']), expect.objectContaining({
|
|
178
|
+
cwd: expect.any(String),
|
|
179
|
+
input: '\u001B[B\n',
|
|
180
|
+
}));
|
|
181
|
+
expect(execaMock).toHaveBeenCalledWith('npx', expect.arrayContaining(['shadcn@2.3.0', 'add', 'button']), expect.objectContaining({
|
|
182
|
+
cwd: expect.any(String),
|
|
183
|
+
input: '\u001B[B\n',
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
it('requires npm before requesting app context', async () => {
|
|
187
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
188
|
+
execaMock.mockRejectedValueOnce(new Error('npm not found'));
|
|
189
|
+
await expect((0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
190
|
+
path: root,
|
|
191
|
+
verbose: false,
|
|
192
|
+
})).rejects.toThrow('npm is required to create a data app');
|
|
193
|
+
expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
|
|
194
|
+
await expect(fs_1.promises.access(path.join(root, 'apps', 'revenue-explorer'))).rejects.toThrow();
|
|
195
|
+
});
|
|
196
|
+
it('does not create anything when package installation is declined', async () => {
|
|
197
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
198
|
+
promptMock
|
|
199
|
+
.mockResolvedValueOnce({ confirmed: true })
|
|
200
|
+
.mockResolvedValueOnce({ confirmed: false });
|
|
201
|
+
await expect((0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
202
|
+
path: root,
|
|
203
|
+
verbose: false,
|
|
204
|
+
})).rejects.toThrow('Data app creation cancelled');
|
|
205
|
+
expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
|
|
206
|
+
await expect(fs_1.promises.access(path.join(root, 'apps', 'revenue-explorer'))).rejects.toThrow();
|
|
207
|
+
});
|
|
208
|
+
it('does not list packages when local tooling approval is declined', async () => {
|
|
209
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
210
|
+
promptMock.mockResolvedValueOnce({ confirmed: false });
|
|
211
|
+
await expect((0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
212
|
+
path: root,
|
|
213
|
+
verbose: false,
|
|
214
|
+
})).rejects.toThrow('Data app creation cancelled');
|
|
215
|
+
expect(promptMock).toHaveBeenCalledTimes(1);
|
|
216
|
+
expect(globalState_1.default.log).not.toHaveBeenCalledWith(expect.stringContaining('react@19.2.5'));
|
|
217
|
+
expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
it('requires explicit approval in non-interactive mode', async () => {
|
|
220
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
221
|
+
vi.mocked(globalState_1.default.isNonInteractive).mockReturnValueOnce(true);
|
|
222
|
+
await expect((0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
223
|
+
path: root,
|
|
224
|
+
verbose: false,
|
|
225
|
+
})).rejects.toThrow('--assume-yes');
|
|
226
|
+
expect(promptMock).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
it('accepts --assume-yes in non-interactive mode', async () => {
|
|
229
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
230
|
+
vi.mocked(globalState_1.default.isNonInteractive).mockReturnValueOnce(true);
|
|
231
|
+
await (0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
232
|
+
assumeYes: true,
|
|
233
|
+
path: root,
|
|
234
|
+
verbose: false,
|
|
235
|
+
});
|
|
236
|
+
expect(promptMock).not.toHaveBeenCalled();
|
|
237
|
+
await expect(fs_1.promises.access(path.join(root, 'apps', 'revenue-explorer'))).resolves.toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
it('cleans up the temporary app when installation fails', async () => {
|
|
240
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
241
|
+
execaMock.mockImplementation(async (command, args = []) => {
|
|
242
|
+
if (command === 'npm' && args[0] === 'install') {
|
|
243
|
+
throw new Error('registry unavailable');
|
|
244
|
+
}
|
|
245
|
+
return { stdout: '' };
|
|
246
|
+
});
|
|
247
|
+
await expect((0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
248
|
+
path: root,
|
|
249
|
+
verbose: false,
|
|
250
|
+
})).rejects.toThrow('registry unavailable');
|
|
251
|
+
expect(await fs_1.promises.readdir(path.join(root, 'apps'))).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
it('does not overwrite an existing app directory', async () => {
|
|
254
|
+
const root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-create-app-'));
|
|
255
|
+
const appDir = path.join(root, 'apps', 'revenue-explorer');
|
|
256
|
+
await fs_1.promises.mkdir(appDir, { recursive: true });
|
|
257
|
+
await fs_1.promises.writeFile(path.join(appDir, 'keep.txt'), 'keep');
|
|
258
|
+
await expect((0, createApp_1.createAppHandler)('Revenue Explorer', {
|
|
259
|
+
path: root,
|
|
260
|
+
verbose: false,
|
|
261
|
+
})).rejects.toThrow('already exists');
|
|
262
|
+
expect(await fs_1.promises.readFile(path.join(appDir, 'keep.txt'), 'utf8')).toBe('keep');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -18,6 +18,7 @@ export declare const loadTemplateDependencies: (sdkVersion: string) => Record<st
|
|
|
18
18
|
* Reads vendored template, excluding sandbox-only entries (skill.md, scripts/, src/).
|
|
19
19
|
*/
|
|
20
20
|
export declare const loadVendoredTemplate: () => DataAppCodeFile[];
|
|
21
|
+
export declare const loadVendoredStarterSource: () => DataAppCodeFile[];
|
|
21
22
|
/**
|
|
22
23
|
* Assembles static authoring files (configs, skills, templates) to deploy alongside a data app.
|
|
23
24
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scaffolding.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/scaffolding.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAMzD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAKpE;AA6DD;;;GAGG;AACH,eAAO,MAAM,oBAAoB,gBAChB,MAAM,WACV,MAAM,KAChB,MAAmE,CAAC;AAEvE;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eACrB,MAAM,KACnB,MAAM,CAAC,MAAM,EAAE,MAAM,CAevB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,oBAAoB,QAAO,eAAe,EAItD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,yBAAyB,SAAU;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACtB,KAAG,eAAe,EAkElB,CAAC"}
|
|
1
|
+
{"version":3,"file":"scaffolding.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/scaffolding.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAMzD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAKpE;AA6DD;;;GAGG;AACH,eAAO,MAAM,oBAAoB,gBAChB,MAAM,WACV,MAAM,KAChB,MAAmE,CAAC;AAEvE;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eACrB,MAAM,KACnB,MAAM,CAAC,MAAM,EAAE,MAAM,CAevB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,oBAAoB,QAAO,eAAe,EAItD,CAAC;AAEF,eAAO,MAAM,yBAAyB,QAAO,eAAe,EAG3D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,yBAAyB,SAAU;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACtB,KAAG,eAAe,EAkElB,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.buildStaticAuthoringFiles = exports.loadVendoredTemplate = exports.loadTemplateDependencies = exports.rewriteWorkspaceDeps = void 0;
|
|
3
|
+
exports.buildStaticAuthoringFiles = exports.loadVendoredStarterSource = exports.loadVendoredTemplate = exports.loadTemplateDependencies = exports.rewriteWorkspaceDeps = void 0;
|
|
4
4
|
exports.firstExistingDir = firstExistingDir;
|
|
5
5
|
const tslib_1 = require("tslib");
|
|
6
6
|
const fs_1 = require("fs");
|
|
@@ -89,6 +89,11 @@ const loadVendoredTemplate = () => {
|
|
|
89
89
|
return walkDir(templateDir, templateDir, SKIP);
|
|
90
90
|
};
|
|
91
91
|
exports.loadVendoredTemplate = loadVendoredTemplate;
|
|
92
|
+
const loadVendoredStarterSource = () => {
|
|
93
|
+
const { templateDir } = resolveVendorDirs();
|
|
94
|
+
return walkDir(path.join(templateDir, 'src'), templateDir, new Set());
|
|
95
|
+
};
|
|
96
|
+
exports.loadVendoredStarterSource = loadVendoredStarterSource;
|
|
92
97
|
/**
|
|
93
98
|
* Assembles static authoring files (configs, skills, templates) to deploy alongside a data app.
|
|
94
99
|
*/
|
|
@@ -55,9 +55,14 @@ describe('buildStaticAuthoringFiles', () => {
|
|
|
55
55
|
});
|
|
56
56
|
const byPath = (p) => files.find((f) => f.path === p);
|
|
57
57
|
const text = (p) => Buffer.from(byPath(p).contentBase64, 'base64').toString('utf-8');
|
|
58
|
-
it(
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
it.each([
|
|
59
|
+
'lightdash-data-app',
|
|
60
|
+
'developing-data-apps-locally',
|
|
61
|
+
'frontend-design',
|
|
62
|
+
'reusable-visualization',
|
|
63
|
+
'sdk-features',
|
|
64
|
+
])('ships the %s skill', (skill) => {
|
|
65
|
+
expect(byPath(`.claude/skills/${skill}/SKILL.md`)).toBeDefined();
|
|
61
66
|
});
|
|
62
67
|
it('pins the SDK to a concrete version in package.json', () => {
|
|
63
68
|
expect(text('package.json')).toContain('"@lightdash/query-sdk": "0.3275.0"');
|
|
@@ -68,9 +73,9 @@ describe('buildStaticAuthoringFiles', () => {
|
|
|
68
73
|
expect(text('.gitignore')).toContain('node_modules');
|
|
69
74
|
});
|
|
70
75
|
it('uses npm for the standard local workflow', () => {
|
|
71
|
-
expect(text('README.md')).toContain('npm install
|
|
76
|
+
expect(text('README.md')).toContain('run `npm install` first');
|
|
72
77
|
expect(text('AGENTS.md')).toContain('npm install && npm run build');
|
|
73
|
-
expect(text('.claude/skills/developing-data-apps-locally/SKILL.md')).toContain('`npm install`
|
|
78
|
+
expect(text('.claude/skills/developing-data-apps-locally/SKILL.md')).toContain('run `npm install` first');
|
|
74
79
|
expect(text('.npmrc')).toContain('ignore-scripts=true');
|
|
75
80
|
expect(text('.npmrc')).not.toContain('shamefully-hoist');
|
|
76
81
|
});
|
|
@@ -78,3 +83,15 @@ describe('buildStaticAuthoringFiles', () => {
|
|
|
78
83
|
expect(files.every((f) => !f.path.startsWith('src/'))).toBe(true);
|
|
79
84
|
});
|
|
80
85
|
});
|
|
86
|
+
describe('loadVendoredStarterSource', () => {
|
|
87
|
+
const files = (0, scaffolding_1.loadVendoredStarterSource)();
|
|
88
|
+
it('loads the starter app source with bundle-relative paths', () => {
|
|
89
|
+
expect(files.map((file) => file.path)).toEqual(expect.arrayContaining([
|
|
90
|
+
'src/App.jsx',
|
|
91
|
+
'src/main.jsx',
|
|
92
|
+
'src/index.css',
|
|
93
|
+
]));
|
|
94
|
+
expect(files.every((file) => file.path.startsWith('src/'))).toBe(true);
|
|
95
|
+
expect(files.some((file) => file.path.startsWith('src/components/ui/'))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|