fix(pi): add snap node compatibility diagnostics and documentation

- Added detailed error logging to detect snap node compatibility issues
- When Pi process exits with code 1, log helpful diagnostic info including node path
- Add hint to check for snap node and reinstall via apt/nvm
- Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide
- Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes
- Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
2026-03-04 01:45:36 +00:00
parent 72d1341cbc
commit ef13f96d36
34 changed files with 2394 additions and 1466 deletions
+147
View File
@@ -0,0 +1,147 @@
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates');
const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
const result = Bun.spawnSync({ cmd, stdout: 'ignore', stderr: 'pipe', ...opts });
if (result.exitCode !== 0) {
console.error(`[provision] command failed: ${cmd.join(' ')}`, result.stderr.toString().trim());
}
return result.exitCode === 0;
};
const linuxUserExists = (username: string): boolean => {
const result = Bun.spawnSync({ cmd: ['id', username], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
const copyTemplate = async (src: string, dest: string) => {
if (existsSync(dest)) return;
const content = await Bun.file(src).text();
await Bun.write(dest, content);
};
export async function provisionLinuxUser(email: string, username: string): Promise<boolean> {
const shellUsername = toShellUsername(username, email);
const homeDir = getHomeDir(email);
const userRoot = join(DATA_PATH, email);
console.log(`[provision] provisioning Linux user ${shellUsername} for ${email}`);
// Ensure data directories exist
mkdirSync(userRoot, { recursive: true });
mkdirSync(homeDir, { recursive: true });
// Create Linux user if not exists
if (!linuxUserExists(shellUsername)) {
const ok = run(['sudo', 'useradd', '-d', homeDir, '-s', '/bin/zsh', '-M', shellUsername]);
if (!ok) {
console.error(`[provision] failed to create Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] created Linux user ${shellUsername}`);
} else {
console.log(`[provision] Linux user ${shellUsername} already exists`);
}
// Set ownership and permissions on user data directory
// chmod 770 so the service user (in the user's group) can read/write for background jobs
run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]);
run(['sudo', 'chmod', '770', userRoot]);
// Add the service user to the new user's group so server jobs can access user data
const serviceUser = process.env.USER ?? '';
if (serviceUser && serviceUser !== shellUsername) {
run(['sudo', 'usermod', '-aG', shellUsername, serviceUser]);
}
// Seed shell config files
await seedShellConfigs(homeDir);
// Generate and write CLAUDE.md + settings.json
const contextFile = generateContainerContext(email);
const claudeDir = join(homeDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
// Copy context to user's .claude dir
const contextContent = await Bun.file(contextFile).text();
await Bun.write(join(claudeDir, 'CLAUDE.md'), contextContent);
const settingsFile = generateClaudeSettings(email, shellUsername);
const settingsContent = await Bun.file(settingsFile).text();
await Bun.write(join(claudeDir, 'settings.json'), settingsContent);
// Fix ownership after seeding
run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]);
console.log(`[provision] provisioning complete for ${shellUsername}`);
return true;
}
async function seedShellConfigs(homeDir: string): Promise<void> {
// .zshenv (must be first — prevents system compinit before oh-my-zsh)
await copyTemplate(join(TEMPLATE_DIR, '.zshenv'), join(homeDir, '.zshenv'));
// .zshrc
await copyTemplate(join(TEMPLATE_DIR, '.zshrc'), join(homeDir, '.zshrc'));
// .tmux.conf
await copyTemplate(join(TEMPLATE_DIR, '.tmux.conf'), join(homeDir, '.tmux.conf'));
// starship config
const configDir = join(homeDir, '.config');
mkdirSync(configDir, { recursive: true });
await copyTemplate(join(TEMPLATE_DIR, 'starship-officer.toml'), join(configDir, 'starship-officer.toml'));
// Oh My Zsh — copy from host install
const ohMyZshDest = join(homeDir, '.oh-my-zsh');
if (!existsSync(ohMyZshDest)) {
const hostOhMyZsh = join(process.env.HOME ?? '', '.oh-my-zsh');
if (existsSync(hostOhMyZsh)) {
Bun.spawnSync({ cmd: ['cp', '-r', hostOhMyZsh, ohMyZshDest], stdout: 'ignore', stderr: 'ignore' });
} else {
Bun.spawnSync({
cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshDest],
stdout: 'ignore',
stderr: 'ignore',
});
}
}
// LazyVim config
const nvimDir = join(homeDir, '.config', 'nvim');
if (!existsSync(nvimDir)) {
const hostNvim = join(process.env.HOME ?? '', '.config', 'nvim');
if (existsSync(hostNvim)) {
Bun.spawnSync({ cmd: ['cp', '-r', hostNvim, nvimDir], stdout: 'ignore', stderr: 'ignore' });
}
}
// Ensure .local/bin exists
mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true });
// Ensure .pi/agent/sessions exists
mkdirSync(join(homeDir, '.pi', 'agent', 'sessions'), { recursive: true });
}
export function deprovisionLinuxUser(email: string, username: string): boolean {
const shellUsername = toShellUsername(username, email);
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
if (!linuxUserExists(shellUsername)) {
console.log(`[provision] Linux user ${shellUsername} does not exist, skipping`);
return true;
}
const ok = run(['sudo', 'userdel', shellUsername]);
if (!ok) {
console.error(`[provision] failed to delete Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] deprovisioned Linux user ${shellUsername}`);
return true;
}