Monorepo for Aesthetic.Computer aesthetic.computer

vscode-extension: vault unlock/lock status bar + passphrase SecretStorage

Add interactive vault management to VSCode status bar:
- Detects aesthetic-computer-vault presence and unlock state
- Click to unlock: prompts passphrase, imports GPG key if needed, warms
gpg-agent, runs vault-tool.fish unlock + devault.fish
- Click to lock: runs vault-tool.fish lock (re-encrypts, shreds plaintext)
- Passphrase cached in VSCode SecretStorage (cleared on wrong passphrase)
- Searches for jeffrey-private.asc in repo root or vault/gpg/
- Auto-discovers vault in workspace folders

Also add .gitignore entries for GPG key backups (canonical copies live
in aesthetic-computer-vault/gpg/).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+222 -1
+7
.gitignore
··· 13 13 # GPG-encrypted secrets (unpacked from vault, not for version control) 14 14 *.gpg 15 15 16 + # GPG private key backups (passphrase-protected but never commit) 17 + jeffrey-private.asc 18 + jeffrey-private.asc:Zone.Identifier 19 + 16 20 # SSH keys (keep in aesthetic-computer-vault only) 17 21 oven/ssh/ 18 22 **/oven-deploy-key* ··· 379 383 fedac/babypat/*.bin 380 384 fedac/babypat/*.img 381 385 fedac/native/upload.env.gpg 386 + 387 + # Stray GPG key backups (canonical copies live in aesthetic-computer-vault/gpg/) 388 + /gpg/
+202
vscode-extension/extension.ts
··· 1975 1975 }) 1976 1976 ); 1977 1977 1978 + // 🔒 Vault Unlock Status Bar 1979 + // Only shown when an aesthetic-computer-vault directory exists in the workspace. 1980 + // Click → passphrase input → imports GPG key if missing, warms gpg-agent, 1981 + // runs vault-tool.fish unlock + devault.fish. Passphrase persists via SecretStorage. 1982 + let statusBarVault: vscode.StatusBarItem | undefined; 1983 + 1984 + function findVaultDir(): string | undefined { 1985 + const folders = vscode.workspace.workspaceFolders; 1986 + if (!folders || !path || !fs) return undefined; 1987 + for (const f of folders) { 1988 + const candidate = path.join(f.uri.fsPath, "aesthetic-computer-vault"); 1989 + if (fs.existsSync(path.join(candidate, "vault-tool.fish"))) return candidate; 1990 + } 1991 + return undefined; 1992 + } 1993 + 1994 + function findPrivateKeyFile(vaultDir: string): string | undefined { 1995 + if (!path || !fs) return undefined; 1996 + const root = path.dirname(vaultDir); 1997 + const candidates = [ 1998 + path.join(root, "jeffrey-private.asc"), 1999 + path.join(vaultDir, "gpg", "jeffrey-private.asc"), 2000 + ]; 2001 + for (const c of candidates) if (fs.existsSync(c)) return c; 2002 + return undefined; 2003 + } 2004 + 2005 + function isSecretKeyImported(): boolean { 2006 + if (!cp) return false; 2007 + try { 2008 + const r = cp.spawnSync("gpg", ["--list-secret-keys", "mail@aesthetic.computer"], { encoding: "utf8" }); 2009 + return r.status === 0 && typeof r.stdout === "string" && r.stdout.includes("mail@aesthetic.computer"); 2010 + } catch { return false; } 2011 + } 2012 + 2013 + function isVaultUnlocked(vaultDir: string): boolean { 2014 + if (!fs || !path) return false; 2015 + // Consider unlocked if the home/.ssh/id_rsa plaintext exists (devault has run) 2016 + try { return fs.existsSync(path.join(vaultDir, "home/.ssh/id_rsa")); } catch { return false; } 2017 + } 2018 + 2019 + async function runVaultUnlock(passphrase: string, vaultDir: string): Promise<string> { 2020 + const os = await import("os"); 2021 + const gnupgDir = path.join(os.homedir(), ".gnupg"); 2022 + fs.mkdirSync(gnupgDir, { recursive: true, mode: 0o700 }); 2023 + 2024 + const agentConf = path.join(gnupgDir, "gpg-agent.conf"); 2025 + const existingAgent = fs.existsSync(agentConf) ? fs.readFileSync(agentConf, "utf8") : ""; 2026 + if (!existingAgent.includes("allow-loopback-pinentry")) { 2027 + fs.appendFileSync(agentConf, (existingAgent.endsWith("\n") || !existingAgent ? "" : "\n") + "allow-loopback-pinentry\n"); 2028 + try { cp.execSync("gpgconf --kill gpg-agent", { stdio: "ignore" }); } catch {} 2029 + } 2030 + const gpgConf = path.join(gnupgDir, "gpg.conf"); 2031 + const existingGpg = fs.existsSync(gpgConf) ? fs.readFileSync(gpgConf, "utf8") : ""; 2032 + if (!existingGpg.includes("pinentry-mode loopback")) { 2033 + fs.appendFileSync(gpgConf, (existingGpg.endsWith("\n") || !existingGpg ? "" : "\n") + "pinentry-mode loopback\n"); 2034 + } 2035 + 2036 + if (!isSecretKeyImported()) { 2037 + const keyFile = findPrivateKeyFile(vaultDir); 2038 + if (!keyFile) throw new Error("No GPG private key to import. Place jeffrey-private.asc in the repo root."); 2039 + const imp = cp.spawnSync("gpg", [ 2040 + "--batch", "--yes", "--passphrase-fd", "0", "--pinentry-mode", "loopback", "--import", keyFile, 2041 + ], { input: passphrase, encoding: "utf8" }); 2042 + if (imp.status !== 0) throw new Error(`gpg import failed: ${imp.stderr || imp.stdout}`); 2043 + } 2044 + 2045 + // Verify passphrase by decrypting a known vault file to a temp location 2046 + const testGpg = path.join(vaultDir, "home/.ssh/id_rsa.gpg"); 2047 + if (fs.existsSync(testGpg)) { 2048 + const test = cp.spawnSync("gpg", [ 2049 + "--batch", "--yes", "--passphrase-fd", "0", "--pinentry-mode", "loopback", "--decrypt", testGpg, 2050 + ], { input: passphrase, encoding: "utf8" }); 2051 + if (test.status !== 0) throw new Error("Passphrase incorrect (test decrypt failed)."); 2052 + } 2053 + 2054 + // Warm gpg-agent cache so vault-tool.fish's batch decrypts don't prompt. 2055 + try { 2056 + const kg = cp.execSync( 2057 + "gpg --with-keygrip --list-secret-keys mail@aesthetic.computer | awk '/Keygrip/ {print $3; exit}'", 2058 + { encoding: "utf8" } 2059 + ).trim(); 2060 + if (kg) { 2061 + const hexPass = Buffer.from(passphrase, "utf8").toString("hex").toUpperCase(); 2062 + cp.execSync(`gpg-connect-agent "PRESET_PASSPHRASE ${kg} -1 ${hexPass}" /bye`, { stdio: "ignore" }); 2063 + } 2064 + } catch {} 2065 + 2066 + // Run vault-tool.fish unlock then devault.fish (which distributes secrets to parent repo). 2067 + const unlock = cp.spawnSync("fish", [path.join(vaultDir, "vault-tool.fish"), "unlock"], { 2068 + encoding: "utf8", cwd: vaultDir, 2069 + env: { ...process.env, GPG_TTY: "" }, 2070 + }); 2071 + if (unlock.status !== 0) throw new Error(`vault unlock failed: ${(unlock.stderr || "") + (unlock.stdout || "")}`.slice(0, 500)); 2072 + 2073 + const devault = cp.spawnSync("fish", [path.join(vaultDir, "devault.fish")], { 2074 + encoding: "utf8", cwd: vaultDir, 2075 + env: { ...process.env, GPG_TTY: "" }, 2076 + }); 2077 + if (devault.status !== 0) throw new Error(`devault failed: ${(devault.stderr || "") + (devault.stdout || "")}`.slice(0, 500)); 2078 + 2079 + return "Vault unlocked and distributed."; 2080 + } 2081 + 2082 + async function updateVaultStatusBar() { 2083 + await _modulesReady; 2084 + const vaultDir = findVaultDir(); 2085 + if (!vaultDir) { statusBarVault?.hide(); return; } 2086 + if (!statusBarVault) { 2087 + statusBarVault = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 48); 2088 + context.subscriptions.push(statusBarVault); 2089 + } 2090 + if (isVaultUnlocked(vaultDir)) { 2091 + statusBarVault.text = "$(unlock) Vault"; 2092 + statusBarVault.tooltip = "Aesthetic Computer vault is unlocked. Click to lock."; 2093 + statusBarVault.command = "aestheticComputer.vaultLock"; 2094 + statusBarVault.backgroundColor = undefined; 2095 + } else { 2096 + statusBarVault.text = "$(lock) Vault"; 2097 + statusBarVault.tooltip = "Aesthetic Computer vault is locked. Click to unlock."; 2098 + statusBarVault.command = "aestheticComputer.vaultUnlock"; 2099 + statusBarVault.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground"); 2100 + } 2101 + statusBarVault.show(); 2102 + } 2103 + 2104 + context.subscriptions.push( 2105 + vscode.commands.registerCommand("aestheticComputer.vaultUnlock", async () => { 2106 + await _modulesReady; 2107 + const vaultDir = findVaultDir(); 2108 + if (!vaultDir) { vscode.window.showErrorMessage("No aesthetic-computer-vault found in workspace."); return; } 2109 + 2110 + let pass = await context.secrets.get("aestheticComputer.vaultPassphrase"); 2111 + if (!pass) { 2112 + pass = await vscode.window.showInputBox({ 2113 + prompt: "Vault passphrase", 2114 + password: true, 2115 + ignoreFocusOut: true, 2116 + placeHolder: "Enter GPG passphrase for mail@aesthetic.computer", 2117 + }); 2118 + if (!pass) return; 2119 + } 2120 + 2121 + try { 2122 + const msg = await vscode.window.withProgress({ 2123 + location: vscode.ProgressLocation.Notification, 2124 + title: "Unlocking vault…", 2125 + cancellable: false, 2126 + }, async () => runVaultUnlock(pass!, vaultDir)); 2127 + await context.secrets.store("aestheticComputer.vaultPassphrase", pass); 2128 + vscode.window.showInformationMessage("🔓 " + msg); 2129 + } catch (err: any) { 2130 + // Clear stored passphrase if it was wrong so the next click re-prompts. 2131 + if (String(err?.message || "").includes("Passphrase incorrect")) { 2132 + await context.secrets.delete("aestheticComputer.vaultPassphrase"); 2133 + } 2134 + vscode.window.showErrorMessage("Vault unlock failed: " + (err?.message || err)); 2135 + } finally { 2136 + updateVaultStatusBar(); 2137 + } 2138 + }) 2139 + ); 2140 + 2141 + context.subscriptions.push( 2142 + vscode.commands.registerCommand("aestheticComputer.vaultLock", async () => { 2143 + await _modulesReady; 2144 + const vaultDir = findVaultDir(); 2145 + if (!vaultDir) return; 2146 + const choice = await vscode.window.showWarningMessage( 2147 + "Lock the vault? This will re-encrypt and shred plaintext secrets.", 2148 + { modal: true }, "Lock" 2149 + ); 2150 + if (choice !== "Lock") return; 2151 + try { 2152 + await vscode.window.withProgress({ 2153 + location: vscode.ProgressLocation.Notification, 2154 + title: "Locking vault…", 2155 + cancellable: false, 2156 + }, async () => { 2157 + const r = cp.spawnSync("fish", [path.join(vaultDir, "vault-tool.fish"), "lock"], { 2158 + encoding: "utf8", cwd: vaultDir, 2159 + }); 2160 + if (r.status !== 0) throw new Error((r.stderr || r.stdout || "").slice(0, 500)); 2161 + }); 2162 + vscode.window.showInformationMessage("🔒 Vault locked."); 2163 + } catch (err: any) { 2164 + vscode.window.showErrorMessage("Vault lock failed: " + (err?.message || err)); 2165 + } finally { 2166 + updateVaultStatusBar(); 2167 + } 2168 + }) 2169 + ); 2170 + 2171 + context.subscriptions.push( 2172 + vscode.commands.registerCommand("aestheticComputer.vaultForgetPassphrase", async () => { 2173 + await context.secrets.delete("aestheticComputer.vaultPassphrase"); 2174 + vscode.window.showInformationMessage("Vault passphrase cleared from SecretStorage."); 2175 + }) 2176 + ); 2177 + 2178 + updateVaultStatusBar(); 2179 + 1978 2180 context.subscriptions.push( 1979 2181 vscode.commands.registerCommand("aestheticComputer.openWindow", () => { 1980 2182 const panel = vscode.window.createWebviewPanel(
+13 -1
vscode-extension/package.json
··· 4 4 "displayName": "Aesthetic Computer", 5 5 "icon": "resources/icon.png", 6 6 "author": "Jeffrey Alan Scudder", 7 - "version": "1.273.0", 7 + "version": "1.274.0", 8 8 "description": "Code, run, and publish your pieces. Includes Aesthetic Computer themes and KidLisp syntax highlighting.", 9 9 "engines": { 10 10 "vscode": "^1.105.0" ··· 188 188 { 189 189 "command": "aestheticComputer.showOTADetails", 190 190 "title": "AC-OS: OTA Build Status" 191 + }, 192 + { 193 + "command": "aestheticComputer.vaultUnlock", 194 + "title": "Aesthetic Computer: Unlock Vault 🔓" 195 + }, 196 + { 197 + "command": "aestheticComputer.vaultLock", 198 + "title": "Aesthetic Computer: Lock Vault 🔒" 199 + }, 200 + { 201 + "command": "aestheticComputer.vaultForgetPassphrase", 202 + "title": "Aesthetic Computer: Forget Vault Passphrase" 191 203 } 192 204 ], 193 205 "grammars": [