Skip to content

Commit 207a6fb

Browse files
committed
feat: wire the sambar bin entry to dispatch run and build a macOS app bundle
1 parent 1c82575 commit 207a6fb

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"url": "https://github.com/indrajeetor/sambar/issues"
1414
},
1515
"type": "module",
16+
"bin": {
17+
"sambar": "./src/cli/index.ts"
18+
},
1619
"main": "./src/index.ts",
1720
"types": "./src/index.ts",
1821
"exports": {

src/cli/index.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* The `sambar` command-line interface: `run`, `build`, `--help`, `--version`.
4+
*
5+
* This is the user-facing build/launch tool, not a runtime module — it uses
6+
* Bun/Node filesystem and process APIs only. Output goes through
7+
* `process.stdout`/`process.stderr` because Biome bans `console.*`.
8+
*/
9+
10+
import { buildMacApp } from './build-macos';
11+
import { type Command, parseArgs } from './parse-args';
12+
import { runApp } from './run';
13+
import { currentPlatform } from '../common/platform';
14+
import { SAMBAR_VERSION } from '../common/version';
15+
16+
const out = (text: string): void => {
17+
process.stdout.write(`${text}\n`);
18+
};
19+
20+
const err = (text: string): void => {
21+
process.stderr.write(`${text}\n`);
22+
};
23+
24+
const USAGE = `sambar ${SAMBAR_VERSION}
25+
26+
Usage:
27+
sambar run <entry.ts> [args...] Launch a Sambar app (bun run <entry>)
28+
sambar build <entry.ts> [options] Bundle a distributable app
29+
sambar --help Show this help
30+
sambar --version Print the Sambar version
31+
32+
build options:
33+
--name <Name> Display/bundle name (default: derived from <entry>)
34+
--id <bundle.id> Bundle identifier (default: com.sambar.<name-slug>)
35+
--out <dir> Output directory (default: current directory)
36+
--icon <path.icns> App icon (.icns)
37+
38+
Currently 'sambar build' produces a macOS .app on macOS hosts.`;
39+
40+
/** Derive a default app name from the entry path's base file name. */
41+
const deriveName = (entry: string): string => {
42+
const base = entry.split(/[\\/]/).pop() ?? entry;
43+
const stem = base.replace(/\.[^.]+$/, '');
44+
return stem.length > 0 ? stem : 'SambarApp';
45+
};
46+
47+
const runBuild = async (command: Extract<Command, { kind: 'build' }>): Promise<number> => {
48+
if (currentPlatform() !== 'macos') {
49+
err(
50+
`sambar build: not yet supported on ${currentPlatform()} (macOS .app is the only target today).`,
51+
);
52+
return 1;
53+
}
54+
const name = command.options.name ?? deriveName(command.entry);
55+
const appPath = await buildMacApp({
56+
entry: command.entry,
57+
name,
58+
...(command.options.id !== undefined ? { id: command.options.id } : {}),
59+
...(command.options.out !== undefined ? { out: command.options.out } : {}),
60+
...(command.options.icon !== undefined ? { icon: command.options.icon } : {}),
61+
});
62+
out(appPath);
63+
return 0;
64+
};
65+
66+
/** Execute a parsed {@link Command} and resolve to the process exit code. */
67+
export const dispatch = async (command: Command): Promise<number> => {
68+
switch (command.kind) {
69+
case 'help':
70+
out(USAGE);
71+
return 0;
72+
case 'version':
73+
out(SAMBAR_VERSION);
74+
return 0;
75+
case 'run':
76+
return await runApp(command.entry, command.args);
77+
case 'build':
78+
return await runBuild(command);
79+
case 'error':
80+
err(command.message);
81+
err('');
82+
err(USAGE);
83+
return 1;
84+
}
85+
};
86+
87+
const main = async (): Promise<void> => {
88+
const command = parseArgs(process.argv.slice(2));
89+
process.exit(await dispatch(command));
90+
};
91+
92+
// Only auto-run when invoked as the CLI entry, never on import (e.g. in tests).
93+
if (import.meta.main) {
94+
await main();
95+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2+
import { spawnSync } from 'node:child_process';
3+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { buildMacApp } from '../../../src/cli/build-macos';
7+
import { currentPlatform } from '../../../src/common/platform';
8+
import { SAMBAR_VERSION } from '../../../src/common/version';
9+
10+
/**
11+
* Integration test for the macOS `.app` bundler. It writes a trivial entry
12+
* (not a real Sambar app — the bundler only packages it), compiles it with
13+
* `bun build --compile`, lays out the `.app`, then asserts the on-disk
14+
* structure. The produced binary is exec'd to confirm it actually runs.
15+
*/
16+
if (currentPlatform() === 'macos') {
17+
describe('buildMacApp (integration)', () => {
18+
let workDir: string;
19+
let entry: string;
20+
let outDir: string;
21+
const name = 'Hi App';
22+
23+
beforeAll(() => {
24+
workDir = mkdtempSync(join(tmpdir(), 'sambar-cli-build-'));
25+
entry = join(workDir, 'entry.ts');
26+
outDir = join(workDir, 'out');
27+
writeFileSync(entry, "console.log('hi');\nprocess.exit(0);\n");
28+
});
29+
30+
afterAll(() => {
31+
rmSync(workDir, { recursive: true, force: true });
32+
});
33+
34+
test('produces a structurally valid .app from a trivial entry', async () => {
35+
const appPath = await buildMacApp({
36+
entry,
37+
name,
38+
id: 'com.example.hi',
39+
out: outDir,
40+
});
41+
42+
expect(appPath).toBe(join(outDir, `${name}.app`));
43+
expect(existsSync(appPath)).toBe(true);
44+
45+
const infoPlist = join(appPath, 'Contents', 'Info.plist');
46+
expect(existsSync(infoPlist)).toBe(true);
47+
const plistText = readFileSync(infoPlist, 'utf8');
48+
expect(plistText).toContain('<key>CFBundleIdentifier</key>');
49+
expect(plistText).toContain('com.example.hi');
50+
expect(plistText).toContain(name);
51+
expect(plistText).toContain(SAMBAR_VERSION);
52+
53+
const exe = join(appPath, 'Contents', 'MacOS', name);
54+
expect(existsSync(exe)).toBe(true);
55+
const mode = statSync(exe).mode;
56+
// Executable bit set for owner/group/other.
57+
expect(mode & 0o111).not.toBe(0);
58+
59+
// The compiled binary should actually run and print 'hi'.
60+
const result = spawnSync(exe, [], { encoding: 'utf8' });
61+
expect(result.status).toBe(0);
62+
expect(result.stdout).toContain('hi');
63+
}, 30000);
64+
65+
test('defaults the bundle id from the name when --id is omitted', async () => {
66+
const appPath = await buildMacApp({
67+
entry,
68+
name: 'Defaulted',
69+
out: join(workDir, 'out2'),
70+
});
71+
const plistText = readFileSync(join(appPath, 'Contents', 'Info.plist'), 'utf8');
72+
expect(plistText).toContain('com.sambar.defaulted');
73+
}, 30000);
74+
});
75+
}

0 commit comments

Comments
 (0)