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:
@@ -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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { sendMail } from 'emailer';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { originMiddleware } from '@@/_middlewares';
|
||||
import { updateUserHandler } from './update-user';
|
||||
import { deprovisionLinuxUser } from './provision';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -35,9 +36,10 @@ usersRouter.post('/invite', async (ctx) => {
|
||||
}
|
||||
|
||||
const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
const assignedRole = (typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number]))
|
||||
? (role as (typeof USER_ROLES)[number])
|
||||
: ('Member' as const);
|
||||
const assignedRole =
|
||||
typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number])
|
||||
? (role as (typeof USER_ROLES)[number])
|
||||
: ('Member' as const);
|
||||
|
||||
const existing = await getUserByEmail(email);
|
||||
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
||||
@@ -101,6 +103,9 @@ usersRouter.delete('/:id', async (ctx) => {
|
||||
const target = await getUserById(id);
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Deprovision Linux user before deleting from database
|
||||
deprovisionLinuxUser(target.email, target.username ?? '');
|
||||
|
||||
await deleteUser(id);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user