mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-18 07:40:09 +02:00
4274b1204d
The skill doc has been telling users to run `gstack-ios-qa-daemon` and `gstack-ios-qa-mint` since v1.41.0.0, but neither binary actually existed. Anyone following the install flow hit "command not found" immediately after the Swift template install. Adds the missing pieces: - bin/gstack-ios-qa-daemon — bash shim that execs `bun run ios-qa/daemon/src/index.ts`. Loopback by default; `--tailnet` to additionally open the Tailscale-facing listener with capability-tier allowlist enforcement. - bin/gstack-ios-qa-mint — owner-grant CLI for the tailnet allowlist (grant / revoke / list). Writes ~/.gstack/ios-qa-allowlist.json at mode 0600. Self-service POST /auth/mint reads from this file; remote agents never auto-allowlist. - ios-qa/daemon/src/cli-mint.ts — TS implementation behind the shim. Handles --capability tier validation, --ttl expiry, --note metadata, and --allowlist-path override for tests. - ios-qa/daemon/src/allowlist.ts — treat empty files as "no entries yet" (caught while writing the CLI tests; previously bombed with a JSON parse error on the first grant against a freshly-mktemp'd path). Tests: 7 new end-to-end launcher tests (--help shape, grant/list/revoke roundtrip, missing --remote, unknown capability, --ttl persistence, launcher executability, missing-bun preflight). All 81 daemon tests pass. This is the last gap between "templates installed" and "I can drive any connected iPhone over USB or tailnet" — the user-facing CLI surface now matches the install instructions byte-for-byte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
// CLI tests for gstack-ios-qa-mint. Invokes the bash launcher end-to-end
|
|
// so we catch any breakage between bin/, the entry-point resolution, and
|
|
// the underlying allowlist primitives. Runs against a temp allowlist path
|
|
// so the user's real ~/.gstack/ios-qa-allowlist.json is untouched.
|
|
|
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, rmSync, readFileSync, statSync, existsSync, chmodSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = join(import.meta.dir, '..', '..', '..');
|
|
const MINT_BIN = join(ROOT, 'bin', 'gstack-ios-qa-mint');
|
|
const DAEMON_BIN = join(ROOT, 'bin', 'gstack-ios-qa-daemon');
|
|
|
|
function runMint(args: string[]) {
|
|
return spawnSync(MINT_BIN, args, { stdio: 'pipe', encoding: 'utf-8' });
|
|
}
|
|
|
|
describe('bin/gstack-ios-qa-mint launcher', () => {
|
|
let tmpDir: string;
|
|
let listPath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-cli-mint-'));
|
|
listPath = join(tmpDir, 'allowlist.json');
|
|
});
|
|
|
|
test('--help prints usage without touching allowlist', () => {
|
|
const r = runMint(['--help']);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('gstack-ios-qa-mint');
|
|
expect(r.stdout).toContain('grant');
|
|
expect(r.stdout).toContain('revoke');
|
|
expect(r.stdout).toContain('list');
|
|
});
|
|
|
|
test('grant + list + revoke roundtrip', () => {
|
|
const grant = runMint([
|
|
'grant', '--remote', 'alice@example.com',
|
|
'--capability', 'interact',
|
|
'--allowlist-path', listPath,
|
|
]);
|
|
expect(grant.status).toBe(0);
|
|
expect(grant.stdout).toContain('granted alice@example.com');
|
|
|
|
// File must exist and be mode 0600 (owner-only). Mint creates the
|
|
// parent directory with 0700 + writes the file at 0600.
|
|
expect(existsSync(listPath)).toBe(true);
|
|
const mode = statSync(listPath).mode & 0o777;
|
|
expect(mode).toBe(0o600);
|
|
|
|
const list = runMint(['list', '--allowlist-path', listPath]);
|
|
expect(list.status).toBe(0);
|
|
expect(list.stdout).toContain('alice@example.com');
|
|
expect(list.stdout).toContain('cap=interact');
|
|
|
|
const revoke = runMint(['revoke', '--remote', 'alice@example.com', '--allowlist-path', listPath]);
|
|
expect(revoke.status).toBe(0);
|
|
|
|
const listAfter = runMint(['list', '--allowlist-path', listPath]);
|
|
expect(listAfter.status).toBe(0);
|
|
expect(listAfter.stdout).toContain('(empty allowlist)');
|
|
});
|
|
|
|
test('grant without --remote exits non-zero with clear error', () => {
|
|
const r = runMint(['grant', '--capability', 'interact', '--allowlist-path', listPath]);
|
|
expect(r.status).not.toBe(0);
|
|
expect(r.stderr).toContain('--remote');
|
|
});
|
|
|
|
test('rejects unknown capability', () => {
|
|
const r = runMint([
|
|
'grant', '--remote', 'alice@example.com',
|
|
'--capability', 'godmode',
|
|
'--allowlist-path', listPath,
|
|
]);
|
|
expect(r.status).not.toBe(0);
|
|
expect(r.stderr).toContain('unknown capability');
|
|
});
|
|
|
|
test('grant with --ttl persists expires_at', () => {
|
|
const r = runMint([
|
|
'grant', '--remote', 'tag:ci',
|
|
'--capability', 'mutate',
|
|
'--ttl', '3600',
|
|
'--note', 'nightly',
|
|
'--allowlist-path', listPath,
|
|
]);
|
|
expect(r.status).toBe(0);
|
|
const raw = readFileSync(listPath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
expect(parsed.entries[0].identity).toBe('tag:ci');
|
|
expect(parsed.entries[0].capabilities).toEqual(['mutate']);
|
|
expect(parsed.entries[0].expires_at).toBeTruthy();
|
|
expect(parsed.entries[0].note).toBe('nightly');
|
|
});
|
|
});
|
|
|
|
describe('bin/gstack-ios-qa-daemon launcher', () => {
|
|
test('launcher is executable', () => {
|
|
expect(existsSync(DAEMON_BIN)).toBe(true);
|
|
const mode = statSync(DAEMON_BIN).mode & 0o111;
|
|
expect(mode).not.toBe(0);
|
|
});
|
|
|
|
test('reports missing bun runtime cleanly', () => {
|
|
// Simulate `bun` missing by giving PATH only /usr/bin + /bin (so bash
|
|
// resolves but `command -v bun` does not). The launcher's preflight
|
|
// check should fire BEFORE attempting to exec bun.
|
|
const r = spawnSync(DAEMON_BIN, [], {
|
|
stdio: 'pipe',
|
|
encoding: 'utf-8',
|
|
env: { PATH: '/usr/bin:/bin' },
|
|
});
|
|
expect(r.status).not.toBe(0);
|
|
expect(r.stderr).toContain('bun');
|
|
});
|
|
});
|