mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(cli): add ccs bar command for the macOS menu bar app
Add ccs bar (install/launch/uninstall/version) and the ~/.ccs/bar.json discovery handshake the app reads. install resolves a floating release asset, follows redirects, validates the download host over HTTPS, checks status, verifies the extracted app, and runs a real version-compat handshake against /api/overview.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* `ccs bar` command dispatcher
|
||||
*
|
||||
* Mirrors the pattern in src/commands/docker/index.ts.
|
||||
* Subcommands: launch (default), install, uninstall, version / --version.
|
||||
*/
|
||||
|
||||
export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
|
||||
// --version / version are aliases for the version subcommand
|
||||
if (subcommand === '--version' || subcommand === 'version') {
|
||||
const { handleBarVersion } = await import('./version-subcommand');
|
||||
await handleBarVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
const commandHandlers: Record<string, (subArgs: string[]) => Promise<void>> = {
|
||||
launch: async (subArgs) => {
|
||||
const { handleBarLaunch } = await import('./launch-subcommand');
|
||||
await handleBarLaunch(subArgs);
|
||||
},
|
||||
install: async (subArgs) => {
|
||||
const { handleBarInstall } = await import('./install-subcommand');
|
||||
await handleBarInstall(subArgs);
|
||||
},
|
||||
uninstall: async (subArgs) => {
|
||||
const { handleBarUninstall } = await import('./uninstall-subcommand');
|
||||
await handleBarUninstall(subArgs);
|
||||
},
|
||||
};
|
||||
|
||||
// Bare `ccs bar` → launch
|
||||
if (!subcommand || subcommand === 'launch') {
|
||||
await commandHandlers.launch(subcommand ? args.slice(1) : []);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = commandHandlers[subcommand];
|
||||
if (!handler) {
|
||||
console.error(`[X] Unknown bar subcommand: ${subcommand}`);
|
||||
console.error('[i] Usage: ccs bar [launch|install|uninstall|--version]');
|
||||
return;
|
||||
}
|
||||
|
||||
await handler(args.slice(1));
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* `ccs bar install` — download the CCS Bar app from the floating
|
||||
* `ccs-bar-latest` GitHub release tag and install to ~/Applications.
|
||||
*
|
||||
* Intentionally uses a FLOATING tag (not the exact CLI version) so the
|
||||
* Swift app can be rebuilt and published independently. After install the
|
||||
* handler calls GET /api/overview to verify version compatibility and warns
|
||||
* (but does not hard-fail) on mismatch.
|
||||
*
|
||||
* Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Floating release tag — never pin to exact CLI semver. */
|
||||
const BAR_RELEASE_TAG = 'ccs-bar-latest';
|
||||
const BAR_APP_NAME = 'CCS Bar.app';
|
||||
const BAR_ASSET_NAME = 'CCS-Bar.app.zip';
|
||||
const BAR_GITHUB_REPO = 'kaitranntt/ccs';
|
||||
|
||||
/**
|
||||
* Allowlist of hostnames from which we will accept asset downloads.
|
||||
* GitHub releases redirect from github.com to objects.githubusercontent.com.
|
||||
*
|
||||
* TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file,
|
||||
* wire SHA-256 verification here. The download URL is already validated for
|
||||
* host+HTTPS as a v1 minimum guard. The verifier hook below is the intended
|
||||
* extension point.
|
||||
*/
|
||||
const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray<string> = [
|
||||
'github.com',
|
||||
'objects.githubusercontent.com',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReleaseAssetResult {
|
||||
downloadUrl: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface CompatResult {
|
||||
version: string;
|
||||
compatible: boolean;
|
||||
}
|
||||
|
||||
export interface InstallDeps {
|
||||
/**
|
||||
* Resolve the asset download URL + version string from a GitHub release tag.
|
||||
* Production: calls GitHub API releases/tags/{tag}.
|
||||
* Test: mock that returns a fake URL + version.
|
||||
*/
|
||||
fetchReleaseAsset: (tag: string, asset: string) => Promise<ReleaseAssetResult>;
|
||||
/**
|
||||
* Download the zip archive and extract the .app bundle into dest/.
|
||||
* Production: uses undici to stream + extract (with redirect + status check).
|
||||
*/
|
||||
downloadAndExtract: (url: string, dest: string) => Promise<void>;
|
||||
/**
|
||||
* Call GET {baseUrl}/api/overview and return { version, compatible }.
|
||||
* compatible = server version major === installed app version major.
|
||||
* Returns compatible:false (never true) when versions cannot be compared.
|
||||
*/
|
||||
verifyCompat: (baseUrl: string, installedVersion: string) => Promise<CompatResult>;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME). */
|
||||
getCcsDir: () => string;
|
||||
/** Destination directory for the .app bundle (~/Applications by default). */
|
||||
getAppsDir: () => string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host allowlist validation (Finding #9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate that a download URL is https and its hostname is in the allowlist
|
||||
* (exact match or *.githubusercontent.com wildcard).
|
||||
* Throws a descriptive Error if validation fails.
|
||||
*/
|
||||
export function validateDownloadUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Download URL is not a valid URL: ${url}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error(`Download URL must use HTTPS, got: ${parsed.protocol} for ${url}`);
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const allowed =
|
||||
(DOWNLOAD_HOST_ALLOWLIST as string[]).includes(host) || host.endsWith('.githubusercontent.com');
|
||||
|
||||
if (!allowed) {
|
||||
throw new Error(
|
||||
`Download URL hostname "${host}" is not in the trusted allowlist ` +
|
||||
`(${DOWNLOAD_HOST_ALLOWLIST.join(', ')}, *.githubusercontent.com). ` +
|
||||
`Refusing to download from untrusted host.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production implementation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<ReleaseAssetResult> {
|
||||
const { request } = await import('undici');
|
||||
const apiUrl = `https://api.github.com/repos/${BAR_GITHUB_REPO}/releases/tags/${tag}`;
|
||||
const { statusCode, body } = await request(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'ccs-cli',
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new Error(`GitHub API returned ${statusCode} for tag ${tag}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const release = (await body.json()) as any;
|
||||
const tagName: string = release.tag_name ?? tag;
|
||||
// Strip leading 'v' for the version pin file.
|
||||
const version = tagName.replace(/^v/, '');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset);
|
||||
if (!found) {
|
||||
throw new Error(`Asset "${asset}" not found in release ${tag}`);
|
||||
}
|
||||
|
||||
return { downloadUrl: found.browser_download_url as string, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a zip from `url` (following up to 5 GitHub 302 redirects) and
|
||||
* extract its contents into `dest`.
|
||||
*
|
||||
* Fixes applied:
|
||||
* - Finding #8: pass `maxRedirections: 5` so GitHub's 302 → objects.githubusercontent.com is followed.
|
||||
* - Finding #11: check `statusCode` and throw a descriptive error before streaming.
|
||||
* - Finding #9: validate host+HTTPS before making the request.
|
||||
*/
|
||||
async function defaultDownloadAndExtract(url: string, dest: string): Promise<void> {
|
||||
const { request } = await import('undici');
|
||||
const { createWriteStream, mkdirSync } = fs;
|
||||
const { promisify } = await import('util');
|
||||
const { pipeline } = await import('stream');
|
||||
const streamPipeline = promisify(pipeline);
|
||||
const { execFile } = await import('child_process');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Finding #9: validate host + HTTPS before any network request.
|
||||
validateDownloadUrl(url);
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`);
|
||||
|
||||
// Finding #8: maxRedirections:5 ensures GitHub's 302 to objects.githubusercontent.com is followed.
|
||||
// Finding #11: destructure statusCode and reject non-200 before streaming.
|
||||
const { statusCode, body } = await request(url, {
|
||||
maxRedirections: 5,
|
||||
});
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new Error(`Download failed: HTTP ${statusCode} for ${url}`);
|
||||
}
|
||||
|
||||
// Stream body to tmpZip — undici body is a Node ReadableStream
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await streamPipeline(body as any, createWriteStream(tmpZip));
|
||||
|
||||
// Extract the zip into dest
|
||||
await execFileAsync('unzip', ['-o', tmpZip, '-d', dest]);
|
||||
|
||||
// Clean up the temp archive
|
||||
try {
|
||||
fs.unlinkSync(tmpZip);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET {baseUrl}/api/overview and compare server version against the
|
||||
* installed bar version. Returns compat=false whenever the comparison cannot
|
||||
* be made (server unreachable, version strings unparseable, etc.).
|
||||
*
|
||||
* Fix for Finding #10: replaces the phantom check that always returned true.
|
||||
* Compatible is defined as same semver major (0 vs 0, 1 vs 1, etc.).
|
||||
*/
|
||||
async function defaultVerifyCompat(
|
||||
baseUrl: string,
|
||||
installedVersion: string
|
||||
): Promise<CompatResult> {
|
||||
try {
|
||||
const { request } = await import('undici');
|
||||
const { statusCode, body } = await request(`${baseUrl}/api/overview`, {
|
||||
headers: { 'User-Agent': 'ccs-cli' },
|
||||
});
|
||||
if (statusCode !== 200) {
|
||||
console.log(
|
||||
`[!] Version compatibility check failed: /api/overview returned HTTP ${statusCode}.`
|
||||
);
|
||||
return { version: 'unknown', compatible: false };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = (await body.json()) as any;
|
||||
const serverVersion: string = (data.version as string) ?? 'unknown';
|
||||
|
||||
if (serverVersion === 'unknown') {
|
||||
console.log('[!] Version compatibility: server did not report a version.');
|
||||
return { version: serverVersion, compatible: false };
|
||||
}
|
||||
|
||||
// Parse installed version major from the pinned version string.
|
||||
const installedMajorRaw = installedVersion.split('.')[0];
|
||||
const serverMajorRaw = serverVersion.split('.')[0];
|
||||
const installedMajor = parseInt(installedMajorRaw ?? '', 10);
|
||||
const serverMajor = parseInt(serverMajorRaw ?? '', 10);
|
||||
|
||||
if (isNaN(installedMajor) || isNaN(serverMajor)) {
|
||||
console.log(
|
||||
`[!] Version compatibility: cannot parse major versions ` +
|
||||
`(installed="${installedVersion}", server="${serverVersion}").`
|
||||
);
|
||||
return { version: serverVersion, compatible: false };
|
||||
}
|
||||
|
||||
const compatible = installedMajor === serverMajor;
|
||||
return { version: serverVersion, compatible };
|
||||
} catch {
|
||||
// Server unreachable — warn but do not claim compatible.
|
||||
return { version: 'unknown', compatible: false };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
function defaultGetAppsDir(): string {
|
||||
return path.join(os.homedir(), 'Applications');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version pin helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getBarVersionFilePath(ccsDir: string): string {
|
||||
return path.join(ccsDir, 'bar', '.version');
|
||||
}
|
||||
|
||||
function pinBarVersion(ccsDir: string, version: string): void {
|
||||
const versionFile = getBarVersionFilePath(ccsDir);
|
||||
fs.mkdirSync(path.dirname(versionFile), { recursive: true });
|
||||
fs.writeFileSync(versionFile, version);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarInstall(
|
||||
_args: string[],
|
||||
deps: Partial<InstallDeps> = {}
|
||||
): Promise<void> {
|
||||
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
|
||||
|
||||
console.log('[i] Fetching CCS Bar release info...');
|
||||
|
||||
// 1. Resolve the floating tag → download URL + version.
|
||||
let releaseInfo: ReleaseAssetResult;
|
||||
try {
|
||||
releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to fetch release asset: ${msg}`);
|
||||
console.error('[i] Check your network connection and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { downloadUrl, version } = releaseInfo;
|
||||
console.log(`[i] Installing CCS Bar v${version} from ${BAR_RELEASE_TAG}...`);
|
||||
|
||||
// 2. Download and extract into ~/Applications.
|
||||
try {
|
||||
await downloadAndExtract(downloadUrl, appsDir);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Download or extraction failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Finding #12: assert that the expected .app bundle was actually extracted
|
||||
// before reporting success.
|
||||
const appPath = path.join(appsDir, BAR_APP_NAME);
|
||||
if (!fs.existsSync(appPath)) {
|
||||
// Report what was actually extracted to help diagnose archive issues.
|
||||
let extracted: string[] = [];
|
||||
try {
|
||||
extracted = fs.readdirSync(appsDir);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const found = extracted.length > 0 ? extracted.join(', ') : '(none)';
|
||||
console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in ${appsDir}.`);
|
||||
console.error(`[i] Files found in ${appsDir}: ${found}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Pin the installed version to ~/.ccs/bar/.version.
|
||||
try {
|
||||
pinBarVersion(ccsDir, version);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Could not write version pin: ${msg}`);
|
||||
// Non-fatal — continue.
|
||||
}
|
||||
|
||||
console.log(`[OK] CCS Bar v${version} installed to ${appsDir}/${BAR_APP_NAME}`);
|
||||
|
||||
// 5. Version-compat handshake via /api/overview.
|
||||
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
let baseUrl = 'http://127.0.0.1:3000';
|
||||
try {
|
||||
const raw = fs.readFileSync(barJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { baseUrl?: string };
|
||||
if (parsed.baseUrl) baseUrl = parsed.baseUrl;
|
||||
} catch {
|
||||
/* bar.json absent — use fallback */
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass the pinned version so verifyCompat can do a real major-version comparison.
|
||||
const compat = await verifyCompat(baseUrl, version);
|
||||
if (!compat.compatible) {
|
||||
console.log(
|
||||
`[!] Version mismatch: CCS server reports v${compat.version}, ` +
|
||||
`app is v${version}. Some features may not work until you restart ` +
|
||||
'`ccs bar` or update CCS.'
|
||||
);
|
||||
} else {
|
||||
console.log(`[OK] Version compatibility confirmed (server: v${compat.version}).`);
|
||||
}
|
||||
} catch {
|
||||
console.log('[!] Could not verify version compatibility (server may not be running).');
|
||||
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||
}
|
||||
|
||||
// 6. Print Gatekeeper guidance for ad-hoc/unsigned builds.
|
||||
console.log('');
|
||||
console.log('[i] Gatekeeper note (ad-hoc build):');
|
||||
console.log(' If macOS says the app is "damaged" or "unverified", run:');
|
||||
console.log(` xattr -dr com.apple.quarantine "${path.join(appsDir, BAR_APP_NAME)}"`);
|
||||
console.log(' Or right-click the app and select Open.');
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* `ccs bar launch` — ensure web-server is up, write ~/.ccs/bar.json, open the app.
|
||||
*
|
||||
* bar.json shape (v1):
|
||||
* { baseUrl: string, port: number, authMode: "loopback" }
|
||||
*
|
||||
* authMode is always "loopback" for v1 (auth-enabled unsupported until v1.1).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BarDiscoveryJson {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
authMode: 'loopback';
|
||||
}
|
||||
|
||||
export interface DashboardInfo {
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface LaunchDeps {
|
||||
/**
|
||||
* Ensure the CCS web-server / dashboard is running.
|
||||
* Returns { port, baseUrl } of the running server.
|
||||
* Throws if the server cannot be started (degraded path).
|
||||
*/
|
||||
ensureDashboard: () => Promise<DashboardInfo>;
|
||||
/** Open the installed .app bundle. Throws if the app is not found. */
|
||||
openApp: (appPath: string) => Promise<void>;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
|
||||
getCcsDir: () => string;
|
||||
/** Full path where the .app should be installed, e.g. ~/Applications/CCS Bar.app */
|
||||
appInstallPath: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port discovery helpers (exported for unit tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the port recorded in an existing bar.json.
|
||||
* Returns null when the file is absent or malformed.
|
||||
*/
|
||||
export function resolveBarPort(ccsDir: string): number | null {
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(barJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<BarDiscoveryJson>;
|
||||
return typeof parsed.port === 'number' ? parsed.port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default production dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function defaultEnsureDashboard(): Promise<DashboardInfo> {
|
||||
// Reuse the same startup path as `ccs config`:
|
||||
// find a free port then start the web-server via startServer().
|
||||
const getPort = (await import('get-port')).default;
|
||||
const { startServer } = await import('../../web-server');
|
||||
|
||||
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080] });
|
||||
const { server } = await startServer({ port });
|
||||
|
||||
const addr = server.address();
|
||||
const resolvedPort = addr && typeof addr === 'object' ? addr.port : port;
|
||||
const baseUrl = `http://127.0.0.1:${resolvedPort}`;
|
||||
return { port: resolvedPort, baseUrl };
|
||||
}
|
||||
|
||||
async function defaultOpenApp(appPath: string): Promise<void> {
|
||||
const { execFile } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
await execFileAsync('open', ['-a', appPath]);
|
||||
}
|
||||
|
||||
function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
const DEFAULT_APP_INSTALL_PATH = path.join(
|
||||
process.env.HOME ?? process.env.CCS_HOME ?? '~',
|
||||
'Applications',
|
||||
'CCS Bar.app'
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarLaunch(
|
||||
_args: string[],
|
||||
deps: Partial<LaunchDeps> = {}
|
||||
): Promise<void> {
|
||||
const ensureDashboard = deps.ensureDashboard ?? defaultEnsureDashboard;
|
||||
const openApp = deps.openApp ?? defaultOpenApp;
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
|
||||
|
||||
// 1. Ensure the web-server/dashboard is running.
|
||||
let dashboardInfo: DashboardInfo;
|
||||
try {
|
||||
dashboardInfo = await ensureDashboard();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not start CCS web-server: ${msg}`);
|
||||
console.error('[i] Run `ccs config` to start the dashboard manually.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Write bar.json — this is the single source of discovery for the Swift app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: dashboardInfo.baseUrl,
|
||||
port: dashboardInfo.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
|
||||
console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`);
|
||||
|
||||
// 3. Open the app.
|
||||
try {
|
||||
await openApp(appInstallPath);
|
||||
console.log('[OK] CCS Bar launched.');
|
||||
} catch {
|
||||
// Degraded path: app not installed or open failed.
|
||||
if (!fs.existsSync(appInstallPath)) {
|
||||
console.log('[!] CCS Bar app is not installed.');
|
||||
console.log('[i] Run `ccs bar install` to install it.');
|
||||
} else {
|
||||
console.log('[!] Could not open CCS Bar. Try right-clicking and selecting Open.');
|
||||
console.log('[i] If Gatekeeper blocks the app, run:');
|
||||
console.log(` xattr -dr com.apple.quarantine "${appInstallPath}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `ccs bar uninstall` — remove CCS Bar.app from ~/Applications
|
||||
* and clear the version pin at ~/.ccs/bar/.version.
|
||||
*
|
||||
* No-op (and no error) when neither the app nor the pin exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UninstallDeps {
|
||||
getCcsDir: () => string;
|
||||
getAppsDir: () => string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarUninstall(
|
||||
_args: string[],
|
||||
deps: Partial<UninstallDeps> = {}
|
||||
): Promise<void> {
|
||||
const ccsDir = (deps.getCcsDir ?? (() => getCcsDir()))();
|
||||
const appsDir = (deps.getAppsDir ?? (() => path.join(os.homedir(), 'Applications')))();
|
||||
const appName = deps.appName ?? 'CCS Bar.app';
|
||||
|
||||
const appPath = path.join(appsDir, appName);
|
||||
const versionPin = path.join(ccsDir, 'bar', '.version');
|
||||
|
||||
let removed = false;
|
||||
|
||||
// Remove the .app bundle (a directory on macOS).
|
||||
if (fs.existsSync(appPath)) {
|
||||
try {
|
||||
fs.rmSync(appPath, { recursive: true, force: true });
|
||||
console.log(`[OK] Removed ${appPath}`);
|
||||
removed = true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to remove ${appPath}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the version pin.
|
||||
if (fs.existsSync(versionPin)) {
|
||||
try {
|
||||
fs.unlinkSync(versionPin);
|
||||
removed = true;
|
||||
} catch {
|
||||
// Non-fatal — pin may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
console.log('[i] CCS Bar is not installed — nothing to remove.');
|
||||
} else {
|
||||
console.log('[OK] CCS Bar uninstalled.');
|
||||
console.log('[i] Run `ccs bar install` to reinstall.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `ccs bar --version` / `ccs bar version`
|
||||
*
|
||||
* Prints the CCS CLI version alongside the installed CCS Bar app version
|
||||
* (read from ~/.ccs/bar/.version, if present).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getVersion } from '../../utils/version';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
function readInstalledBarVersion(ccsDir: string): string | null {
|
||||
const versionFile = path.join(ccsDir, 'bar', '.version');
|
||||
try {
|
||||
const content = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBarVersion(): Promise<void> {
|
||||
const cliVersion = getVersion();
|
||||
const ccsDir = getCcsDir();
|
||||
const barVersion = readInstalledBarVersion(ccsDir);
|
||||
|
||||
// Finding #13: label each line unambiguously — CLI version vs installed Bar app version.
|
||||
console.log(`[i] CCS CLI v${cliVersion}`);
|
||||
if (barVersion) {
|
||||
console.log(`[i] CCS Bar app: v${barVersion}`);
|
||||
} else {
|
||||
console.log('[i] CCS Bar app: not installed (run `ccs bar install`)');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -139,6 +139,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
||||
group: 'operations',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'bar',
|
||||
summary: 'Install and launch the CCS macOS menu bar app',
|
||||
group: 'operations',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'sync',
|
||||
summary: 'Sync delegation commands and skills',
|
||||
|
||||
@@ -193,6 +193,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||
await handleSetupCommand(args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bar',
|
||||
handle: async (args) => {
|
||||
const { handleBarCommand } = await import('./bar');
|
||||
await handleBarCommand(args);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user