Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Keep the TypeScript manifest compiler out of CLI downloads by installing it with the cached single-file preview runtime.

- Complete stdin-based credential configuration before exiting on Windows.

- Keep profiles and credentials in the user's home directory when `HOME` is unset, and reject relative config and shell-completion directory settings.
Expand Down
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ git submodule update --init --recursive

## Build and package

Builds enforce a 1 MiB uncompressed bundle budget in `cli/scripts/check-bundle-size.ts`.

The JS bundle is the npm `bin` entry and a GitHub Release asset:

```bash
Expand Down
5 changes: 0 additions & 5 deletions cli/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"test:watch": "bun test --watch"
},
"dependencies": {
"@altertable/data-app-manifest": "0.73.0",
"@clack/prompts": "1.7.0",
"oauth4webapi": "3.8.7",
"yaml": "2.9.0"
Expand Down
26 changes: 26 additions & 0 deletions cli/scripts/check-bundle-size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'bun:test';
import { rejects } from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { checkBundleSize } from '@/../scripts/check-bundle-size.ts';

test('enforces the shipped bundle budget, including missing and empty artifacts', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cli-bundle-budget-'));
const path = join(directory, 'cli.js');
try {
await rejects(checkBundleSize(path), /Missing or empty/);
await Bun.write(path, '');
await rejects(checkBundleSize(path), /Missing or empty/);
await Bun.write(path, new Uint8Array(1024 * 1024));
await checkBundleSize(path);
await Bun.write(path, new Uint8Array(1024 * 1024 + 1));
await rejects(
checkBundleSize(path),
/1,048,577 bytes \/ 1,048,576 byte budget/
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
17 changes: 17 additions & 0 deletions cli/scripts/check-bundle-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The standalone JavaScript bundle also feeds every native release build.
// Keep compiler and app runtime dependencies in installed projects instead.
const MAX_BUNDLE_BYTES = 1024 * 1024;

export async function checkBundleSize(path: string): Promise<void> {
const file = Bun.file(path);
if (!(await file.exists()) || file.size === 0) {
throw new Error(`Missing or empty CLI bundle: ${path}.`);
}
const message = `CLI bundle: ${file.size.toLocaleString('en-US')} bytes / ${MAX_BUNDLE_BYTES.toLocaleString('en-US')} byte budget`;
if (file.size > MAX_BUNDLE_BYTES) {
throw new Error(
`${message}. Inspect bundled dependencies before changing the budget in scripts/check-bundle-size.ts.`
);
}
console.log(message);
}
6 changes: 2 additions & 4 deletions cli/scripts/npm-bundle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';

import { checkBundleSize } from '@/../scripts/check-bundle-size.ts';
import { dataAppPlugin } from '@/../scripts/package-data-app.ts';

export const NPM_BUNDLE_OPTIONS = {
Expand Down Expand Up @@ -29,10 +30,7 @@ export async function buildNpmBundle(
`npm bundle failed:\n${result.logs.map(String).join('\n')}`
);
}
const output = Bun.file(outputPath);
if (!(await output.exists()) || output.size === 0) {
throw new Error(`npm bundle is missing or empty: ${outputPath}.`);
}
await checkBundleSize(outputPath);
return outputPath;
}

Expand Down
2 changes: 1 addition & 1 deletion cli/scripts/package-data-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BunPlugin } from 'bun';

import { readDataAppPayload } from '@/commands/app/lib/distribution.ts';

/** Both npm and native releases embed the same deterministic starter template. */
/** Both npm and native releases embed the same deterministic project templates. */
export function dataAppPlugin(): BunPlugin {
return {
name: 'data-app-starter',
Expand Down
8 changes: 3 additions & 5 deletions cli/src/commands/app/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,9 @@ export const appDevCommand = defineCommand({
: appDirectory(args.dir);
requireAppScripts(directory, ['dev']);
if (!file) await checkAppScope(directory, execution.profile);
const installExitCode = await runAppCommand(
file ? 'install-frozen' : 'install',
directory
);
if (installExitCode !== 0) return { exitCode: EXIT_GENERIC };
if (!file && (await runAppCommand('install', directory)) !== 0) {
return { exitCode: EXIT_GENERIC };
}
const proxy = startAppDevProxy(execution);
try {
const environment = {
Expand Down
13 changes: 12 additions & 1 deletion cli/src/commands/app/lib/distribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,19 @@ afterEach(async () => {
test('distribution embeds a deterministic starter with an exact package pin and no runtime', async () => {
const payload = await readDataAppPayload();
expect(await readDataAppPayload()).toEqual(payload);
expect(Object.keys(payload)).toEqual(['starter']);
expect(Object.keys(payload)).toEqual(['starter', 'filePreview']);
expect(Object.keys(payload.starter)).toContain('src/App.tsx');
const previewPackage = JSON.parse(payload.filePreview['package.json']!);
const starterPackage = JSON.parse(payload.starter['package.json']!);
expect(previewPackage.dependencies).toEqual({
...starterPackage.dependencies,
'@altertable/data-app-manifest': recommendedDataAppVersion(),
});
expect(payload.filePreview['bun.lock']).toContain('typescript');
expect(payload.filePreview['extract-manifest.js']).toContain(
'extractDataAppManifest'
);

for (const path of Object.keys(payload.starter)) {
expect(path).not.toMatch(
/(^|\/)(tests|fixtures|node_modules|dist|\.altertable)(\/|$)|\.txt$/
Expand Down
17 changes: 14 additions & 3 deletions cli/src/commands/app/lib/distribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export const starterFiles = [
export const dataAppDirectory = fileURLToPath(
new URL('../../../../../data-app/', import.meta.url)
);
export type DataAppPayload = { starter: Record<string, string> };
export type DataAppPayload = {
starter: Record<string, string>;
filePreview: Record<string, string>;
};

function assertRelativePath(path: string): void {
if (
Expand Down Expand Up @@ -60,11 +63,19 @@ async function readFiles(
return files;
}

/** Release builds embed only the starter; its runtime is installed from npm. */
/** Embed project files; runtime packages are installed from npm. */
export async function readDataAppPayload(
directory = dataAppDirectory
): Promise<DataAppPayload> {
return { starter: await readFiles(join(directory, 'starter'), starterFiles) };
return {
starter: await readFiles(join(directory, 'starter'), starterFiles),
filePreview: await readFiles(join(directory, 'file-preview'), [
'package.json',
'bun.lock',
'bunfig.toml',
'extract-manifest.js',
]),
};
}

export function createAppFiles(
Expand Down
8 changes: 4 additions & 4 deletions cli/src/commands/app/lib/file-dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,19 @@ throw new Error("must not execute");`
const manifest = JSON.parse(
await readFile(join(directory, 'package.json'), 'utf8')
);
const starter = JSON.parse(dataAppPayload.starter['package.json']!);
const starter = JSON.parse(dataAppPayload.filePreview['package.json']!);
expect(manifest.dependencies).toEqual(starter.dependencies);
expect(manifest.scripts).toEqual({ dev: 'data-app-preview' });
expect(await readFile(join(directory, 'bun.lock'), 'utf8')).toBe(
dataAppPayload.starter['bun.lock']!
dataAppPayload.filePreview['bun.lock']!
);
expect(existsSync(join(directory, 'server.ts'))).toBe(false);
expect(
await prepare(app('{ organization: "acme", environment: "production" }'))
).toBe(directory);
});

test('rejects invalid declarations and mismatched scopes before creating the runtime', async () => {
test('rejects invalid declarations and mismatched scopes before launching the preview', async () => {
await rejects(
prepare('export const app = 1;'),
/Could not extract the data app manifest\.\n1:/
Expand All @@ -70,5 +70,5 @@ test('rejects invalid declarations and mismatched scopes before creating the run
]) {
await rejects(prepare(app(scope)), /data app file scope does not match/);
}
expect(existsSync(join(workspace.home, 'data-app-dev'))).toBe(false);
expect(existsSync(join(workspace.home, 'data-app-dev'))).toBe(true);
});
73 changes: 48 additions & 25 deletions cli/src/commands/app/lib/file-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,72 @@ import { join } from 'node:path';
import { recommendedDataAppVersion } from '@/commands/app/lib/package.ts';
import { dataAppPayload } from '@/commands/app/lib/payload.ts';
import { readRequiredFile } from '@/commands/app/lib/remote.ts';
import { runAppCommand } from '@/commands/app/lib/run.ts';
import { assertAppScope, profileAppScope } from '@/commands/app/lib/scope.ts';
import { configDir } from '@/lib/config-files.ts';
import { copyProcessEnv } from '@/lib/env.ts';
import { ConfigurationError } from '@/lib/errors.ts';

/** Prepare a regular dev project backed by the starter's pinned runtime. */
/** Install the pinned preview tools and verify scope before granting lakehouse access. */
export async function prepareFilePreview(
file: string,
profile: string
): Promise<string> {
const source = await readRequiredFile(file, 'source');
// Keep the TypeScript compiler off the startup path of other CLI commands.
const { extractDataAppManifest } =
await import('@altertable/data-app-manifest');
const { manifest, diagnostics } = extractDataAppManifest(source);
if (!manifest) {
const details = diagnostics
.slice(0, 3)
.map(({ message, start }) => `${start.line}:${start.column}: ${message}`)
.join('\n');
throw new ConfigurationError(
`Could not extract the data app manifest.\n${details}`
);
}
assertAppScope(manifest.scope, profileAppScope(profile), 'data app file');

const directory = join(
configDir(),
'data-app-dev',
recommendedDataAppVersion()
);
const starter = dataAppPayload.starter;
const packageJson = JSON.parse(starter['package.json']!);
packageJson.scripts = { dev: 'data-app-preview' };
await mkdir(directory, { recursive: true });
const files = {
'package.json': `${JSON.stringify(packageJson, null, 2)}\n`,
'bun.lock': starter['bun.lock']!,
'bunfig.toml': starter['bunfig.toml']!,
};
await Promise.all(
Object.entries(files).map(([name, content]) =>
Object.entries(dataAppPayload.filePreview).map(([name, content]) =>
writeFile(join(directory, name), content)
)
);
if ((await runAppCommand('install-frozen', directory)) !== 0) {
throw new ConfigurationError('Could not install the file preview runtime.');
}

// Run the compiler in the installed preview project, outside the CLI bundle.
const env = copyProcessEnv();
for (const key of Object.keys(env)) {
if (key.startsWith('ALTERTABLE_')) delete env[key];
}
env.BUN_BE_BUN = '1';
const child = Bun.spawn(
[process.execPath, join(directory, 'extract-manifest.js')],
{
cwd: directory,
env,
stdin: new Blob([source]),
stdout: 'pipe',
stderr: 'pipe',
}
);
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
if (exitCode !== 0) {
throw new ConfigurationError(
`Could not extract the data app manifest.\n${stderr.trim()}`
);
}
const { manifest, diagnostics } = JSON.parse(stdout) as {
manifest: { scope?: unknown } | null;
diagnostics: { message: string; start: { line: number; column: number } }[];
};
if (!manifest) {
const details = diagnostics
.slice(0, 3)
.map(({ message, start }) => `${start.line}:${start.column}: ${message}`)
.join('\n');
throw new ConfigurationError(
`Could not extract the data app manifest.\n${details}`
);
}
assertAppScope(manifest.scope, profileAppScope(profile), 'data app file');
return directory;
}
2 changes: 1 addition & 1 deletion cli/src/commands/app/lib/payload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readDataAppPayload } from '@/commands/app/lib/distribution.ts';

// Source execution reads the starter template. Release builds replace this module with literal data.
// Source execution reads project templates. Release builds replace this module with literal data.
export const dataAppPayload = await readDataAppPayload();
11 changes: 6 additions & 5 deletions data-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ showing Connected. App queries, data context, views, and appearance remain app-o

## Ownership and distribution

The CLI embeds only the starter allowlist in `cli/src/commands/app/lib/distribution.ts`.
The CLI embeds the starter and internal file-preview project allowlists in
`cli/src/commands/app/lib/distribution.ts`.
Both npm and native CLI builds can scaffold an app without reading this checkout or accessing
the network. Installing the generated app needs registry access or a populated Bun cache.
Generated projects commit their source, package manifest, and lockfile; they do not vendor runtime
Expand All @@ -42,10 +43,10 @@ The starter owns React, ReactDOM, and its authoring tools. The package owns its
dependencies. The browser entry calls `injectDataAppStyles()` before mounting. The app's `AGENTS.md`
points to the installed package guide and docs; dependency guides are not assumed to load automatically.

To adopt a package release, update the exact pins for packages used in `starter/package.json` and
`tests/package.json`, regenerate both lockfiles, and run the checks below. Never resolve `latest`
during app creation. Develop package changes in the package repository; the CLI has no runtime
source watcher.
To adopt a package release, update the exact pins for packages used in `starter/package.json`,
`tests/package.json`, and `file-preview/package.json`, regenerate their lockfiles, and run the checks
below. Never resolve `latest` during app creation. Develop package changes in the package
repository; the CLI has no runtime source watcher.

## Upgrades

Expand Down
Loading
Loading