Skip to content
Open
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 packages/boxel-cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export {
BoxelCLIClient,
type CreateRealmOptions,
type CreateRealmResult,
type PullOptions,
type PullResult,
} from './src/lib/boxel-cli-client';
43 changes: 27 additions & 16 deletions packages/boxel-cli/src/commands/realm/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface PullOptions extends SyncOptions {

class RealmPuller extends RealmSyncBase {
hasError = false;
downloadedFiles: string[] = [];

constructor(
private pullOptions: PullOptions,
Expand Down Expand Up @@ -106,7 +107,7 @@ class RealmPuller extends RealmSyncBase {
}),
),
);
const downloadedFiles = downloadResults.filter(
this.downloadedFiles = downloadResults.filter(
(f): f is string => f !== null,
);

Expand Down Expand Up @@ -138,10 +139,10 @@ class RealmPuller extends RealmSyncBase {

if (
!this.options.dryRun &&
downloadedFiles.length + deletedFiles.length > 0
this.downloadedFiles.length + deletedFiles.length > 0
) {
const pullChanges: CheckpointChange[] = [
...downloadedFiles.map((f) => ({
...this.downloadedFiles.map((f) => ({
file: f,
status: 'modified' as const,
})),
Expand Down Expand Up @@ -189,23 +190,28 @@ export function registerPullCommand(realm: Command): void {
localDir: string,
options: { delete?: boolean; dryRun?: boolean },
) => {
await pullCommand(realmUrl, localDir, options);
let result = await pull(realmUrl, localDir, options);
if (result.error) {
console.error(`Error: ${result.error}`);
process.exit(result.files.length > 0 ? 2 : 1);
}
console.log('Pull completed successfully');
},
);
}

export async function pullCommand(
export async function pull(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can drop the pullCommand function and use this function in registerPullCommand and move the console.log and process.exit that previously handled in pullCommand to registerPullCommand.

realmUrl: string,
localDir: string,
options: PullCommandOptions,
): Promise<void> {
): Promise<{ files: string[]; error?: string }> {
let pm = options.profileManager ?? getProfileManager();
let active = pm.getActiveProfile();
if (!active) {
console.error(
'Error: no active profile. Run `boxel profile add` to create one.',
);
process.exit(1);
return {
files: [],
error: 'No active profile. Run `boxel profile add` to create one.',
};
Comment on lines +203 to +214
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New programmatic pull() is introduced to return { files, error? } instead of calling process.exit(), but existing integration coverage in this repo appears to exercise pullCommand() only. Add a test that calls pull() directly (and/or BoxelCLIClient.pull()) to verify it returns an error string on failure conditions (e.g., no active profile / partial download errors) and does not terminate the process.

Copilot uses AI. Check for mistakes.
}

try {
Expand All @@ -222,13 +228,18 @@ export async function pullCommand(
await puller.sync();

if (puller.hasError) {
console.log('Pull did not complete successfully. View logs for details');
process.exit(2);
} else {
console.log('Pull completed successfully');
return {
files: puller.downloadedFiles.sort(),
error:
'Pull completed with errors. Some files may not have been downloaded.',
};
}

return { files: puller.downloadedFiles.sort() };
} catch (error) {
console.error('Pull failed:', error);
process.exit(1);
return {
files: [],
error: `Pull failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
23 changes: 23 additions & 0 deletions packages/boxel-cli/src/lib/boxel-cli-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRealm as coreCreateRealm } from '../commands/realm/create';
import { pull as realmPull } from '../commands/realm/pull';
import { getProfileManager, type ProfileManager } from './profile-manager';

export interface CreateRealmOptions {
Expand All @@ -21,6 +22,17 @@ export interface CreateRealmResult {
authorization: string;
}

export interface PullOptions {
/** Delete local files that don't exist in the realm (default: false). */
delete?: boolean;
}

export interface PullResult {
/** Relative file paths that were downloaded. */
files: string[];
error?: string;
}

export class BoxelCLIClient {
private pm: ProfileManager;

Expand Down Expand Up @@ -59,6 +71,17 @@ export class BoxelCLIClient {
};
}

async pull(
realmUrl: string,
localDir: string,
options?: PullOptions,
): Promise<PullResult> {
return realmPull(realmUrl, localDir, {
delete: options?.delete,
profileManager: this.pm,
});
}

async createRealm(options: CreateRealmOptions): Promise<CreateRealmResult> {
let result = await coreCreateRealm(options.realmName, options.displayName, {
background: options.backgroundURL,
Expand Down
61 changes: 48 additions & 13 deletions packages/boxel-cli/tests/integration/realm-pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { pullCommand } from '../../src/commands/realm/pull';
import { pull } from '../../src/commands/realm/pull';
import { CheckpointManager } from '../../src/lib/checkpoint-manager';
import { ProfileManager } from '../../src/lib/profile-manager';
import {
startTestRealmServer,
stopTestRealmServer,
createTestProfileDir,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration';
import type { ProfileManager } from '../../src/lib/profile-manager';

let profileManager: ProfileManager;
let cleanupProfile: () => void;
Expand Down Expand Up @@ -56,7 +56,11 @@ describe('realm pull (integration)', () => {
it('pulls seeded files into an empty local directory', async () => {
let localDir = makeLocalDir();

await pullCommand(realmUrl, localDir, { profileManager });
let result = await pull(realmUrl, localDir, { profileManager });

expect(result.error).toBeUndefined();
expect(result.files).toContain('hello.gts');
expect(result.files).toContain('nested/card.gts');

let helloPath = path.join(localDir, 'hello.gts');
let nestedPath = path.join(localDir, 'nested', 'card.gts');
Expand All @@ -81,7 +85,12 @@ describe('realm pull (integration)', () => {
it('writes nothing when invoked with --dry-run', async () => {
let localDir = makeLocalDir();

await pullCommand(realmUrl, localDir, { dryRun: true, profileManager });
let result = await pull(realmUrl, localDir, {
dryRun: true,
profileManager,
});

expect(result.error).toBeUndefined();

let entries = fs
.readdirSync(localDir)
Expand All @@ -102,8 +111,9 @@ describe('realm pull (integration)', () => {
'{"local":"only"}',
);

await pullCommand(realmUrl, localDir, { profileManager });
let result = await pull(realmUrl, localDir, { profileManager });

expect(result.error).toBeUndefined();
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
expect(fs.existsSync(path.join(localOnlyDir, 'local-only.json'))).toBe(
true,
Expand All @@ -116,11 +126,12 @@ describe('realm pull (integration)', () => {
let stalePath = path.join(localDir, staleRel);
fs.writeFileSync(stalePath, 'export const stale = true;\n', 'utf8');

await pullCommand(realmUrl, localDir, {
let result = await pull(realmUrl, localDir, {
delete: true,
profileManager,
});

expect(result.error).toBeUndefined();
expect(fs.existsSync(stalePath)).toBe(false);
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);

Expand All @@ -140,8 +151,9 @@ describe('realm pull (integration)', () => {
it('pulls subdirectories recursively', async () => {
let localDir = makeLocalDir();

await pullCommand(realmUrl, localDir, { profileManager });
let result = await pull(realmUrl, localDir, { profileManager });

expect(result.error).toBeUndefined();
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
expect(fs.existsSync(path.join(localDir, 'nested', 'card.gts'))).toBe(true);
expect(
Expand All @@ -160,7 +172,7 @@ describe('realm pull (integration)', () => {
let stalePath = path.join(localDir, 'stale.gts');
fs.writeFileSync(stalePath, 'export const stale = true;\n', 'utf8');

await pullCommand(realmUrl, localDir, {
await pull(realmUrl, localDir, {
delete: true,
dryRun: true,
profileManager,
Expand All @@ -173,7 +185,7 @@ describe('realm pull (integration)', () => {
it('creates only a post-pull checkpoint when --delete has nothing to delete', async () => {
let localDir = makeLocalDir();

await pullCommand(realmUrl, localDir, {
await pull(realmUrl, localDir, {
delete: true,
profileManager,
});
Expand All @@ -188,11 +200,11 @@ describe('realm pull (integration)', () => {
it('re-pulling an up-to-date directory adds no new checkpoint', async () => {
let localDir = makeLocalDir();

await pullCommand(realmUrl, localDir, { profileManager });
await pull(realmUrl, localDir, { profileManager });
let cm = new CheckpointManager(localDir);
let afterFirst = (await cm.getCheckpoints()).length;

await pullCommand(realmUrl, localDir, { profileManager });
await pull(realmUrl, localDir, { profileManager });
let afterSecond = (await cm.getCheckpoints()).length;

expect(afterSecond).toBe(afterFirst);
Expand All @@ -204,7 +216,7 @@ describe('realm pull (integration)', () => {
// sanity: directory does not exist before the pull
expect(fs.existsSync(localDir)).toBe(false);

await pullCommand(realmUrl, localDir, { profileManager });
await pull(realmUrl, localDir, { profileManager });

expect(fs.existsSync(localDir)).toBe(true);
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
Expand All @@ -219,7 +231,7 @@ describe('realm pull (integration)', () => {
let helloPath = path.join(localDir, 'hello.gts');
fs.writeFileSync(helloPath, 'export const hello = "local-edit";\n');

await pullCommand(realmUrl, localDir, { profileManager });
await pull(realmUrl, localDir, { profileManager });

expect(fs.readFileSync(helloPath, 'utf8')).toContain('hello = "world"');

Expand All @@ -228,4 +240,27 @@ describe('realm pull (integration)', () => {
expect(checkpoints.length).toBe(1);
expect(checkpoints[0].source).toBe('remote');
});

it('returns an error (not process.exit) when no active profile is configured', async () => {
let emptyProfile = createTestProfileDir();
try {
let result = await pull(realmUrl, makeLocalDir(), {
profileManager: emptyProfile.profileManager,
});
expect(result.files).toEqual([]);
expect(result.error).toContain('No active profile');
} finally {
emptyProfile.cleanup();
}
});

it('returns an error when the realm URL is unreachable', async () => {
let localDir = makeLocalDir();
let result = await pull('http://127.0.0.1:1/nonexistent/', localDir, {
profileManager,
});

expect(result.error).toBeDefined();
expect(result.files).toEqual([]);
});
});
Loading