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
36 changes: 34 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.1] - Unreleased
## [0.3.0] - Unreleased

Theme: Test Locally, Secure by Default. Catch workflow problems before pushing, and enforce least-privilege sandboxing.

### Added
- **Workflow Test Mode**: Run workflows locally before pushing with `localmost test`
- Intercepts `actions/checkout` to use local working tree
- Intercepts `actions/cache` for local caching
- Stubs `actions/upload-artifact` and `actions/download-artifact`
- Matrix support with `--full-matrix` and `--matrix` options
- Environment diff reporting with `--env` flag
- **Declarative Sandbox Policy**: Per-repo `.localmostrc` files that declare allowed access
- Default-deny sandbox for network and filesystem
- Per-workflow policy overrides
- Discovery mode with `localmost test --updaterc`
- Policy validation with `localmost policy validate`
- **Environment Comparison**: Detect differences between local and GitHub runner environments
- `localmost env` command shows local tooling versions
- Compare against any GitHub runner label
- Suggestions for pinning versions in workflows
- **Policy Cache**: Background runner caches and validates `.localmostrc` changes
- Requires approval when policy changes
- Diff display for policy modifications

### Changed
- CLI restructured with standalone commands that don't require the app
- Improved help text with examples for all commands

## [0.2.1] - 2025-12-26

### Fixed
- Minor bug fixes

## [0.2.0] - 2025-12-26

Expand All @@ -26,6 +57,7 @@ Core improvements to architecture to enable multiple targets.

Initial release of localmost, a Mac app which manages GitHub Actions runners.

[0.2.1]: https://github.com/bfulton/localmost/compare/v0.2.0...HEAD
[0.3.0]: https://github.com/bfulton/localmost/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/bfulton/localmost/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/bfulton/localmost/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/bfulton/localmost/releases/tag/v0.1.0
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,14 @@ npm run make

## Roadmap

Next release: [0.3.0 — Test Locally, Secure by Default](docs/roadmap/release-0.3.0.md)
Current release: **0.3.0 — Test Locally, Secure by Default**
- Run workflows locally before pushing with `localmost test`
- Declarative sandbox policies with `.localmostrc`
- Secure secrets management in macOS Keychain
- Environment comparison with GitHub runners

Future feature ideas:

- **Workflow testing mode** - Run and validate workflows locally before pushing. ([design](docs/roadmap/workflow-test-mode.md))
- **Declarative sandbox policy** - Per-repo `.localmostrc` files that specify allowed network and filesystem access, with audit logging. ([design](docs/roadmap/localmostrc.md))
- **Trusted contributors for public repos** - Control which repos can run on your machine based on their contributor list. Options: never build public repos, only build repos where all contributors are trusted (default: you + known bots, customizable), or always build (with high-friction confirmation). Repos with untrusted contributors fail with a clear error.
- **Graceful heartbeat shutdown** - On clean exit, immediately mark heartbeat stale so workflows fall back to cloud without waiting for the 90s timeout.
- **Quick actions** - Re-run failed job, cancel all jobs.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "localmost",
"productName": "localmost",
"version": "0.2.1",
"version": "0.3.0",
"description": "Run GitHub actions locally when possible.",
"author": "Bright Fulton",
"license": "GPL-3.0",
Expand Down
127 changes: 127 additions & 0 deletions src/cli/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* CLI Env Command
*
* Show local environment information and compare to GitHub runners.
*
* Usage:
* localmost env # Show local environment
* localmost env --compare macos-14 # Compare to specific runner
*/

import {
detectLocalEnvironment,
compareEnvironments,
formatEnvironmentInfo,
formatEnvironmentDiff,
GITHUB_RUNNER_ENVIRONMENTS,
} from '../shared/environment';

// ANSI colors
const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
yellow: '\x1b[33m',
};

// =============================================================================
// Types
// =============================================================================

export interface EnvOptions {
/** Runner to compare against */
compare?: string;
/** Show available runner labels */
list?: boolean;
}

// =============================================================================
// CLI Entry Point
// =============================================================================

/**
* Run the env command.
*/
export function runEnv(options: EnvOptions): void {
if (options.list) {
console.log(`${colors.bold}Available GitHub runner labels:${colors.reset}\n`);
for (const [label, env] of Object.entries(GITHUB_RUNNER_ENVIRONMENTS)) {
console.log(` ${label}`);
console.log(` macOS ${env.macosVersion}, Xcode ${env.xcodeVersion}, ${env.arch}`);
}
console.log();
console.log(`${colors.dim}Source: https://github.com/actions/runner-images${colors.reset}`);
return;
}

// Detect local environment
const localEnv = detectLocalEnvironment();
console.log(formatEnvironmentInfo(localEnv));

// Compare if requested
if (options.compare) {
console.log();
if (!GITHUB_RUNNER_ENVIRONMENTS[options.compare]) {
console.log(`${colors.yellow}Unknown runner: ${options.compare}${colors.reset}`);
console.log('Use --list to see available runners.');
return;
}

console.log(`${colors.bold}Comparing to ${options.compare}:${colors.reset}\n`);
const diffs = compareEnvironments(localEnv, options.compare);
console.log(formatEnvironmentDiff(diffs));
} else {
// Default comparison to macos-latest
console.log();
console.log(`${colors.bold}Comparing to macos-latest:${colors.reset}\n`);
const diffs = compareEnvironments(localEnv, 'macos-latest');
console.log(formatEnvironmentDiff(diffs));
}
}

/**
* Parse env command arguments.
*/
export function parseEnvArgs(args: string[]): EnvOptions {
const options: EnvOptions = {};

let i = 0;
while (i < args.length) {
const arg = args[i];

if (arg === '--compare' || arg === '-c') {
options.compare = args[++i];
} else if (arg === '--list' || arg === '-l') {
options.list = true;
}

i++;
}

return options;
}

/**
* Print env command help.
*/
export function printEnvHelp(): void {
console.log(`
${colors.bold}localmost env${colors.reset} - Show environment information

${colors.bold}USAGE:${colors.reset}
localmost env [options]

${colors.bold}OPTIONS:${colors.reset}
-c, --compare <runner> Compare to specific GitHub runner
-l, --list List available runner labels

${colors.bold}EXAMPLES:${colors.reset}
localmost env
localmost env --compare macos-14
localmost env --list

${colors.bold}PURPOSE:${colors.reset}
Shows your local development environment and compares it to GitHub-hosted
runners. This helps identify potential "works locally, fails in CI" issues.
`);
}
110 changes: 97 additions & 13 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#!/usr/bin/env node
/**
* localmost CLI companion
* localmost CLI
*
* Commands:
* localmost test - Run workflows locally (standalone, no app required)
* localmost policy - Manage sandbox policies
* localmost env - Show environment information
* localmost start - Start the localmost app
* localmost stop - Stop the localmost app
* localmost status - Show runner status
Expand All @@ -16,6 +19,9 @@ import * as fs from 'fs';
import * as path from 'path';
import { spawn } from 'child_process';
import { getCliSocketPath } from '../shared/paths';
import { runTest, parseTestArgs, printTestHelp } from './test';
import { runPolicy, parsePolicyArgs, printPolicyHelp } from './policy';
import { runEnv, parseEnvArgs, printEnvHelp } from './env';

interface CliRequest {
command: 'status' | 'pause' | 'resume' | 'jobs' | 'quit';
Expand Down Expand Up @@ -77,29 +83,39 @@ interface ErrorResponse {
type CliResponse = StatusResponse | JobsResponse | ActionResponse | ErrorResponse;

const HELP_TEXT = `
localmost - CLI companion for localmost app
localmost - Run GitHub Actions locally

USAGE:
localmost <command>
localmost <command> [options]

COMMANDS:
STANDALONE COMMANDS (no app required):
test Run workflows locally before pushing
policy Manage .localmostrc sandbox policies
env Show environment information

APP COMMANDS (requires running app):
start Start the localmost app
stop Stop the localmost app
status Show current runner status
pause Pause the runner (stops accepting jobs)
resume Resume the runner (start accepting jobs)
jobs Show recent job history
help Show this help message

EXAMPLES:
localmost start
localmost status
localmost pause
localmost stop

NOTE:
Most commands require the localmost app to be running.
Use 'localmost start' to launch the app first.
localmost test Run default workflow locally
localmost test --updaterc Generate .localmostrc from access
localmost policy show Display current policy
localmost env Show environment info
localmost start Launch background app
localmost status Check runner status

For command-specific help:
localmost test --help
localmost policy --help
localmost env --help

DOCUMENTATION:
https://github.com/bfulton/localmost
`;

function printHelp(): void {
Expand Down Expand Up @@ -434,6 +450,74 @@ async function main(): Promise<void> {
}

const command = args[0];
const subArgs = args.slice(1);

// =========================================================================
// STANDALONE COMMANDS (no app required)
// =========================================================================

// Test command - run workflows locally
if (command === 'test') {
if (subArgs.includes('--help') || subArgs.includes('-h')) {
printTestHelp();
process.exit(0);
}
try {
const options = parseTestArgs(subArgs);
const result = await runTest(options);
process.exit(result.success ? 0 : 1);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
}

// Policy command - manage .localmostrc
if (command === 'policy') {
if (subArgs.includes('--help') || subArgs.includes('-h')) {
printPolicyHelp();
process.exit(0);
}
try {
const { subcommand, options } = parsePolicyArgs(subArgs);
runPolicy(subcommand, options);
process.exit(0);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
}

// Env command - show environment info
if (command === 'env') {
if (subArgs.includes('--help') || subArgs.includes('-h')) {
printEnvHelp();
process.exit(0);
}
try {
const options = parseEnvArgs(subArgs);
runEnv(options);
process.exit(0);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
}

// Version command
if (command === 'version' || command === '--version' || command === '-v') {
try {
const packageJson = require('../../package.json');
console.log(`localmost ${packageJson.version}`);
} catch {
console.log('localmost (version unknown)');
}
process.exit(0);
}

// =========================================================================
// APP COMMANDS (require running app)
// =========================================================================

// Handle start command separately (doesn't need socket)
if (command === 'start') {
Expand Down
Loading