mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 20:28:24 +08:00
merge: integrate origin/main (v0.18.3.0) — community wave
Resolves conflicts: - VERSION: kept 0.19.0.0 (feature branch, higher than main's 0.18.3.0) - package.json: kept 0.19.0.0 - CHANGELOG.md: preserved 0.19.0.0 at top, inserted 0.18.3.0 between 0.19.0.0 and 0.18.2.0 Main brought community wave (6 PRs + hardening): - Windows cookie import - Persistent browse server across CLI invocations - One-command OpenCode install - OpenClaw skill frontmatter fixes - Cookie picker UI resilience Auto-merge applied to design.ts, design-consultation/SKILL.md.tmpl, design-shotgun/SKILL.md.tmpl, and plan-design-review/SKILL.md.tmpl — main's UX_PRINCIPLES changes and my TASTE_PROFILE resolver coexist cleanly. Regenerated all SKILL.md files via gen:skill-docs and refreshed ship golden fixtures. 423 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
24
.github/docker/Dockerfile.ci
vendored
24
.github/docker/Dockerfile.ci
vendored
@@ -4,8 +4,25 @@ FROM ubuntu:24.04
|
|||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# System deps
|
# Switch apt sources to Hetzner's public mirror.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# Ubicloud runners (Hetzner FSN1-DC21) hit reliable connection timeouts to
|
||||||
|
# archive.ubuntu.com:80 — observed 90+ second outages on multiple builds.
|
||||||
|
# Hetzner's mirror is publicly accessible from any cloud and route-local for
|
||||||
|
# Ubicloud, so this fixes both reliability and latency. Ubuntu 24.04 uses
|
||||||
|
# the deb822 sources format at /etc/apt/sources.list.d/ubuntu.sources.
|
||||||
|
#
|
||||||
|
# Using HTTP (not HTTPS) intentionally: the base ubuntu:24.04 image ships
|
||||||
|
# without ca-certificates, so HTTPS apt fails with "No system certificates
|
||||||
|
# available." Apt's security model verifies via GPG-signed Release files,
|
||||||
|
# not TLS, so HTTP here is no weaker than the upstream defaults.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's|http://archive.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' \
|
||||||
|
-e 's|http://security.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' \
|
||||||
|
/etc/apt/sources.list.d/ubuntu.sources
|
||||||
|
|
||||||
|
# System deps (retry apt-get update — even Hetzner can blip occasionally)
|
||||||
|
RUN for i in 1 2 3; do apt-get update && break || sleep 5; done \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
git curl unzip ca-certificates jq bc gpg \
|
git curl unzip ca-certificates jq bc gpg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -14,7 +31,8 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|||||||
| gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
| gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||||
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends gh \
|
&& for i in 1 2 3; do apt-get update && break || sleep 5; done \
|
||||||
|
&& apt-get install -y --no-install-recommends gh \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Node.js 22 LTS (needed for claude CLI)
|
# Node.js 22 LTS (needed for claude CLI)
|
||||||
|
|||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -28,6 +28,24 @@
|
|||||||
- **`scripts/resolvers/preamble/`** — 18 single-purpose generators, 16-160 lines each. The composition root in `scripts/resolvers/preamble.ts` imports them and wires them into the tier-gated section list.
|
- **`scripts/resolvers/preamble/`** — 18 single-purpose generators, 16-160 lines each. The composition root in `scripts/resolvers/preamble.ts` imports them and wires them into the tier-gated section list.
|
||||||
- **Plan and reviews persisted.** Implementation followed `~/.claude/plans/declarative-riding-cook.md` which went through CEO review (SCOPE EXPANSION, 6 expansions accepted), DX review (POLISH, 5 gaps fixed), Eng review (4 architecture issues), and Codex review (11 brutal findings, all integrated and 2 prior decisions reversed).
|
- **Plan and reviews persisted.** Implementation followed `~/.claude/plans/declarative-riding-cook.md` which went through CEO review (SCOPE EXPANSION, 6 expansions accepted), DX review (POLISH, 5 gaps fixed), Eng review (4 architecture issues), and Codex review (11 brutal findings, all integrated and 2 prior decisions reversed).
|
||||||
|
|
||||||
|
## [0.18.3.0] - 2026-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Windows cookie import.** `/setup-browser-cookies` now works on Windows. Point it at Chrome, Edge, Brave, or Chromium, pick a profile, and gstack will pull your real browser cookies into the headless session. Handles AES-256-GCM (Chrome 80+), DPAPI key unwrap via PowerShell, and falls back to a headless CDP session for v20 App-Bound Encryption on Chrome 127+. Windows users can now do authenticated QA testing with `/qa` and `/design-review` for the first time.
|
||||||
|
- **One-command OpenCode install.** `./setup --host opencode` now wires up gstack skills for OpenCode the same way it does for Claude Code and Codex. No more manual workaround.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **No more permission prompts on every skill invocation.** Every `/browse`, `/qa`, `/qa-only`, `/design-review`, `/office-hours`, `/canary`, `/pair-agent`, `/benchmark`, `/land-and-deploy`, `/design-shotgun`, `/design-consultation`, `/design-html`, `/plan-design-review`, and `/open-gstack-browser` invocation used to trigger Claude Code's sandbox asking about "tilde in assignment value." Replaced bare `~/` with `"$HOME/..."` in the browse and design resolvers plus a handful of templates that still used the old pattern. Every skill runs silently now.
|
||||||
|
- **Multi-step QA actually works.** The `$B` browse server was dying between Bash tool invocations — Claude Code's sandbox kills the parent shell when a command finishes, and the server took that as a cue to shut down. Now the server persists across calls, keeping your cookies, page state, and navigation intact. Run `$B goto`, then `$B fill`, then `$B click` in three separate Bash calls and it just works. A 30-minute idle timeout still handles eventual cleanup. `Ctrl+C` and `/stop` still do an immediate shutdown.
|
||||||
|
- **Cookie picker stops stranding the UI.** If the launching CLI exited mid-import, the picker page would flash `Failed to fetch` because the server had shut down under it. The browse server now stays alive while any picker code or session is live.
|
||||||
|
- **OpenClaw skills load cleanly in Codex.** The 4 hand-authored ClawHub skills (ceo-review, investigate, office-hours, retro) had frontmatter with unquoted colons and non-standard `version`/`metadata` fields that stricter parsers rejected. Now they load without errors on Codex CLI and render correctly on GitHub.
|
||||||
|
|
||||||
|
### For contributors
|
||||||
|
- Community wave lands 6 PRs: #993 (byliu-labs), #994 (joelgreen), #996 (voidborne-d), #864 (cathrynlavery), #982 (breakneo), #892 (msr-hickory).
|
||||||
|
- SIGTERM handling is now mode-aware. In normal mode the server ignores SIGTERM so Claude Code's sandbox doesn't tear it down mid-session. In headed mode (`/open-gstack-browser`) and tunnel mode (`/pair-agent`) SIGTERM still triggers a clean shutdown — those modes skip idle cleanup, so without the mode gate orphan daemons would accumulate forever. Note that v0.18.1.0 also disables the parent-PID watchdog when `BROWSE_HEADED=1`, so headed mode is doubly protected. Inline comments document the resolution order.
|
||||||
|
- Windows v20 App-Bound Encryption CDP fallback now logs the Chrome version on entry and has an inline comment documenting the debug-port security posture (127.0.0.1-only, random port in [9222, 9321] for collision avoidance, always killed in finally).
|
||||||
|
- New regression test `test/openclaw-native-skills.test.ts` pins OpenClaw skill frontmatter to `name` + `description` only — catches version/metadata drift at PR time.
|
||||||
|
|
||||||
## [0.18.2.0] - 2026-04-17
|
## [0.18.2.0] - 2026-04-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Chromium browser cookie import — read and decrypt cookies from real browsers
|
* Chromium browser cookie import — read and decrypt cookies from real browsers
|
||||||
*
|
*
|
||||||
* Supports macOS and Linux Chromium-based browsers.
|
* Supports macOS, Linux, and Windows Chromium-based browsers.
|
||||||
* Pure logic module — no Playwright dependency, no HTTP concerns.
|
* Pure logic module — no Playwright dependency, no HTTP concerns.
|
||||||
*
|
*
|
||||||
* Decryption pipeline:
|
* Decryption pipeline:
|
||||||
@@ -40,6 +40,7 @@ import * as crypto from 'crypto';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { TEMP_DIR } from './platform';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export interface BrowserInfo {
|
|||||||
aliases: string[];
|
aliases: string[];
|
||||||
linuxDataDir?: string;
|
linuxDataDir?: string;
|
||||||
linuxApplication?: string;
|
linuxApplication?: string;
|
||||||
|
windowsDataDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileEntry {
|
export interface ProfileEntry {
|
||||||
@@ -91,7 +93,7 @@ export class CookieImportError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserPlatform = 'darwin' | 'linux';
|
type BrowserPlatform = 'darwin' | 'linux' | 'win32';
|
||||||
|
|
||||||
interface BrowserMatch {
|
interface BrowserMatch {
|
||||||
browser: BrowserInfo;
|
browser: BrowserInfo;
|
||||||
@@ -104,11 +106,11 @@ interface BrowserMatch {
|
|||||||
|
|
||||||
const BROWSER_REGISTRY: BrowserInfo[] = [
|
const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' },
|
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
||||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' },
|
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
||||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' },
|
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
||||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge' },
|
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Key Cache ──────────────────────────────────────────────────
|
// ─── Key Cache ──────────────────────────────────────────────────
|
||||||
@@ -133,10 +135,12 @@ export function findInstalledBrowsers(): BrowserInfo[] {
|
|||||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||||
if (entries.some(e =>
|
if (entries.some(e => {
|
||||||
e.isDirectory() && e.name.startsWith('Profile ') &&
|
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
|
||||||
fs.existsSync(path.join(browserDir, e.name, 'Cookies'))
|
const profileDir = path.join(browserDir, e.name);
|
||||||
)) return true;
|
return fs.existsSync(path.join(profileDir, 'Cookies'))
|
||||||
|
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
|
||||||
|
})) return true;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -174,8 +178,11 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
|
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
|
||||||
const cookiePath = path.join(browserDir, entry.name, 'Cookies');
|
// Chrome 80+ on Windows stores cookies under Network/Cookies
|
||||||
if (!fs.existsSync(cookiePath)) continue;
|
const cookieCandidates = platform === 'win32'
|
||||||
|
? [path.join(browserDir, entry.name, 'Network', 'Cookies'), path.join(browserDir, entry.name, 'Cookies')]
|
||||||
|
: [path.join(browserDir, entry.name, 'Cookies')];
|
||||||
|
if (!cookieCandidates.some(p => fs.existsSync(p))) continue;
|
||||||
|
|
||||||
// Avoid duplicates if the same profile appears on multiple platforms
|
// Avoid duplicates if the same profile appears on multiple platforms
|
||||||
if (profiles.some(p => p.name === entry.name)) continue;
|
if (profiles.some(p => p.name === entry.name)) continue;
|
||||||
@@ -268,7 +275,7 @@ export async function importCookies(
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
const value = decryptCookieValue(row, derivedKeys);
|
const value = decryptCookieValue(row, derivedKeys, match.platform);
|
||||||
const cookie = toPlaywrightCookie(row, value);
|
const cookie = toPlaywrightCookie(row, value);
|
||||||
cookies.push(cookie);
|
cookies.push(cookie);
|
||||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||||
@@ -310,7 +317,8 @@ function validateProfile(profile: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHostPlatform(): BrowserPlatform | null {
|
function getHostPlatform(): BrowserPlatform | null {
|
||||||
if (process.platform === 'darwin' || process.platform === 'linux') return process.platform;
|
const p = process.platform;
|
||||||
|
if (p === 'darwin' || p === 'linux' || p === 'win32') return p as BrowserPlatform;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,20 +326,22 @@ function getSearchPlatforms(): BrowserPlatform[] {
|
|||||||
const current = getHostPlatform();
|
const current = getHostPlatform();
|
||||||
const order: BrowserPlatform[] = [];
|
const order: BrowserPlatform[] = [];
|
||||||
if (current) order.push(current);
|
if (current) order.push(current);
|
||||||
for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) {
|
for (const platform of ['darwin', 'linux', 'win32'] as BrowserPlatform[]) {
|
||||||
if (!order.includes(platform)) order.push(platform);
|
if (!order.includes(platform)) order.push(platform);
|
||||||
}
|
}
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
||||||
return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null;
|
if (platform === 'darwin') return browser.dataDir;
|
||||||
|
if (platform === 'linux') return browser.linuxDataDir || null;
|
||||||
|
return browser.windowsDataDir || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseDir(platform: BrowserPlatform): string {
|
function getBaseDir(platform: BrowserPlatform): string {
|
||||||
return platform === 'darwin'
|
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
|
||||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
|
||||||
: path.join(os.homedir(), '.config');
|
return path.join(os.homedir(), '.config');
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
|
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
|
||||||
@@ -339,12 +349,18 @@ function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch |
|
|||||||
for (const platform of getSearchPlatforms()) {
|
for (const platform of getSearchPlatforms()) {
|
||||||
const dataDir = getDataDirForPlatform(browser, platform);
|
const dataDir = getDataDirForPlatform(browser, platform);
|
||||||
if (!dataDir) continue;
|
if (!dataDir) continue;
|
||||||
const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies');
|
const baseProfile = path.join(getBaseDir(platform), dataDir, profile);
|
||||||
try {
|
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
|
||||||
if (fs.existsSync(dbPath)) {
|
const candidates = platform === 'win32'
|
||||||
return { browser, platform, dbPath };
|
? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
|
||||||
}
|
: [path.join(baseProfile, 'Cookies')];
|
||||||
} catch {}
|
for (const dbPath of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
return { browser, platform, dbPath };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -369,6 +385,13 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
|||||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||||
|
|
||||||
function openDb(dbPath: string, browserName: string): Database {
|
function openDb(dbPath: string, browserName: string): Database {
|
||||||
|
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
|
||||||
|
// The readonly open may "succeed" but return empty results because the WAL
|
||||||
|
// (where all actual data lives) can't be replayed. Always use the copy
|
||||||
|
// approach on Windows so we can open read-write and process the WAL.
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return openDbFromCopy(dbPath, browserName);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return new Database(dbPath, { readonly: true });
|
return new Database(dbPath, { readonly: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -439,6 +462,11 @@ async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>>
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (match.platform === 'win32') {
|
||||||
|
const key = await getWindowsAesKey(match.browser);
|
||||||
|
return new Map([['v10', key]]);
|
||||||
|
}
|
||||||
|
|
||||||
const keys = new Map<string, Buffer>();
|
const keys = new Map<string, Buffer>();
|
||||||
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
|
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
|
||||||
|
|
||||||
@@ -452,6 +480,84 @@ async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>>
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
||||||
|
const cacheKey = `win32:${browser.keychainService}`;
|
||||||
|
const cached = keyCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const platform = 'win32' as const;
|
||||||
|
const dataDir = getDataDirForPlatform(browser, platform);
|
||||||
|
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||||
|
|
||||||
|
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
|
||||||
|
let localState: any;
|
||||||
|
try {
|
||||||
|
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? `: ${err.message}` : '';
|
||||||
|
throw new CookieImportError(
|
||||||
|
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
|
||||||
|
'keychain_error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedKeyB64: string = localState?.os_crypt?.encrypted_key;
|
||||||
|
if (!encryptedKeyB64) {
|
||||||
|
throw new CookieImportError(
|
||||||
|
`No encrypted key in Local State for ${browser.name}`,
|
||||||
|
'keychain_not_found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored value is base64(b"DPAPI" + dpapi_encrypted_bytes) — strip the 5-byte prefix
|
||||||
|
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64').slice(5);
|
||||||
|
const key = await dpapiDecrypt(encryptedKey);
|
||||||
|
keyCache.set(cacheKey, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
||||||
|
const script = [
|
||||||
|
'Add-Type -AssemblyName System.Security',
|
||||||
|
'$stdin = [Console]::In.ReadToEnd().Trim()',
|
||||||
|
'$bytes = [System.Convert]::FromBase64String($stdin)',
|
||||||
|
'$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
||||||
|
'Write-Output ([System.Convert]::ToBase64String($dec))',
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
|
||||||
|
stdin: 'pipe',
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||||
|
proc.stdin.end();
|
||||||
|
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||||
|
}, 10_000),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
|
||||||
|
}
|
||||||
|
return Buffer.from(stdout.trim(), 'base64');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CookieImportError) throw err;
|
||||||
|
throw new CookieImportError(
|
||||||
|
`DPAPI decryption failed: ${(err as Error).message}`,
|
||||||
|
'keychain_error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getMacKeychainPassword(service: string): Promise<string> {
|
async function getMacKeychainPassword(service: string): Promise<string> {
|
||||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||||
@@ -566,7 +672,7 @@ interface RawCookie {
|
|||||||
samesite: number;
|
samesite: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
|
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform: BrowserPlatform): string {
|
||||||
// Prefer unencrypted value if present
|
// Prefer unencrypted value if present
|
||||||
if (row.value && row.value.length > 0) return row.value;
|
if (row.value && row.value.length > 0) return row.value;
|
||||||
|
|
||||||
@@ -574,9 +680,28 @@ function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
|
|||||||
if (ev.length === 0) return '';
|
if (ev.length === 0) return '';
|
||||||
|
|
||||||
const prefix = ev.slice(0, 3).toString('utf-8');
|
const prefix = ev.slice(0, 3).toString('utf-8');
|
||||||
|
|
||||||
|
// Chrome 127+ on Windows uses App-Bound Encryption (v20) — cannot be decrypted
|
||||||
|
// outside the Chrome process. Caller should fall back to CDP extraction.
|
||||||
|
if (prefix === 'v20') throw new CookieImportError(
|
||||||
|
'Cookie uses App-Bound Encryption (v20). Use CDP extraction instead.',
|
||||||
|
'v20_encryption',
|
||||||
|
);
|
||||||
|
|
||||||
const key = keys.get(prefix);
|
const key = keys.get(prefix);
|
||||||
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
|
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
|
||||||
|
|
||||||
|
if (platform === 'win32' && prefix === 'v10') {
|
||||||
|
// Windows: AES-256-GCM — structure: v10(3) + nonce(12) + ciphertext + tag(16)
|
||||||
|
const nonce = ev.slice(3, 15);
|
||||||
|
const tag = ev.slice(ev.length - 16);
|
||||||
|
const ciphertext = ev.slice(15, ev.length - 16);
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
|
||||||
const ciphertext = ev.slice(3);
|
const ciphertext = ev.slice(3);
|
||||||
const iv = Buffer.alloc(16, 0x20); // 16 space characters
|
const iv = Buffer.alloc(16, 0x20); // 16 space characters
|
||||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
||||||
@@ -624,3 +749,284 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
|
|||||||
default: return 'Lax';
|
default: return 'Lax';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
|
||||||
|
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
|
||||||
|
// with remote debugging and extract cookies via the DevTools Protocol.
|
||||||
|
// This only works when Chrome is NOT already running (profile lock).
|
||||||
|
|
||||||
|
const CHROME_PATHS_WIN = [
|
||||||
|
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const EDGE_PATHS_WIN = [
|
||||||
|
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||||
|
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function findBrowserExe(browserName: string): string | null {
|
||||||
|
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBrowserRunning(browserName: string): Promise<boolean> {
|
||||||
|
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||||
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
|
});
|
||||||
|
proc.exited.then(async () => {
|
||||||
|
const out = await new Response(proc.stdout).text();
|
||||||
|
resolve(out.toLowerCase().includes(exe));
|
||||||
|
}).catch(() => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
|
||||||
|
* remote debugging on the user's real profile directory. Requires Chrome to be
|
||||||
|
* closed first (profile lock).
|
||||||
|
*
|
||||||
|
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
|
||||||
|
* path, so a temp copy of the profile won't work — Chrome silently discards
|
||||||
|
* cookies it can't decrypt. We must use the real profile.
|
||||||
|
*/
|
||||||
|
export async function importCookiesViaCdp(
|
||||||
|
browserName: string,
|
||||||
|
domains: string[],
|
||||||
|
profile = 'Default',
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = resolveBrowser(browserName);
|
||||||
|
const exePath = findBrowserExe(browser.name);
|
||||||
|
if (!exePath) {
|
||||||
|
throw new CookieImportError(
|
||||||
|
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
|
||||||
|
'not_installed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isBrowserRunning(browser.name)) {
|
||||||
|
throw new CookieImportError(
|
||||||
|
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
|
||||||
|
'browser_running',
|
||||||
|
'retry',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must use the real user data dir — v20 ABE keys are path-bound
|
||||||
|
const dataDir = getDataDirForPlatform(browser, 'win32');
|
||||||
|
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||||
|
const userDataDir = path.join(getBaseDir('win32'), dataDir);
|
||||||
|
|
||||||
|
// Launch Chrome headless with remote debugging on the real profile.
|
||||||
|
//
|
||||||
|
// Security posture of the debug port:
|
||||||
|
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. We rely
|
||||||
|
// on that — the port is NOT exposed to the network. Any local process
|
||||||
|
// running as the same user could connect and read cookies, but if an
|
||||||
|
// attacker already has local-user access they can read the cookie DB
|
||||||
|
// directly. Threat model: no worse than baseline.
|
||||||
|
// - Port is randomized in [9222, 9321] to avoid collisions with other
|
||||||
|
// Chrome-based tools the user may have open. Not cryptographic.
|
||||||
|
// - Chrome is always killed in the finally block below (even on crash).
|
||||||
|
//
|
||||||
|
// Debugging note: if this path starts failing after a Chrome update,
|
||||||
|
// check the Chrome version logged below — Chrome's ABE key format (v20)
|
||||||
|
// or /json/list shape can change between major versions.
|
||||||
|
const debugPort = 9222 + Math.floor(Math.random() * 100);
|
||||||
|
const chromeProc = Bun.spawn([
|
||||||
|
exePath,
|
||||||
|
`--remote-debugging-port=${debugPort}`,
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
`--profile-directory=${profile}`,
|
||||||
|
'--headless=new',
|
||||||
|
'--no-first-run',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--disable-default-apps',
|
||||||
|
'--disable-extensions',
|
||||||
|
'--disable-sync',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
|
||||||
|
// Wait for Chrome to start, then find a page target's WebSocket URL.
|
||||||
|
// Network.getAllCookies is only available on page targets, not browser.
|
||||||
|
let wsUrl: string | null = null;
|
||||||
|
const startTime = Date.now();
|
||||||
|
let loggedVersion = false;
|
||||||
|
while (Date.now() - startTime < 15_000) {
|
||||||
|
try {
|
||||||
|
// One-time version log for future diagnostics when Chrome changes v20 format.
|
||||||
|
if (!loggedVersion) {
|
||||||
|
try {
|
||||||
|
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
|
||||||
|
if (versionResp.ok) {
|
||||||
|
const v = await versionResp.json() as { Browser?: string };
|
||||||
|
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
|
||||||
|
loggedVersion = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
|
||||||
|
const page = targets.find(t => t.type === 'page');
|
||||||
|
if (page?.webSocketDebuggerUrl) {
|
||||||
|
wsUrl = page.webSocketDebuggerUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not ready yet
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wsUrl) {
|
||||||
|
chromeProc.kill();
|
||||||
|
throw new CookieImportError(
|
||||||
|
`${browser.name} headless did not start within 15s`,
|
||||||
|
'cdp_timeout',
|
||||||
|
'retry',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect via CDP WebSocket
|
||||||
|
const cookies = await extractCookiesViaCdp(wsUrl, domains);
|
||||||
|
|
||||||
|
const domainCounts: Record<string, number> = {};
|
||||||
|
for (const c of cookies) {
|
||||||
|
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||||
|
} finally {
|
||||||
|
chromeProc.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
let msgId = 1;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Enable Network domain first, then request all cookies
|
||||||
|
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(String(event.data));
|
||||||
|
|
||||||
|
// After Network.enable succeeds, request all cookies
|
||||||
|
if (data.id === 1 && !data.error) {
|
||||||
|
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.id === msgId && data.result?.cookies) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
|
||||||
|
const domainSet = new Set<string>();
|
||||||
|
for (const d of domains) {
|
||||||
|
domainSet.add(d);
|
||||||
|
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched: PlaywrightCookie[] = [];
|
||||||
|
for (const c of data.result.cookies as CdpCookie[]) {
|
||||||
|
if (!domainSet.has(c.domain)) continue;
|
||||||
|
matched.push({
|
||||||
|
name: c.name,
|
||||||
|
value: c.value,
|
||||||
|
domain: c.domain,
|
||||||
|
path: c.path || '/',
|
||||||
|
expires: c.expires === -1 ? -1 : c.expires,
|
||||||
|
secure: c.secure,
|
||||||
|
httpOnly: c.httpOnly,
|
||||||
|
sameSite: cdpSameSite(c.sameSite),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve(matched);
|
||||||
|
} else if (data.id === msgId && data.error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
reject(new CookieImportError(
|
||||||
|
`CDP error: ${data.error.message}`,
|
||||||
|
'cdp_error',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new CookieImportError(
|
||||||
|
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
|
||||||
|
'cdp_error',
|
||||||
|
));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CdpCookie {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
size: number;
|
||||||
|
httpOnly: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
session: boolean;
|
||||||
|
sameSite: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
|
||||||
|
switch (value) {
|
||||||
|
case 'Strict': return 'Strict';
|
||||||
|
case 'Lax': return 'Lax';
|
||||||
|
case 'None': return 'None';
|
||||||
|
default: return 'Lax';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
|
||||||
|
* Quick check — reads a small sample, no decryption attempted.
|
||||||
|
*/
|
||||||
|
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
|
||||||
|
if (process.platform !== 'win32') return false;
|
||||||
|
try {
|
||||||
|
const browser = resolveBrowser(browserName);
|
||||||
|
const match = getBrowserMatch(browser, profile);
|
||||||
|
const db = openDb(match.dbPath, browser.name);
|
||||||
|
try {
|
||||||
|
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
|
||||||
|
return rows.some(row => {
|
||||||
|
const ev = Buffer.from(row.encrypted_value);
|
||||||
|
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager } from './browser-manager';
|
||||||
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||||
|
|
||||||
// ─── Auth State ─────────────────────────────────────────────────
|
// ─── Auth State ─────────────────────────────────────────────────
|
||||||
@@ -40,6 +40,23 @@ export function generatePickerCode(): string {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return true while the picker still has a live code or session. */
|
||||||
|
export function hasActivePicker(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [code, expiry] of pendingCodes) {
|
||||||
|
if (expiry > now) return true;
|
||||||
|
pendingCodes.delete(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [session, expiry] of validSessions) {
|
||||||
|
if (expiry > now) return true;
|
||||||
|
validSessions.delete(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract session ID from the gstack_picker cookie. */
|
/** Extract session ID from the gstack_picker cookie. */
|
||||||
function getSessionFromCookie(req: Request): string | null {
|
function getSessionFromCookie(req: Request): string | null {
|
||||||
const cookie = req.headers.get('cookie');
|
const cookie = req.headers.get('cookie');
|
||||||
@@ -217,7 +234,25 @@ export async function handleCookiePickerRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt cookies from the browser DB
|
// Decrypt cookies from the browser DB
|
||||||
const result = await importCookies(browser, domains, profile || 'Default');
|
const selectedProfile = profile || 'Default';
|
||||||
|
let result = await importCookies(browser, domains, selectedProfile);
|
||||||
|
|
||||||
|
// If all cookies failed and v20 encryption is detected, try CDP extraction
|
||||||
|
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) {
|
||||||
|
console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`);
|
||||||
|
try {
|
||||||
|
result = await importCookiesViaCdp(browser, domains, selectedProfile);
|
||||||
|
} catch (cdpErr: any) {
|
||||||
|
console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`);
|
||||||
|
return jsonResponse({
|
||||||
|
imported: 0,
|
||||||
|
failed: result.failed,
|
||||||
|
domainCounts: {},
|
||||||
|
message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`,
|
||||||
|
code: 'v20_encryption',
|
||||||
|
}, { port });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result.cookies.length === 0) {
|
if (result.cookies.length === 0) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { BrowserManager } from './browser-manager';
|
|||||||
import { handleReadCommand } from './read-commands';
|
import { handleReadCommand } from './read-commands';
|
||||||
import { handleWriteCommand } from './write-commands';
|
import { handleWriteCommand } from './write-commands';
|
||||||
import { handleMetaCommand } from './meta-commands';
|
import { handleMetaCommand } from './meta-commands';
|
||||||
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
||||||
import { sanitizeExtensionUrl } from './sidebar-utils';
|
import { sanitizeExtensionUrl } from './sidebar-utils';
|
||||||
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
|
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
|
||||||
import {
|
import {
|
||||||
@@ -765,14 +765,37 @@ const idleCheckInterval = setInterval(() => {
|
|||||||
// also checks BROWSE_HEADED in case a future launcher forgets.
|
// also checks BROWSE_HEADED in case a future launcher forgets.
|
||||||
// Cleanup happens via browser disconnect event or $B disconnect.
|
// Cleanup happens via browser disconnect event or $B disconnect.
|
||||||
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
||||||
|
// Outer gate: if the spawner explicitly marks this as headed (env var set at
|
||||||
|
// launch time), skip registering the watchdog entirely. Cheaper than entering
|
||||||
|
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
|
||||||
|
// so this branch is the normal path for /open-gstack-browser.
|
||||||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||||
|
let parentGone = false;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try {
|
try {
|
||||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||||
} catch {
|
} catch {
|
||||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited, shutting down`);
|
// Parent exited. Resolution order:
|
||||||
shutdown();
|
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||||
|
// regardless of mode — tearing down the server mid-import leaves the
|
||||||
|
// picker UI with a stale "Failed to fetch" error.
|
||||||
|
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
|
||||||
|
// these modes (see idleCheckInterval above — both early-return), so
|
||||||
|
// ignoring parent death here would leak orphan daemons after
|
||||||
|
// /pair-agent or /open-gstack-browser sessions.
|
||||||
|
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
|
||||||
|
// the parent shell between invocations. The idle timeout (30 min)
|
||||||
|
// handles eventual cleanup.
|
||||||
|
if (hasActivePicker()) return;
|
||||||
|
const headed = browserManager.getConnectionMode() === 'headed';
|
||||||
|
if (headed || tunnelActive) {
|
||||||
|
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||||
|
shutdown();
|
||||||
|
} else if (!parentGone) {
|
||||||
|
parentGone = true;
|
||||||
|
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
} else if (IS_HEADED_WATCHDOG) {
|
} else if (IS_HEADED_WATCHDOG) {
|
||||||
@@ -1241,11 +1264,36 @@ async function shutdown(exitCode: number = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle signals
|
// Handle signals
|
||||||
|
//
|
||||||
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
|
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
|
||||||
// Wrap so shutdown() receives no args — otherwise the string gets passed as
|
// Wrap calls to shutdown() so it receives no args — otherwise the string gets
|
||||||
// exitCode and process.exit() coerces it to NaN, exiting with code 1 instead of 0.
|
// passed as exitCode and process.exit() coerces it to NaN, exiting with code 1
|
||||||
process.on('SIGTERM', () => shutdown());
|
// instead of 0. (Caught in v0.18.1.0 #1025.)
|
||||||
|
//
|
||||||
|
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
|
||||||
process.on('SIGINT', () => shutdown());
|
process.on('SIGINT', () => shutdown());
|
||||||
|
// SIGTERM behavior depends on mode:
|
||||||
|
// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the
|
||||||
|
// parent shell exits between tool invocations. Ignoring it keeps the server
|
||||||
|
// alive across $B calls. Idle timeout (30 min) handles eventual cleanup.
|
||||||
|
// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect
|
||||||
|
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
|
||||||
|
// without waiting forever. Ctrl+C and /stop still work either way.
|
||||||
|
// - Active cookie picker: never tear down mid-import regardless of mode —
|
||||||
|
// would strand the picker UI with "Failed to fetch."
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
if (hasActivePicker()) {
|
||||||
|
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headed = browserManager.getConnectionMode() === 'headed';
|
||||||
|
if (headed || tunnelActive) {
|
||||||
|
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||||
|
shutdown();
|
||||||
|
} else {
|
||||||
|
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
|
||||||
|
}
|
||||||
|
});
|
||||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import type { TabSession } from './tab-session';
|
import type { TabSession } from './tab-session';
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager } from './browser-manager';
|
||||||
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||||
import { generatePickerCode } from './cookie-picker-routes';
|
import { generatePickerCode } from './cookie-picker-routes';
|
||||||
import { validateNavigationUrl } from './url-validation';
|
import { validateNavigationUrl } from './url-validation';
|
||||||
import { validateOutputPath } from './path-security';
|
import { validateOutputPath } from './path-security';
|
||||||
@@ -504,7 +504,11 @@ export async function handleWriteCommand(
|
|||||||
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
|
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
|
||||||
}
|
}
|
||||||
const browser = browserArg || 'comet';
|
const browser = browserArg || 'comet';
|
||||||
const result = await importCookies(browser, [domain], profile);
|
let result = await importCookies(browser, [domain], profile);
|
||||||
|
// If all cookies failed and v20 is detected, try CDP extraction
|
||||||
|
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, profile)) {
|
||||||
|
result = await importCookiesViaCdp(browser, [domain], profile);
|
||||||
|
}
|
||||||
if (result.cookies.length > 0) {
|
if (result.cookies.length > 0) {
|
||||||
await page.context().addCookies(result.cookies);
|
await page.context().addCookies(result.cookies);
|
||||||
bm.trackCookieImportDomains([domain]);
|
bm.trackCookieImportDomains([domain]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes';
|
import { handleCookiePickerRoute, generatePickerCode, hasActivePicker } from '../src/cookie-picker-routes';
|
||||||
|
|
||||||
// ─── Mock BrowserManager ──────────────────────────────────────
|
// ─── Mock BrowserManager ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -284,6 +284,57 @@ describe('cookie-picker-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('active picker tracking', () => {
|
||||||
|
test('one-time codes keep the picker active until consumed', async () => {
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = () => realNow() + 3_700_000;
|
||||||
|
try {
|
||||||
|
expect(hasActivePicker()).toBe(false); // clears any stale state from prior tests
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bm } = mockBrowserManager();
|
||||||
|
const code = generatePickerCode();
|
||||||
|
expect(hasActivePicker()).toBe(true);
|
||||||
|
|
||||||
|
const res = await handleCookiePickerRoute(
|
||||||
|
makeUrl(`/cookie-picker?code=${code}`),
|
||||||
|
new Request('http://127.0.0.1:9470', { method: 'GET' }),
|
||||||
|
bm,
|
||||||
|
'test-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(hasActivePicker()).toBe(true); // session is now active
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picker becomes inactive after an invalid session probe clears expired state', async () => {
|
||||||
|
const { bm } = mockBrowserManager();
|
||||||
|
const session = await getSessionCookie(bm, 'test-token');
|
||||||
|
expect(hasActivePicker()).toBe(true);
|
||||||
|
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = () => realNow() + 3_700_000;
|
||||||
|
try {
|
||||||
|
const res = await handleCookiePickerRoute(
|
||||||
|
makeUrl('/cookie-picker'),
|
||||||
|
new Request('http://127.0.0.1:9470', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||||
|
}),
|
||||||
|
bm,
|
||||||
|
'test-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(hasActivePicker()).toBe(false);
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('session cookie auth', () => {
|
describe('session cookie auth', () => {
|
||||||
test('valid session cookie grants HTML access', async () => {
|
test('valid session cookie grants HTML access', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
|
|||||||
@@ -5,16 +5,28 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
// End-to-end regression tests for the parent-process watchdog in server.ts.
|
// End-to-end regression tests for the parent-process watchdog in server.ts.
|
||||||
// Proves three invariants that the v0.18.1.0 fix depends on:
|
// The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
|
||||||
|
// (community wave #994 + our mode-gating follow-up):
|
||||||
//
|
//
|
||||||
// 1. BROWSE_PARENT_PID=0 disables the watchdog (opt-in used by CI and pair-agent).
|
// 1. BROWSE_PARENT_PID=0 disables the watchdog entirely (opt-in for CI + pair-agent).
|
||||||
// 2. BROWSE_HEADED=1 disables the watchdog (server-side defense-in-depth).
|
// 2. BROWSE_HEADED=1 disables the watchdog entirely (server-side defense for headed
|
||||||
// 3. Default headless mode still kills the server when its parent dies
|
// mode, where the user controls window lifecycle).
|
||||||
// (the original orphan-prevention must keep working).
|
// 3. Default headless mode + parent dies: server STAYS ALIVE. The original
|
||||||
|
// "kill on parent death" was inverted by #994 because Claude Code's Bash
|
||||||
|
// sandbox kills the parent shell between every tool invocation, and #994
|
||||||
|
// makes browse persist across $B calls. Idle timeout (30 min) handles
|
||||||
|
// eventual cleanup.
|
||||||
//
|
//
|
||||||
// Each test spawns the real server.ts, not a mock. Tests 1 and 2 verify the
|
// Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
|
||||||
// code path via stdout log line (fast). Test 3 waits for the watchdog's 15s
|
// apply) is not covered by an automated test here — tunnelActive is a runtime
|
||||||
// poll cycle to actually fire (slow — ~25s).
|
// variable set by /pair-agent's tunnel-create flow, not an env var, so faking
|
||||||
|
// it would require invasive test-only hooks. The mode check is documented
|
||||||
|
// inline at the watchdog and SIGTERM handlers, and would regress visibly for
|
||||||
|
// /pair-agent users (server lingers after disconnect).
|
||||||
|
//
|
||||||
|
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
|
||||||
|
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
|
||||||
|
// the server REMAINS alive after parent death (slow — ~20s observation window).
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dir, '..');
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
|
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
|
||||||
@@ -117,7 +129,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
|
|||||||
expect(out).not.toContain('Parent process 999999 exited');
|
expect(out).not.toContain('Parent process 999999 exited');
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
test('default headless mode: watchdog fires when parent dies', async () => {
|
test('default headless mode: server STAYS ALIVE when parent dies (#994)', async () => {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-'));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-'));
|
||||||
|
|
||||||
// Spawn a real, short-lived "parent" that the watchdog will poll.
|
// Spawn a real, short-lived "parent" that the watchdog will poll.
|
||||||
@@ -133,15 +145,13 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
|
|||||||
expect(isProcessAlive(serverPid)).toBe(true);
|
expect(isProcessAlive(serverPid)).toBe(true);
|
||||||
|
|
||||||
// Kill the parent. The watchdog polls every 15s, so first tick after
|
// Kill the parent. The watchdog polls every 15s, so first tick after
|
||||||
// parent death lands within ~15s, plus shutdown() cleanup time.
|
// parent death lands within ~15s. Pre-#994 the server would shutdown
|
||||||
|
// here. Post-#994 the server logs the parent exit and stays alive.
|
||||||
parentProc.kill('SIGKILL');
|
parentProc.kill('SIGKILL');
|
||||||
|
|
||||||
// Poll for up to 25s for the server to exit.
|
// Wait long enough for at least one watchdog tick (15s) plus margin.
|
||||||
const deadline = Date.now() + 25_000;
|
// Server should still be alive — that's the whole point of #994.
|
||||||
while (Date.now() < deadline) {
|
await Bun.sleep(20_000);
|
||||||
if (!isProcessAlive(serverPid)) break;
|
expect(isProcessAlive(serverPid)).toBe(true);
|
||||||
await Bun.sleep(500);
|
|
||||||
}
|
|
||||||
expect(isProcessAlive(serverPid)).toBe(false);
|
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ If browse is not available, that's fine — visual research is optional. The ski
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
||||||
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design
|
[ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
|
||||||
if [ -x "$D" ]; then
|
if [ -x "$D" ]; then
|
||||||
echo "DESIGN_READY: $D"
|
echo "DESIGN_READY: $D"
|
||||||
else
|
else
|
||||||
@@ -731,7 +731,7 @@ else
|
|||||||
fi
|
fi
|
||||||
B=""
|
B=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||||
if [ -x "$B" ]; then
|
if [ -x "$B" ]; then
|
||||||
echo "BROWSE_READY: $B"
|
echo "BROWSE_READY: $B"
|
||||||
else
|
else
|
||||||
@@ -1114,7 +1114,7 @@ Generate AI-rendered mockups showing the proposed design system applied to reali
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ Generate AI-rendered mockups showing the proposed design system applied to reali
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -632,7 +632,7 @@ around obstacles.
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
||||||
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design
|
[ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
|
||||||
if [ -x "$D" ]; then
|
if [ -x "$D" ]; then
|
||||||
echo "DESIGN_READY: $D"
|
echo "DESIGN_READY: $D"
|
||||||
else
|
else
|
||||||
@@ -640,7 +640,7 @@ else
|
|||||||
fi
|
fi
|
||||||
B=""
|
B=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||||
if [ -x "$B" ]; then
|
if [ -x "$B" ]; then
|
||||||
echo "BROWSE_READY: $B"
|
echo "BROWSE_READY: $B"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ Only commit if there are changes. Stage all bootstrap files (config, test direct
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
||||||
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design
|
[ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
|
||||||
if [ -x "$D" ]; then
|
if [ -x "$D" ]; then
|
||||||
echo "DESIGN_READY: $D"
|
echo "DESIGN_READY: $D"
|
||||||
else
|
else
|
||||||
@@ -894,7 +894,7 @@ else
|
|||||||
fi
|
fi
|
||||||
B=""
|
B=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||||
if [ -x "$B" ]; then
|
if [ -x "$B" ]; then
|
||||||
echo "BROWSE_READY: $B"
|
echo "BROWSE_READY: $B"
|
||||||
else
|
else
|
||||||
@@ -931,7 +931,7 @@ If `DESIGN_NOT_AVAILABLE`: skip mockup generation — the fix loop works without
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
REPORT_DIR=~/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d)
|
REPORT_DIR="$HOME/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d)"
|
||||||
mkdir -p "$REPORT_DIR/screenshots"
|
mkdir -p "$REPORT_DIR/screenshots"
|
||||||
echo "REPORT_DIR: $REPORT_DIR"
|
echo "REPORT_DIR: $REPORT_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ If `DESIGN_NOT_AVAILABLE`: skip mockup generation — the fix loop works without
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
REPORT_DIR=~/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d)
|
REPORT_DIR="$HOME/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d)"
|
||||||
mkdir -p "$REPORT_DIR/screenshots"
|
mkdir -p "$REPORT_DIR/screenshots"
|
||||||
echo "REPORT_DIR: $REPORT_DIR"
|
echo "REPORT_DIR: $REPORT_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ visual brainstorming, not a review process.
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
||||||
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design
|
[ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
|
||||||
if [ -x "$D" ]; then
|
if [ -x "$D" ]; then
|
||||||
echo "DESIGN_READY: $D"
|
echo "DESIGN_READY: $D"
|
||||||
else
|
else
|
||||||
@@ -634,7 +634,7 @@ else
|
|||||||
fi
|
fi
|
||||||
B=""
|
B=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||||
if [ -x "$B" ]; then
|
if [ -x "$B" ]; then
|
||||||
echo "BROWSE_READY: $B"
|
echo "BROWSE_READY: $B"
|
||||||
else
|
else
|
||||||
@@ -906,7 +906,7 @@ Set up the output directory:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ Set up the output directory:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ const opencode: HostConfig = {
|
|||||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1185,7 +1185,7 @@ Present via AskUserQuestion. Do NOT proceed without user approval of the approac
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
||||||
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design
|
[ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
|
||||||
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
|
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1200,7 +1200,7 @@ Generating visual mockups of the proposed design... (say "skip" if you don't nee
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: gstack-openclaw-ceo-review
|
name: gstack-openclaw-ceo-review
|
||||||
description: CEO/founder-mode plan review. Rethink the problem, find the 10-star product, challenge premises, expand scope when it creates a better product. Four modes: SCOPE EXPANSION (dream big), SELECTIVE EXPANSION (hold scope + cherry-pick), HOLD SCOPE (maximum rigor), SCOPE REDUCTION (strip to essentials). Use when asked to review a plan, challenge this, CEO review, poke holes, think bigger, or expand scope.
|
description: Use when asked to review a plan, challenge a proposal, run a CEO review, poke holes in an approach, think bigger about scope, or decide whether to expand or reduce the plan.
|
||||||
version: 1.0.0
|
|
||||||
metadata: { "openclaw": { "emoji": "👑" } }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# CEO Plan Review
|
# CEO Plan Review
|
||||||
@@ -129,7 +127,6 @@ Once selected, commit fully. Do not silently drift.
|
|||||||
**Anti-skip rule:** Never condense, abbreviate, or skip any review section regardless of plan type. If a section genuinely has zero findings, say "No issues found" and move on, but you must evaluate it.
|
**Anti-skip rule:** Never condense, abbreviate, or skip any review section regardless of plan type. If a section genuinely has zero findings, say "No issues found" and move on, but you must evaluate it.
|
||||||
|
|
||||||
Ask the user about each issue ONE AT A TIME. Do NOT batch.
|
Ask the user about each issue ONE AT A TIME. Do NOT batch.
|
||||||
**Reminder: Do NOT make any code changes. Review only.**
|
|
||||||
|
|
||||||
### Section 1: Architecture Review
|
### Section 1: Architecture Review
|
||||||
Evaluate system design, component boundaries, data flow (all four paths), state machines, coupling, scaling, security architecture, production failure scenarios, rollback posture. Draw dependency graphs.
|
Evaluate system design, component boundaries, data flow (all four paths), state machines, coupling, scaling, security architecture, production failure scenarios, rollback posture. Draw dependency graphs.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: gstack-openclaw-investigate
|
name: gstack-openclaw-investigate
|
||||||
description: Systematic debugging with root cause investigation. Four phases: investigate, analyze, hypothesize, implement. Iron Law: no fixes without root cause. Use when asked to debug, fix a bug, investigate an error, or root cause analysis. Proactively use when user reports errors, stack traces, unexpected behavior, or says something stopped working.
|
description: Use when asked to debug, fix a bug, investigate an error, or do root cause analysis, and when users report errors, stack traces, unexpected behavior, or say something stopped working.
|
||||||
version: 1.0.0
|
|
||||||
metadata: { "openclaw": { "emoji": "🔍" } }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Systematic Debugging
|
# Systematic Debugging
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: gstack-openclaw-office-hours
|
name: gstack-openclaw-office-hours
|
||||||
description: Product interrogation with six forcing questions. Two modes: startup diagnostic (demand reality, status quo, desperate specificity, narrowest wedge, observation, future-fit) and builder brainstorm. Use when asked to brainstorm, "is this worth building", "I have an idea", "office hours", or "help me think through this". Proactively use when user describes a new product idea or wants to think through design decisions before any code is written.
|
description: Use when asked to brainstorm, evaluate whether an idea is worth building, run office hours, or think through a new product idea or design direction before any code is written.
|
||||||
version: 1.0.0
|
|
||||||
metadata: { "openclaw": { "emoji": "🎯" } }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# YC Office Hours
|
# YC Office Hours
|
||||||
@@ -281,8 +279,7 @@ Count the signals for the closing message.
|
|||||||
|
|
||||||
## Phase 5: Design Doc
|
## Phase 5: Design Doc
|
||||||
|
|
||||||
Write the design document and save it to memory. After writing, tell the user:
|
Write the design document and save it to memory.
|
||||||
**"Design doc saved. Other skills (/plan-ceo-review, /plan-eng-review) will find it automatically."**
|
|
||||||
|
|
||||||
### Startup mode design doc template:
|
### Startup mode design doc template:
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: gstack-openclaw-retro
|
name: gstack-openclaw-retro
|
||||||
description: Weekly engineering retrospective. Analyzes commit history, work patterns, and code quality metrics with persistent history and trend tracking. Team-aware with per-person contributions, praise, and growth areas. Use when asked for weekly retro, what shipped this week, or engineering retrospective.
|
description: "Weekly engineering retrospective. Analyzes commit history, work patterns, and code quality metrics with persistent history and trend tracking. Team-aware with per-person contributions, praise, and growth areas. Use when asked for weekly retro, what shipped this week, or engineering retrospective."
|
||||||
version: 1.0.0
|
|
||||||
metadata: { "openclaw": { "emoji": "📊" } }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Weekly Engineering Retrospective
|
# Weekly Engineering Retrospective
|
||||||
@@ -25,11 +23,6 @@ Parse the argument to determine the time window. Default to 7 days. All times sh
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Non-git context (optional)
|
|
||||||
|
|
||||||
Check memory for non-git context: meeting notes, calendar events, decisions, and other
|
|
||||||
context that doesn't appear in git history. If found, incorporate into the retro narrative.
|
|
||||||
|
|
||||||
### Step 1: Gather Raw Data
|
### Step 1: Gather Raw Data
|
||||||
|
|
||||||
First, fetch origin and identify the current user:
|
First, fetch origin and identify the current user:
|
||||||
|
|||||||
@@ -869,7 +869,7 @@ Report findings before proceeding to Step 0.
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
|
||||||
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design
|
[ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
|
||||||
if [ -x "$D" ]; then
|
if [ -x "$D" ]; then
|
||||||
echo "DESIGN_READY: $D"
|
echo "DESIGN_READY: $D"
|
||||||
else
|
else
|
||||||
@@ -877,7 +877,7 @@ else
|
|||||||
fi
|
fi
|
||||||
B=""
|
B=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||||
if [ -x "$B" ]; then
|
if [ -x "$B" ]; then
|
||||||
echo "BROWSE_READY: $B"
|
echo "BROWSE_READY: $B"
|
||||||
else
|
else
|
||||||
@@ -957,7 +957,7 @@ First, set up the output directory. Name it after the screen/feature being desig
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ First, set up the output directory. Name it after the screen/feature being desig
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ export function generateDesignSetup(ctx: TemplateContext): string {
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
|
||||||
[ -z "$D" ] && D=${ctx.paths.designDir}/design
|
[ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
|
||||||
if [ -x "$D" ]; then
|
if [ -x "$D" ]; then
|
||||||
echo "DESIGN_READY: $D"
|
echo "DESIGN_READY: $D"
|
||||||
else
|
else
|
||||||
@@ -800,7 +800,7 @@ else
|
|||||||
fi
|
fi
|
||||||
B=""
|
B=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
|
||||||
[ -z "$B" ] && B=${ctx.paths.browseDir}/browse
|
[ -z "$B" ] && B="$HOME${ctx.paths.browseDir.replace(/^~/, '')}/browse"
|
||||||
if [ -x "$B" ]; then
|
if [ -x "$B" ]; then
|
||||||
echo "BROWSE_READY: $B"
|
echo "BROWSE_READY: $B"
|
||||||
else
|
else
|
||||||
@@ -837,7 +837,7 @@ export function generateDesignMockup(ctx: TemplateContext): string {
|
|||||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
D=""
|
D=""
|
||||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
|
||||||
[ -z "$D" ] && D=${ctx.paths.designDir}/design
|
[ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
|
||||||
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
|
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
@@ -852,7 +852,7 @@ Generating visual mockups of the proposed design... (say "skip" if you don't nee
|
|||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d)
|
_DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d)"
|
||||||
mkdir -p "$_DESIGN_DIR"
|
mkdir -p "$_DESIGN_DIR"
|
||||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|||||||
119
setup
119
setup
@@ -22,6 +22,8 @@ CODEX_SKILLS="$HOME/.codex/skills"
|
|||||||
CODEX_GSTACK="$CODEX_SKILLS/gstack"
|
CODEX_GSTACK="$CODEX_SKILLS/gstack"
|
||||||
FACTORY_SKILLS="$HOME/.factory/skills"
|
FACTORY_SKILLS="$HOME/.factory/skills"
|
||||||
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
|
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
|
||||||
|
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
|
||||||
|
OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
|
||||||
|
|
||||||
IS_WINDOWS=0
|
IS_WINDOWS=0
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
@@ -41,7 +43,7 @@ TEAM_MODE=0
|
|||||||
NO_TEAM_MODE=0
|
NO_TEAM_MODE=0
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
|
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
|
||||||
--host=*) HOST="${1#--host=}"; shift ;;
|
--host=*) HOST="${1#--host=}"; shift ;;
|
||||||
--local) LOCAL_INSTALL=1; shift ;;
|
--local) LOCAL_INSTALL=1; shift ;;
|
||||||
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
|
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
|
||||||
@@ -54,7 +56,7 @@ while [ $# -gt 0 ]; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
case "$HOST" in
|
case "$HOST" in
|
||||||
claude|codex|kiro|factory|auto) ;;
|
claude|codex|kiro|factory|opencode|auto) ;;
|
||||||
openclaw)
|
openclaw)
|
||||||
echo ""
|
echo ""
|
||||||
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
|
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
|
||||||
@@ -89,7 +91,7 @@ case "$HOST" in
|
|||||||
echo "GBrain setup and brain skills ship from the GBrain repo."
|
echo "GBrain setup and brain skills ship from the GBrain repo."
|
||||||
echo ""
|
echo ""
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
|
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# ─── Resolve skill prefix preference ─────────────────────────
|
# ─── Resolve skill prefix preference ─────────────────────────
|
||||||
@@ -152,13 +154,15 @@ INSTALL_CLAUDE=0
|
|||||||
INSTALL_CODEX=0
|
INSTALL_CODEX=0
|
||||||
INSTALL_KIRO=0
|
INSTALL_KIRO=0
|
||||||
INSTALL_FACTORY=0
|
INSTALL_FACTORY=0
|
||||||
|
INSTALL_OPENCODE=0
|
||||||
if [ "$HOST" = "auto" ]; then
|
if [ "$HOST" = "auto" ]; then
|
||||||
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
|
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
|
||||||
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
|
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
|
||||||
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
|
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
|
||||||
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
|
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
|
||||||
|
command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
|
||||||
# If none found, default to claude
|
# If none found, default to claude
|
||||||
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ]; then
|
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ]; then
|
||||||
INSTALL_CLAUDE=1
|
INSTALL_CLAUDE=1
|
||||||
fi
|
fi
|
||||||
elif [ "$HOST" = "claude" ]; then
|
elif [ "$HOST" = "claude" ]; then
|
||||||
@@ -169,6 +173,8 @@ elif [ "$HOST" = "kiro" ]; then
|
|||||||
INSTALL_KIRO=1
|
INSTALL_KIRO=1
|
||||||
elif [ "$HOST" = "factory" ]; then
|
elif [ "$HOST" = "factory" ]; then
|
||||||
INSTALL_FACTORY=1
|
INSTALL_FACTORY=1
|
||||||
|
elif [ "$HOST" = "opencode" ]; then
|
||||||
|
INSTALL_OPENCODE=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
migrate_direct_codex_install() {
|
migrate_direct_codex_install() {
|
||||||
@@ -271,6 +277,16 @@ if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 1d. Generate .opencode/ OpenCode skill docs
|
||||||
|
if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
||||||
|
log "Generating .opencode/ skill docs..."
|
||||||
|
(
|
||||||
|
cd "$SOURCE_GSTACK_DIR"
|
||||||
|
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
bun run gen:skill-docs --host opencode
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. Ensure Playwright's Chromium is available
|
# 2. Ensure Playwright's Chromium is available
|
||||||
if ! ensure_playwright_browser; then
|
if ! ensure_playwright_browser; then
|
||||||
echo "Installing Playwright Chromium..."
|
echo "Installing Playwright Chromium..."
|
||||||
@@ -596,6 +612,59 @@ create_factory_runtime_root() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
create_opencode_runtime_root() {
|
||||||
|
local gstack_dir="$1"
|
||||||
|
local opencode_gstack="$2"
|
||||||
|
local opencode_dir="$gstack_dir/.opencode/skills"
|
||||||
|
|
||||||
|
if [ -L "$opencode_gstack" ]; then
|
||||||
|
rm -f "$opencode_gstack"
|
||||||
|
elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
|
||||||
|
rm -rf "$opencode_gstack"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
|
||||||
|
|
||||||
|
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
|
||||||
|
ln -snf "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
|
||||||
|
fi
|
||||||
|
if [ -d "$gstack_dir/bin" ]; then
|
||||||
|
ln -snf "$gstack_dir/bin" "$opencode_gstack/bin"
|
||||||
|
fi
|
||||||
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||||
|
ln -snf "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
|
||||||
|
fi
|
||||||
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
||||||
|
ln -snf "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
|
||||||
|
fi
|
||||||
|
if [ -d "$gstack_dir/design/dist" ]; then
|
||||||
|
ln -snf "$gstack_dir/design/dist" "$opencode_gstack/design/dist"
|
||||||
|
fi
|
||||||
|
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
|
||||||
|
ln -snf "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
|
||||||
|
fi
|
||||||
|
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
|
||||||
|
if [ -f "$gstack_dir/review/$f" ]; then
|
||||||
|
ln -snf "$gstack_dir/review/$f" "$opencode_gstack/review/$f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -d "$gstack_dir/review/specialists" ]; then
|
||||||
|
ln -snf "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists"
|
||||||
|
fi
|
||||||
|
if [ -d "$gstack_dir/qa/templates" ]; then
|
||||||
|
ln -snf "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates"
|
||||||
|
fi
|
||||||
|
if [ -d "$gstack_dir/qa/references" ]; then
|
||||||
|
ln -snf "$gstack_dir/qa/references" "$opencode_gstack/qa/references"
|
||||||
|
fi
|
||||||
|
if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then
|
||||||
|
ln -snf "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md"
|
||||||
|
fi
|
||||||
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
||||||
|
ln -snf "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
link_factory_skill_dirs() {
|
link_factory_skill_dirs() {
|
||||||
local gstack_dir="$1"
|
local gstack_dir="$1"
|
||||||
local skills_dir="$2"
|
local skills_dir="$2"
|
||||||
@@ -628,6 +697,38 @@ link_factory_skill_dirs() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
link_opencode_skill_dirs() {
|
||||||
|
local gstack_dir="$1"
|
||||||
|
local skills_dir="$2"
|
||||||
|
local opencode_dir="$gstack_dir/.opencode/skills"
|
||||||
|
local linked=()
|
||||||
|
|
||||||
|
if [ ! -d "$opencode_dir" ]; then
|
||||||
|
echo " Generating .opencode/ skill docs..."
|
||||||
|
( cd "$gstack_dir" && bun run gen:skill-docs --host opencode )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$opencode_dir" ]; then
|
||||||
|
echo " warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for skill_dir in "$opencode_dir"/gstack*/; do
|
||||||
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
||||||
|
skill_name="$(basename "$skill_dir")"
|
||||||
|
[ "$skill_name" = "gstack" ] && continue
|
||||||
|
target="$skills_dir/$skill_name"
|
||||||
|
if [ -L "$target" ] || [ ! -e "$target" ]; then
|
||||||
|
ln -snf "$skill_dir" "$target"
|
||||||
|
linked+=("$skill_name")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#linked[@]} -gt 0 ]; then
|
||||||
|
echo " linked skills: ${linked[*]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# 4. Install for Claude (default)
|
# 4. Install for Claude (default)
|
||||||
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
|
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
|
||||||
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
|
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
|
||||||
@@ -790,6 +891,16 @@ if [ "$INSTALL_FACTORY" -eq 1 ]; then
|
|||||||
echo " factory skills: $FACTORY_SKILLS"
|
echo " factory skills: $FACTORY_SKILLS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 6c. Install for OpenCode
|
||||||
|
if [ "$INSTALL_OPENCODE" -eq 1 ]; then
|
||||||
|
mkdir -p "$OPENCODE_SKILLS"
|
||||||
|
create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
|
||||||
|
link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
|
||||||
|
echo "gstack ready (opencode)."
|
||||||
|
echo " browse: $BROWSE_BIN"
|
||||||
|
echo " opencode skills: $OPENCODE_SKILLS"
|
||||||
|
fi
|
||||||
|
|
||||||
# 7. Create .agents/ sidecar symlinks for the real Codex skill target.
|
# 7. Create .agents/ sidecar symlinks for the real Codex skill target.
|
||||||
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
|
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
|
||||||
# so the runtime assets must live there for both global and repo-local installs.
|
# so the runtime assets must live there for both global and repo-local installs.
|
||||||
|
|||||||
@@ -2122,15 +2122,16 @@ describe('setup script validation', () => {
|
|||||||
expect(fnBody).toContain('rm -f "$target"');
|
expect(fnBody).toContain('rm -f "$target"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setup supports --host auto|claude|codex|kiro', () => {
|
test('setup supports --host auto|claude|codex|kiro|opencode', () => {
|
||||||
expect(setupContent).toContain('--host');
|
expect(setupContent).toContain('--host');
|
||||||
expect(setupContent).toContain('claude|codex|kiro|factory|auto');
|
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto mode detects claude, codex, and kiro binaries', () => {
|
test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
|
||||||
expect(setupContent).toContain('command -v claude');
|
expect(setupContent).toContain('command -v claude');
|
||||||
expect(setupContent).toContain('command -v codex');
|
expect(setupContent).toContain('command -v codex');
|
||||||
expect(setupContent).toContain('command -v kiro-cli');
|
expect(setupContent).toContain('command -v kiro-cli');
|
||||||
|
expect(setupContent).toContain('command -v opencode');
|
||||||
});
|
});
|
||||||
|
|
||||||
// T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill
|
// T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill
|
||||||
@@ -2150,7 +2151,6 @@ describe('setup script validation', () => {
|
|||||||
expect(content).toContain('$GSTACK_BIN/');
|
expect(content).toContain('$GSTACK_BIN/');
|
||||||
});
|
});
|
||||||
|
|
||||||
// T3: Kiro host support in setup script
|
|
||||||
test('setup supports --host kiro with install section and sed rewrites', () => {
|
test('setup supports --host kiro with install section and sed rewrites', () => {
|
||||||
expect(setupContent).toContain('INSTALL_KIRO=');
|
expect(setupContent).toContain('INSTALL_KIRO=');
|
||||||
expect(setupContent).toContain('kiro-cli');
|
expect(setupContent).toContain('kiro-cli');
|
||||||
@@ -2158,6 +2158,21 @@ describe('setup script validation', () => {
|
|||||||
expect(setupContent).toContain('~/.kiro/skills/gstack');
|
expect(setupContent).toContain('~/.kiro/skills/gstack');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setup supports --host opencode with install section and OpenCode skill path vars', () => {
|
||||||
|
expect(setupContent).toContain('INSTALL_OPENCODE=');
|
||||||
|
expect(setupContent).toContain('OPENCODE_SKILLS="$HOME/.config/opencode/skills"');
|
||||||
|
expect(setupContent).toContain('OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setup installs OpenCode skills into a nested gstack runtime root', () => {
|
||||||
|
expect(setupContent).toContain('create_opencode_runtime_root');
|
||||||
|
expect(setupContent).toContain('.opencode/skills');
|
||||||
|
expect(setupContent).toContain('review/specialists');
|
||||||
|
expect(setupContent).toContain('qa/templates');
|
||||||
|
expect(setupContent).toContain('qa/references');
|
||||||
|
expect(setupContent).toContain('dx-hall-of-fame.md');
|
||||||
|
});
|
||||||
|
|
||||||
test('create_agents_sidecar links runtime assets', () => {
|
test('create_agents_sidecar links runtime assets', () => {
|
||||||
// Sidecar must link bin, browse, review, qa
|
// Sidecar must link bin, browse, review, qa
|
||||||
const fnStart = setupContent.indexOf('create_agents_sidecar()');
|
const fnStart = setupContent.indexOf('create_agents_sidecar()');
|
||||||
|
|||||||
@@ -354,6 +354,21 @@ describe('host-config-export.ts CLI', () => {
|
|||||||
expect(lines).toContain('review/checklist.md');
|
expect(lines).toContain('review/checklist.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('opencode symlinks returns nested runtime assets', () => {
|
||||||
|
const { stdout, exitCode } = run('symlinks', 'opencode');
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
expect(lines).toContain('bin');
|
||||||
|
expect(lines).toContain('browse/dist');
|
||||||
|
expect(lines).toContain('browse/bin');
|
||||||
|
expect(lines).toContain('review/design-checklist.md');
|
||||||
|
expect(lines).toContain('review/greptile-triage.md');
|
||||||
|
expect(lines).toContain('review/specialists');
|
||||||
|
expect(lines).toContain('qa/templates');
|
||||||
|
expect(lines).toContain('qa/references');
|
||||||
|
expect(lines).toContain('plan-devex-review/dx-hall-of-fame.md');
|
||||||
|
});
|
||||||
|
|
||||||
test('symlinks with missing host exits 1', () => {
|
test('symlinks with missing host exits 1', () => {
|
||||||
const { exitCode } = run('symlinks');
|
const { exitCode } = run('symlinks');
|
||||||
expect(exitCode).toBe(1);
|
expect(exitCode).toBe(1);
|
||||||
|
|||||||
35
test/openclaw-native-skills.test.ts
Normal file
35
test/openclaw-native-skills.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
|
||||||
|
const OPENCLAW_NATIVE_SKILLS = [
|
||||||
|
'openclaw/skills/gstack-openclaw-investigate/SKILL.md',
|
||||||
|
'openclaw/skills/gstack-openclaw-office-hours/SKILL.md',
|
||||||
|
'openclaw/skills/gstack-openclaw-ceo-review/SKILL.md',
|
||||||
|
'openclaw/skills/gstack-openclaw-retro/SKILL.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractFrontmatter(content: string): string {
|
||||||
|
expect(content.startsWith('---\n')).toBe(true);
|
||||||
|
const fmEnd = content.indexOf('\n---', 4);
|
||||||
|
expect(fmEnd).toBeGreaterThan(0);
|
||||||
|
return content.slice(4, fmEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OpenClaw native skills', () => {
|
||||||
|
test('frontmatter parses as YAML and keeps only name + description', () => {
|
||||||
|
for (const skill of OPENCLAW_NATIVE_SKILLS) {
|
||||||
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||||||
|
const frontmatter = extractFrontmatter(content);
|
||||||
|
const parsed = Bun.YAML.parse(frontmatter) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(Object.keys(parsed).sort()).toEqual(['description', 'name']);
|
||||||
|
expect(typeof parsed.name).toBe('string');
|
||||||
|
expect(typeof parsed.description).toBe('string');
|
||||||
|
expect((parsed.name as string).length).toBeGreaterThan(0);
|
||||||
|
expect((parsed.description as string).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user