Compare commits
13 Commits
65aae85369
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b09295570d | |||
| 3f1fee4e44 | |||
| 573246ce98 | |||
| 5fc1e2caf6 | |||
| ca5c623392 | |||
| 8d2a089268 | |||
| c6b27063a4 | |||
| c103430c7d | |||
| c0ff6d8bed | |||
| 2cd03b6a83 | |||
| 4c0de2cbf3 | |||
| 683fc62129 | |||
| 32a71f00ca |
+2
-1
@@ -45,7 +45,8 @@ packages/backend/.convex
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker
|
docker/*
|
||||||
|
!docker/agent-job-rootfs
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
bunx lint-staged --concurrent 1
|
bunx lint-staged --concurrent 1
|
||||||
|
infisical scan git-changes --staged
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const env = {
|
|||||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||||
'',
|
'',
|
||||||
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
||||||
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
|
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||||
|
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
||||||
|
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||||
|
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||||
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
|||||||
workBranch: string;
|
workBranch: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
// Directory name to clone into under `workdir` (default "repo"). Used to lay
|
||||||
|
// out checkouts as ~/Code/{spoon}/{branch}.
|
||||||
|
dirName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await mkdir(args.workdir, { recursive: true });
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
const dirName = args.dirName ?? 'repo';
|
||||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||||
const clone = await run(
|
const clone = await run(
|
||||||
'git',
|
'git',
|
||||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||||
{
|
{
|
||||||
cwd: args.workdir,
|
cwd: args.workdir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
|||||||
if (clone.exitCode !== 0) {
|
if (clone.exitCode !== 0) {
|
||||||
throw new Error(`git clone failed:\n${clone.output}`);
|
throw new Error(`git clone failed:\n${clone.output}`);
|
||||||
}
|
}
|
||||||
const repoDir = path.join(args.workdir, 'repo');
|
const repoDir = path.join(args.workdir, dirName);
|
||||||
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
|
import { env } from './env';
|
||||||
import { startWorkerServer } from './server';
|
import { startWorkerServer } from './server';
|
||||||
import { startWorker } from './worker';
|
import { startWorker } from './worker';
|
||||||
|
|
||||||
|
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
||||||
|
// doesn't always forward the stop signal to this leaf process, so on restart the
|
||||||
|
// worker can orphan and keep holding port 3921. Exit when our original parent
|
||||||
|
// goes away (we get reparented) or on a stop signal, so restarts stay clean.
|
||||||
|
// Never enabled in prod (gated on SPOON_AGENT_DEV_WATCHDOG).
|
||||||
|
if (env.devWatchdog) {
|
||||||
|
// Bun caches `process.ppid`, so poll whether the original parent still exists
|
||||||
|
// (signal 0 throws once it's gone) rather than comparing ppid.
|
||||||
|
const parentPid = process.ppid;
|
||||||
|
const watcher = setInterval(() => {
|
||||||
|
try {
|
||||||
|
process.kill(parentPid, 0);
|
||||||
|
} catch {
|
||||||
|
console.log('Dev parent exited; shutting down worker.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
watcher.unref();
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||||
|
process.on(signal, () => process.exit(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startWorkerServer();
|
startWorkerServer();
|
||||||
await startWorker();
|
await startWorker();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
@@ -80,18 +82,23 @@ export const containerVolumeSuffix = () =>
|
|||||||
|
|
||||||
export { hostWorkspacePath };
|
export { hostWorkspacePath };
|
||||||
|
|
||||||
export const jobWorkspaceVolumeSpec = (workdir: string) => {
|
export const jobWorkspaceVolumeSpec = (
|
||||||
|
workdir: string,
|
||||||
|
containerHome = '/workspace',
|
||||||
|
) => {
|
||||||
const volumeOptions =
|
const volumeOptions =
|
||||||
env.containerVolumeOptions ??
|
env.containerVolumeOptions ??
|
||||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
const source = hostWorkspacePath(workdir);
|
const source = hostWorkspacePath(workdir);
|
||||||
return volumeOptions
|
return volumeOptions
|
||||||
? `${source}:/workspace:${volumeOptions}`
|
? `${source}:${containerHome}:${volumeOptions}`
|
||||||
: `${source}:/workspace`;
|
: `${source}:${containerHome}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runInJobContainer = async (args: {
|
export const runInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
@@ -110,9 +117,9 @@ export const runInJobContainer = async (args: {
|
|||||||
...networkArgs(),
|
...networkArgs(),
|
||||||
...environmentArgs(args.environment),
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
@@ -128,6 +135,8 @@ export const runInJobContainer = async (args: {
|
|||||||
|
|
||||||
export const startWorkspaceContainer = async (args: {
|
export const startWorkspaceContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
containerName: string;
|
containerName: string;
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
command?: string[];
|
command?: string[];
|
||||||
@@ -154,9 +163,9 @@ export const startWorkspaceContainer = async (args: {
|
|||||||
: []),
|
: []),
|
||||||
...environmentArgs(args.environment),
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...(args.command ?? ['sleep', 'infinity']),
|
...(args.command ?? ['sleep', 'infinity']),
|
||||||
],
|
],
|
||||||
@@ -218,8 +227,71 @@ export const execInWorkspaceContainer = async (args: {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared line-streaming + result normalization for a started subprocess
|
||||||
|
// (used by both `docker run` and `docker exec` paths).
|
||||||
|
type StreamingSubprocess = {
|
||||||
|
stdout: Readable | null;
|
||||||
|
stderr: Readable | null;
|
||||||
|
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
|
||||||
|
|
||||||
|
const streamSubprocess = async (
|
||||||
|
subprocess: StreamingSubprocess,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>,
|
||||||
|
onStderrLine?: (line: string) => Promise<void>,
|
||||||
|
): Promise<CommandResult> => {
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
let stderrBuffer = '';
|
||||||
|
const output: string[] = [];
|
||||||
|
let lineHandlers = Promise.resolve();
|
||||||
|
const consume = async (
|
||||||
|
chunk: Buffer,
|
||||||
|
source: 'stdout' | 'stderr',
|
||||||
|
handler?: (line: string) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
output.push(chunk.toString('utf8'));
|
||||||
|
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||||
|
const lines = next.split(/\r?\n/);
|
||||||
|
const remainder = lines.pop() ?? '';
|
||||||
|
if (source === 'stdout') stdoutBuffer = remainder;
|
||||||
|
else stderrBuffer = remainder;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (handler) await handler(redact(line));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subprocess.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stdout', onStdoutLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
subprocess.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stderr', onStderrLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let result: Awaited<StreamingSubprocess>;
|
||||||
|
try {
|
||||||
|
result = await subprocess;
|
||||||
|
} catch (error) {
|
||||||
|
await lineHandlers;
|
||||||
|
const outputText = output.join('');
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Container command failed.';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await lineHandlers;
|
||||||
|
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
|
||||||
|
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
|
||||||
|
return normalizeRunResult(result, output.join(''), redact);
|
||||||
|
};
|
||||||
|
|
||||||
export const streamInJobContainer = async (args: {
|
export const streamInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
@@ -240,9 +312,9 @@ export const streamInJobContainer = async (args: {
|
|||||||
...networkArgs(),
|
...networkArgs(),
|
||||||
...environmentArgs(args.environment),
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
@@ -253,58 +325,114 @@ export const streamInJobContainer = async (args: {
|
|||||||
timeout: args.timeoutMs,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let stdoutBuffer = '';
|
return streamSubprocess(
|
||||||
let stderrBuffer = '';
|
subprocess,
|
||||||
const output: string[] = [];
|
args.redact,
|
||||||
let lineHandlers = Promise.resolve();
|
args.onStdoutLine,
|
||||||
const consume = async (
|
args.onStderrLine,
|
||||||
chunk: Buffer,
|
|
||||||
source: 'stdout' | 'stderr',
|
|
||||||
handler?: (line: string) => Promise<void>,
|
|
||||||
) => {
|
|
||||||
output.push(chunk.toString('utf8'));
|
|
||||||
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
|
||||||
const lines = next.split(/\r?\n/);
|
|
||||||
const remainder = lines.pop() ?? '';
|
|
||||||
if (source === 'stdout') stdoutBuffer = remainder;
|
|
||||||
else stderrBuffer = remainder;
|
|
||||||
for (const line of lines) {
|
|
||||||
if (handler) {
|
|
||||||
await handler(args.redact(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
|
||||||
lineHandlers = lineHandlers.then(() =>
|
|
||||||
consume(chunk, 'stdout', args.onStdoutLine),
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
subprocess.stderr.on('data', (chunk: Buffer) => {
|
|
||||||
lineHandlers = lineHandlers.then(() =>
|
|
||||||
consume(chunk, 'stderr', args.onStderrLine),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
let result: Awaited<typeof subprocess>;
|
|
||||||
try {
|
|
||||||
result = await subprocess;
|
|
||||||
} catch (error) {
|
|
||||||
await lineHandlers;
|
|
||||||
const outputText = output.join('');
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : 'Container command failed.';
|
|
||||||
return {
|
|
||||||
exitCode: 1,
|
|
||||||
output: args.redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
await lineHandlers;
|
// Per-user persistent "box" container that all of a user's threads exec into
|
||||||
if (stdoutBuffer && args.onStdoutLine) {
|
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||||
await args.onStdoutLine(args.redact(stdoutBuffer));
|
export const userContainerName = (username: string) =>
|
||||||
}
|
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||||
if (stderrBuffer && args.onStderrLine) {
|
|
||||||
await args.onStderrLine(args.redact(stderrBuffer));
|
export const ensureUserContainer = async (args: {
|
||||||
}
|
username: string;
|
||||||
return normalizeRunResult(result, output.join(''), args.redact);
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
const name = userContainerName(args.username);
|
||||||
|
const inspect = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['inspect', '-f', '{{.State.Running}}', name],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
|
||||||
|
// The box mounts the per-user home, but it's created before the thread's clone
|
||||||
|
// populates it — ensure it exists first, since podman (unlike docker) refuses to
|
||||||
|
// bind-mount a missing source directory (statfs: no such file or directory).
|
||||||
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
// Not running: remove any stale container, then start fresh.
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
|
||||||
|
await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--name',
|
||||||
|
name,
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerHome,
|
||||||
|
env.jobImage,
|
||||||
|
'sleep',
|
||||||
|
'infinity',
|
||||||
|
],
|
||||||
|
{ stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const subprocess = execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return streamSubprocess(
|
||||||
|
subprocess,
|
||||||
|
args.redact,
|
||||||
|
args.onStdoutLine,
|
||||||
|
args.onStderrLine,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return normalizeRunResult(result, result.all, args.redact);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stopWorkspaceContainer = async (containerName: string) => {
|
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||||
|
|||||||
+112
-118
@@ -1,69 +1,22 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
import type { Server } from 'node:http';
|
import type { Server } from 'node:http';
|
||||||
import type { Duplex } from 'node:stream';
|
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import Docker from 'dockerode';
|
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
|
|
||||||
import { verifyTerminalToken } from './terminal-token';
|
import { verifyTerminalToken } from './terminal-token';
|
||||||
|
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||||
import { getTerminalWorkspace } from './worker';
|
import { getTerminalWorkspace } from './worker';
|
||||||
|
|
||||||
const TERMINAL_IMAGE = env.terminalImage;
|
const clampDimension = (value: unknown) => {
|
||||||
const IDLE_STOP_MS = env.terminalIdleMs;
|
const n = Math.trunc(Number(value));
|
||||||
const CONTAINER_WORKDIR = '/workspace/repo';
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return Math.min(Math.max(n, 1), 1000);
|
||||||
const docker = new Docker();
|
|
||||||
|
|
||||||
const containerName = (jobId: string) =>
|
|
||||||
`spoon-agent-term-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
|
||||||
|
|
||||||
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
|
|
||||||
const sessions = new Map<string, Session>();
|
|
||||||
|
|
||||||
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
|
|
||||||
const name = containerName(jobId);
|
|
||||||
const container = docker.getContainer(name);
|
|
||||||
const info = await container.inspect().catch(() => null);
|
|
||||||
if (info?.State.Running) return container;
|
|
||||||
if (info && !info.State.Running) {
|
|
||||||
await container.remove({ force: true }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
const suffix = containerVolumeSuffix();
|
|
||||||
const source = hostWorkspacePath(workdir);
|
|
||||||
const created = await docker.createContainer({
|
|
||||||
name,
|
|
||||||
Image: TERMINAL_IMAGE,
|
|
||||||
Cmd: ['sleep', 'infinity'],
|
|
||||||
WorkingDir: CONTAINER_WORKDIR,
|
|
||||||
Tty: false,
|
|
||||||
Labels: { 'spoon.agent.terminal': jobId },
|
|
||||||
HostConfig: {
|
|
||||||
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
|
|
||||||
NetworkMode: env.network,
|
|
||||||
Memory: 4 * 1024 * 1024 * 1024,
|
|
||||||
AutoRemove: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await created.start();
|
|
||||||
return created;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopTerminalContainer = async (jobId: string) => {
|
// Single-quote a string for a POSIX shell.
|
||||||
await docker
|
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
||||||
.getContainer(containerName(jobId))
|
|
||||||
.remove({ force: true })
|
|
||||||
.catch(() => undefined);
|
|
||||||
sessions.delete(jobId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleIdleStop = (jobId: string) => {
|
|
||||||
const session = sessions.get(jobId);
|
|
||||||
if (!session || session.connections > 0) return;
|
|
||||||
session.idleTimer = setTimeout(() => {
|
|
||||||
void stopTerminalContainer(jobId);
|
|
||||||
}, IDLE_STOP_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||||
const workspace = getTerminalWorkspace(jobId);
|
const workspace = getTerminalWorkspace(jobId);
|
||||||
@@ -72,87 +25,128 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sessions.get(jobId) ?? { connections: 0 };
|
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
|
||||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
|
||||||
session.idleTimer = undefined;
|
// *inside* the container with `script`, bridging the plain pipes to the socket.
|
||||||
session.connections += 1;
|
//
|
||||||
sessions.set(jobId, session);
|
// Register the message handler immediately and buffer input/size until the exec
|
||||||
|
// is ready (acquiring the box can take seconds on first connect), so the initial
|
||||||
let stream: Duplex | undefined;
|
// resize and early keystrokes aren't dropped.
|
||||||
let exec: Docker.Exec | undefined;
|
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
|
||||||
try {
|
const pendingInput: Buffer[] = [];
|
||||||
const container = await ensureTerminalContainer(jobId, workspace.workdir);
|
let cols = 80;
|
||||||
exec = await container.exec({
|
let rows = 24;
|
||||||
// Reattach a persistent tmux session across reconnects when tmux is
|
|
||||||
// available; otherwise fall back to a plain login shell.
|
|
||||||
Cmd: [
|
|
||||||
'/bin/bash',
|
|
||||||
'-lc',
|
|
||||||
'exec tmux new-session -A -s spoon 2>/dev/null || exec bash -l',
|
|
||||||
],
|
|
||||||
AttachStdin: true,
|
|
||||||
AttachStdout: true,
|
|
||||||
AttachStderr: true,
|
|
||||||
Tty: true,
|
|
||||||
WorkingDir: CONTAINER_WORKDIR,
|
|
||||||
Env: [
|
|
||||||
'TERM=xterm-256color',
|
|
||||||
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
|
||||||
} catch (error) {
|
|
||||||
ws.close(
|
|
||||||
1011,
|
|
||||||
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
||||||
);
|
|
||||||
session.connections -= 1;
|
|
||||||
scheduleIdleStop(jobId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeStream = stream;
|
|
||||||
const activeExec = exec;
|
|
||||||
|
|
||||||
activeStream.on('data', (chunk: Buffer) => {
|
|
||||||
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
|
||||||
});
|
|
||||||
activeStream.on('end', () => ws.close());
|
|
||||||
activeStream.on('error', () => ws.close());
|
|
||||||
|
|
||||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||||
if (isBinary) {
|
if (!isBinary) {
|
||||||
activeStream.write(data);
|
// Text frames are control messages (resize); anything else is raw input.
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Text frames are control messages (resize); anything else is treated as
|
|
||||||
// input for resilience.
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString('utf8')) as {
|
const message = JSON.parse(data.toString('utf8')) as {
|
||||||
type?: string;
|
type?: string;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
};
|
};
|
||||||
if (message.type === 'resize' && message.cols && message.rows) {
|
if (message.type === 'resize') {
|
||||||
void activeExec.resize({ w: message.cols, h: message.rows });
|
const c = clampDimension(message.cols);
|
||||||
|
const r = clampDimension(message.rows);
|
||||||
|
if (c && r) {
|
||||||
|
cols = c;
|
||||||
|
rows = r;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through: treat as raw input
|
// fall through: treat as raw input
|
||||||
}
|
}
|
||||||
activeStream.write(data);
|
}
|
||||||
|
if (procHolder.current) procHolder.current.stdin.write(data);
|
||||||
|
else pendingInput.push(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let acquired = false;
|
||||||
|
let released = false;
|
||||||
|
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||||
|
// cleanup handler flips it asynchronously when the socket closes.
|
||||||
|
const isReleased = () => released;
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
activeStream.end();
|
if (released) return;
|
||||||
const current = sessions.get(jobId);
|
released = true;
|
||||||
if (current) {
|
procHolder.current?.kill();
|
||||||
current.connections = Math.max(0, current.connections - 1);
|
if (acquired) releaseUserBox(workspace.username);
|
||||||
scheduleIdleStop(jobId);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
ws.on('close', cleanup);
|
ws.on('close', cleanup);
|
||||||
ws.on('error', cleanup);
|
ws.on('error', cleanup);
|
||||||
|
|
||||||
|
// Hold the per-user box open while this terminal is connected; the agent and
|
||||||
|
// the terminal share the exact same container (Phase 2).
|
||||||
|
let boxName: string;
|
||||||
|
try {
|
||||||
|
boxName = await acquireUserBox({
|
||||||
|
username: workspace.username,
|
||||||
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
});
|
||||||
|
acquired = true;
|
||||||
|
} catch (error) {
|
||||||
|
ws.close(
|
||||||
|
1011,
|
||||||
|
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReleased()) return; // client disconnected during startup; cleanup ran
|
||||||
|
|
||||||
|
// Reattach a persistent tmux session across reconnects when available, else a
|
||||||
|
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
|
||||||
|
const launcher =
|
||||||
|
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
|
||||||
|
// Reattach a persistent tmux session when tmux is present; otherwise fall back
|
||||||
|
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
|
||||||
|
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
|
||||||
|
// non-interactive shell exit before the `||`, so the fallback never runs.
|
||||||
|
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
|
||||||
|
'else exec bash -il; fi';
|
||||||
|
const envFlags = [
|
||||||
|
'-e',
|
||||||
|
'TERM=xterm-256color',
|
||||||
|
'-e',
|
||||||
|
`HOME=${workspace.containerHome}`,
|
||||||
|
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
env.containerRuntime,
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
'-i',
|
||||||
|
...envFlags,
|
||||||
|
'-w',
|
||||||
|
workspace.containerRepo,
|
||||||
|
boxName,
|
||||||
|
'/bin/bash',
|
||||||
|
'-lc',
|
||||||
|
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
|
||||||
|
],
|
||||||
|
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
procHolder.current = proc;
|
||||||
|
|
||||||
|
// Replay any keystrokes the client sent before the process was ready.
|
||||||
|
for (const buffered of pendingInput) proc.stdin.write(buffered);
|
||||||
|
pendingInput.length = 0;
|
||||||
|
|
||||||
|
const forward = (chunk: Buffer) => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||||
|
};
|
||||||
|
proc.stdout.on('data', forward);
|
||||||
|
proc.stderr.on('data', forward);
|
||||||
|
proc.on('exit', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
import {
|
||||||
|
ensureUserContainer,
|
||||||
|
stopWorkspaceContainer,
|
||||||
|
userContainerName,
|
||||||
|
} from './runtime/docker';
|
||||||
|
|
||||||
|
// Phase 2: one persistent "box" container per user that all of their threads
|
||||||
|
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
|
||||||
|
// up while any thread workspace is active or a terminal is connected, and is
|
||||||
|
// reaped after an idle period once nothing holds it.
|
||||||
|
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
|
||||||
|
const boxes = new Map<string, Box>();
|
||||||
|
|
||||||
|
export const acquireUserBox = async (args: {
|
||||||
|
username: string;
|
||||||
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
const name = await ensureUserContainer(args);
|
||||||
|
const box = boxes.get(args.username) ?? { refs: 0 };
|
||||||
|
if (box.idleTimer) {
|
||||||
|
clearTimeout(box.idleTimer);
|
||||||
|
box.idleTimer = undefined;
|
||||||
|
}
|
||||||
|
box.refs += 1;
|
||||||
|
boxes.set(args.username, box);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseUserBox = (username: string) => {
|
||||||
|
const box = boxes.get(username);
|
||||||
|
if (!box) return;
|
||||||
|
box.refs = Math.max(0, box.refs - 1);
|
||||||
|
if (box.refs > 0) return;
|
||||||
|
box.idleTimer = setTimeout(() => {
|
||||||
|
void stopWorkspaceContainer(userContainerName(username));
|
||||||
|
boxes.delete(username);
|
||||||
|
}, env.boxIdleMs);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { runExecInContainer } from './runtime/docker';
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
|
export type UserEnvironment = {
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
files: { path: string; content: string; isExecutable: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The job owner's resolved environment (username + dotfiles, decrypted). */
|
||||||
|
export const fetchUserEnvironment = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
): Promise<UserEnvironment | null> =>
|
||||||
|
await client.action(api.userDotfilesNode.getEnvironmentForJob, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
// Keep a written path inside the home directory.
|
||||||
|
const safeHomeJoin = (homeDir: string, relPath: string) => {
|
||||||
|
const target = path.resolve(homeDir, relPath);
|
||||||
|
const root = path.resolve(homeDir);
|
||||||
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||||
|
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materializes the persistent per-user home: a `.bash_profile` so login shells
|
||||||
|
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
|
||||||
|
* repo + the setup command, run inside the job image so the user's tools/paths
|
||||||
|
* apply; then the editable overlay files (which win over the repo). Idempotent
|
||||||
|
* via a hash marker so the repo/setup only re-runs when the config changes.
|
||||||
|
*/
|
||||||
|
export const materializeUserHome = async (args: {
|
||||||
|
homeDir: string;
|
||||||
|
containerHome: string;
|
||||||
|
boxName: string;
|
||||||
|
userEnv: UserEnvironment;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
||||||
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
|
||||||
|
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
|
||||||
|
const bashProfile = path.join(homeDir, '.bash_profile');
|
||||||
|
await readFile(bashProfile, 'utf8').catch(async () => {
|
||||||
|
await writeFile(
|
||||||
|
bashProfile,
|
||||||
|
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
|
// Public dotfiles repo + setup command, only re-run when the config changes.
|
||||||
|
if (userEnv.dotfilesRepoUrl) {
|
||||||
|
const configHash = createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
repo: userEnv.dotfilesRepoUrl,
|
||||||
|
ref: userEnv.dotfilesRepoRef ?? '',
|
||||||
|
setup: userEnv.setupCommand ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
const markerPath = path.join(homeDir, '.spoon', 'env-hash');
|
||||||
|
const previous = await readFile(markerPath, 'utf8').catch(() => '');
|
||||||
|
if (previous.trim() !== configHash) {
|
||||||
|
const branch = userEnv.dotfilesRepoRef
|
||||||
|
? `--branch ${shellQuote(userEnv.dotfilesRepoRef)} `
|
||||||
|
: '';
|
||||||
|
const script = [
|
||||||
|
'set -e',
|
||||||
|
'rm -rf ~/.dotfiles',
|
||||||
|
`git clone --depth 1 ${branch}${shellQuote(userEnv.dotfilesRepoUrl)} ~/.dotfiles`,
|
||||||
|
userEnv.setupCommand
|
||||||
|
? `cd ~/.dotfiles && bash ${shellQuote(userEnv.setupCommand)}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
await runExecInContainer({
|
||||||
|
containerName: boxName,
|
||||||
|
command: ['bash', '-lc', script],
|
||||||
|
containerCwd: containerHome,
|
||||||
|
environment: { HOME: containerHome },
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
|
await writeFile(markerPath, configHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
for (const file of userEnv.files) {
|
||||||
|
const target = safeHomeJoin(homeDir, file.path);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, file.content);
|
||||||
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
}
|
||||||
|
};
|
||||||
+107
-30
@@ -17,11 +17,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
|||||||
import type { NormalizedAgentEvent } from './agent-events';
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
import type { OpenCodeSession } from './opencode-session';
|
import type { OpenCodeSession } from './opencode-session';
|
||||||
import { normalizeCodexJsonLine } from './agent-events';
|
import { normalizeCodexJsonLine } from './agent-events';
|
||||||
import {
|
import { prepareCodexWorkspaceFiles } from './codex-runtime';
|
||||||
codexContainerRepo,
|
|
||||||
codexContainerWorkspace,
|
|
||||||
prepareCodexWorkspaceFiles,
|
|
||||||
} from './codex-runtime';
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
@@ -40,11 +36,13 @@ import {
|
|||||||
import { createRedactor, truncate } from './redact';
|
import { createRedactor, truncate } from './redact';
|
||||||
import {
|
import {
|
||||||
listWorkspaceContainerNames,
|
listWorkspaceContainerNames,
|
||||||
runInJobContainer,
|
runExecInContainer,
|
||||||
startWorkspaceContainer,
|
startWorkspaceContainer,
|
||||||
stopWorkspaceContainer,
|
stopWorkspaceContainer,
|
||||||
streamInJobContainer,
|
streamExecInContainer,
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
|
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||||
|
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
||||||
|
|
||||||
type Claim = {
|
type Claim = {
|
||||||
job: {
|
job: {
|
||||||
@@ -98,8 +96,17 @@ type Claim = {
|
|||||||
|
|
||||||
type ActiveWorkspace = {
|
type ActiveWorkspace = {
|
||||||
claim: Claim;
|
claim: Claim;
|
||||||
|
// Host path of the persistent per-user home (mounted at `containerHome`).
|
||||||
|
// Equal to `homeDir`; kept as `workdir` for the container mount source.
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
homeDir: string;
|
||||||
|
username: string;
|
||||||
|
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
|
||||||
|
containerHome: string;
|
||||||
|
containerRepo: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
|
// Phase 2: the per-user box container this thread execs into.
|
||||||
|
boxName: string;
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
||||||
@@ -620,6 +627,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
|||||||
);
|
);
|
||||||
const container = await startWorkspaceContainer({
|
const container = await startWorkspaceContainer({
|
||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
containerName,
|
containerName,
|
||||||
environment: {
|
environment: {
|
||||||
...aiEnv,
|
...aiEnv,
|
||||||
@@ -649,7 +658,7 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
|||||||
const session = await createOpenCodeSession({
|
const session = await createOpenCodeSession({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
password,
|
password,
|
||||||
directory: '/workspace/repo',
|
directory: workspace.containerRepo,
|
||||||
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
||||||
onEvent: async (event) => {
|
onEvent: async (event) => {
|
||||||
const messageId = workspaceCurrentMessage.get(
|
const messageId = workspaceCurrentMessage.get(
|
||||||
@@ -721,7 +730,7 @@ const runCodexTurn = async (args: {
|
|||||||
outputFileName,
|
outputFileName,
|
||||||
);
|
);
|
||||||
const outputFileContainerPath = path.posix.join(
|
const outputFileContainerPath = path.posix.join(
|
||||||
codexContainerWorkspace,
|
workspace.containerHome,
|
||||||
'.codex',
|
'.codex',
|
||||||
outputFileName,
|
outputFileName,
|
||||||
);
|
);
|
||||||
@@ -747,15 +756,16 @@ const runCodexTurn = async (args: {
|
|||||||
'--output-last-message',
|
'--output-last-message',
|
||||||
outputFileContainerPath,
|
outputFileContainerPath,
|
||||||
'--cd',
|
'--cd',
|
||||||
codexContainerRepo,
|
workspace.containerRepo,
|
||||||
prompt,
|
prompt,
|
||||||
];
|
];
|
||||||
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
|
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||||
const secretEnv = Object.fromEntries(
|
const secretEnv = Object.fromEntries(
|
||||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
);
|
);
|
||||||
const result = await streamInJobContainer({
|
const result = await streamExecInContainer({
|
||||||
workdir: workspace.workdir,
|
containerName: workspace.boxName,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: {
|
environment: {
|
||||||
...aiEnv,
|
...aiEnv,
|
||||||
@@ -866,7 +876,7 @@ const runOpenCodeTurn = async (args: {
|
|||||||
session,
|
session,
|
||||||
prompt,
|
prompt,
|
||||||
model: opencodeModel(workspace.claim),
|
model: opencodeModel(workspace.claim),
|
||||||
directory: '/workspace/repo',
|
directory: workspace.containerRepo,
|
||||||
});
|
});
|
||||||
await turnDone;
|
await turnDone;
|
||||||
};
|
};
|
||||||
@@ -998,27 +1008,29 @@ const runProjectCommand = async (args: {
|
|||||||
command: string;
|
command: string;
|
||||||
phase: 'install' | 'check' | 'test';
|
phase: 'install' | 'check' | 'test';
|
||||||
claim: Claim;
|
claim: Claim;
|
||||||
workdir: string;
|
boxName: string;
|
||||||
|
containerHome: string;
|
||||||
|
containerCwd: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
}) => {
|
}) => {
|
||||||
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
|
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
|
||||||
|
const secretEnv = Object.fromEntries(
|
||||||
|
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
|
);
|
||||||
const result =
|
const result =
|
||||||
env.runtime === 'docker'
|
env.runtime === 'docker'
|
||||||
? await runInJobContainer({
|
? await runExecInContainer({
|
||||||
workdir: args.workdir,
|
containerName: args.boxName,
|
||||||
command: commandToShell(args.command),
|
command: commandToShell(args.command),
|
||||||
environment: Object.fromEntries(
|
containerCwd: args.containerCwd,
|
||||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
environment: { HOME: args.containerHome, ...secretEnv },
|
||||||
),
|
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
})
|
})
|
||||||
: await run('bash', ['-lc', args.command], {
|
: await run('bash', ['-lc', args.command], {
|
||||||
cwd: args.repoDir,
|
cwd: args.repoDir,
|
||||||
env: Object.fromEntries(
|
env: secretEnv,
|
||||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
|
||||||
),
|
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
});
|
});
|
||||||
@@ -1268,9 +1280,15 @@ const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const slugify = (value: string) =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-{2,}/g, '-') || 'x';
|
||||||
|
|
||||||
const runClaim = async (claim: Claim) => {
|
const runClaim = async (claim: Claim) => {
|
||||||
const jobId = claim.job._id;
|
const jobId = claim.job._id;
|
||||||
const workdir = path.resolve(env.workdir, jobId);
|
|
||||||
const secretValues = [
|
const secretValues = [
|
||||||
claim.openai.apiKey ?? '',
|
claim.openai.apiKey ?? '',
|
||||||
claim.aiProviderProfile?.secret ?? '',
|
claim.aiProviderProfile?.secret ?? '',
|
||||||
@@ -1278,6 +1296,7 @@ const runClaim = async (claim: Claim) => {
|
|||||||
...claim.secrets.map((secret) => secret.value),
|
...claim.secrets.map((secret) => secret.value),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const redact = createRedactor(secretValues);
|
const redact = createRedactor(secretValues);
|
||||||
|
let acquiredBoxUser: string | undefined;
|
||||||
try {
|
try {
|
||||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||||
@@ -1288,8 +1307,36 @@ const runClaim = async (claim: Claim) => {
|
|||||||
throw new Error('GitHub installation ID is missing.');
|
throw new Error('GitHub installation ID is missing.');
|
||||||
}
|
}
|
||||||
const githubToken = await getInstallationToken(claim.github.installationId);
|
const githubToken = await getInstallationToken(claim.github.installationId);
|
||||||
|
|
||||||
|
// Resolve the persistent per-user home and lay the checkout out as
|
||||||
|
// ~/Code/{spoon}/{branch} inside it, so dotfiles/tools persist and every
|
||||||
|
// thread shows up as a folder in one home.
|
||||||
|
const userEnv = await fetchUserEnvironment(jobId);
|
||||||
|
const username = userEnv?.username ?? 'user';
|
||||||
|
const homeDir = path.resolve(env.workdir, 'homes', username);
|
||||||
|
const containerHome = path.posix.join('/home', username);
|
||||||
|
const spoonSlug = slugify(claim.spoon.name);
|
||||||
|
const branchSlug = slugify(claim.job.workBranch);
|
||||||
|
const checkoutParent = path.join(homeDir, 'Code', spoonSlug);
|
||||||
|
const containerRepo = path.posix.join(
|
||||||
|
containerHome,
|
||||||
|
'Code',
|
||||||
|
spoonSlug,
|
||||||
|
branchSlug,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start (or reuse) the persistent per-user box that this thread — and the
|
||||||
|
// terminal — exec into. It mounts the home, so the clone below is visible.
|
||||||
|
const boxName = await acquireUserBox({
|
||||||
|
username,
|
||||||
|
workdir: homeDir,
|
||||||
|
containerHome,
|
||||||
|
});
|
||||||
|
acquiredBoxUser = username;
|
||||||
|
|
||||||
const repoDir = await cloneRepository({
|
const repoDir = await cloneRepository({
|
||||||
workdir,
|
workdir: checkoutParent,
|
||||||
|
dirName: branchSlug,
|
||||||
token: githubToken,
|
token: githubToken,
|
||||||
owner: claim.job.forkOwner,
|
owner: claim.job.forkOwner,
|
||||||
repo: claim.job.forkRepo,
|
repo: claim.job.forkRepo,
|
||||||
@@ -1300,11 +1347,31 @@ const runClaim = async (claim: Claim) => {
|
|||||||
});
|
});
|
||||||
const workspace: ActiveWorkspace = {
|
const workspace: ActiveWorkspace = {
|
||||||
claim,
|
claim,
|
||||||
workdir,
|
workdir: homeDir,
|
||||||
|
homeDir,
|
||||||
|
username,
|
||||||
|
containerHome,
|
||||||
|
containerRepo,
|
||||||
repoDir,
|
repoDir,
|
||||||
|
boxName,
|
||||||
githubToken,
|
githubToken,
|
||||||
redact,
|
redact,
|
||||||
};
|
};
|
||||||
|
if (userEnv) {
|
||||||
|
await appendEvent(
|
||||||
|
jobId,
|
||||||
|
'info',
|
||||||
|
'clone',
|
||||||
|
'Applying your dotfiles and environment.',
|
||||||
|
);
|
||||||
|
await materializeUserHome({
|
||||||
|
homeDir,
|
||||||
|
containerHome,
|
||||||
|
boxName,
|
||||||
|
userEnv,
|
||||||
|
redact,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isCodexLoginProfile(claim)) {
|
if (isCodexLoginProfile(claim)) {
|
||||||
await prepareCodexAuth(workspace);
|
await prepareCodexAuth(workspace);
|
||||||
}
|
}
|
||||||
@@ -1366,6 +1433,7 @@ const runClaim = async (claim: Claim) => {
|
|||||||
).catch((stopError: unknown) => {
|
).catch((stopError: unknown) => {
|
||||||
console.error(stopError);
|
console.error(stopError);
|
||||||
});
|
});
|
||||||
|
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1451,7 +1519,9 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
|||||||
command,
|
command,
|
||||||
phase: command.includes('test') ? 'test' : 'check',
|
phase: command.includes('test') ? 'test' : 'check',
|
||||||
claim: workspace.claim,
|
claim: workspace.claim,
|
||||||
workdir: workspace.workdir,
|
boxName: workspace.boxName,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
repoDir: workspace.repoDir,
|
repoDir: workspace.repoDir,
|
||||||
redact: workspace.redact,
|
redact: workspace.redact,
|
||||||
});
|
});
|
||||||
@@ -1471,6 +1541,9 @@ export const getTerminalWorkspace = (jobId: string) => {
|
|||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
return {
|
return {
|
||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
containerRepo: workspace.containerRepo,
|
||||||
|
username: workspace.username,
|
||||||
secrets: workspace.claim.secrets,
|
secrets: workspace.claim.secrets,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1532,7 +1605,7 @@ export const replyToInteraction = async (
|
|||||||
session: workspace.opencodeSession,
|
session: workspace.opencodeSession,
|
||||||
permissionId: args.externalRequestId,
|
permissionId: args.externalRequestId,
|
||||||
response: mapped,
|
response: mapped,
|
||||||
directory: '/workspace/repo',
|
directory: workspace.containerRepo,
|
||||||
});
|
});
|
||||||
await patchInteractionRequest({
|
await patchInteractionRequest({
|
||||||
interactionId: args.interactionId,
|
interactionId: args.interactionId,
|
||||||
@@ -1781,7 +1854,9 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
|||||||
await stopWorkspaceContainer(workspace.containerName);
|
await stopWorkspaceContainer(workspace.containerName);
|
||||||
}
|
}
|
||||||
activeWorkspaces.delete(jobId);
|
activeWorkspaces.delete(jobId);
|
||||||
await rm(workspace.workdir, { recursive: true, force: true });
|
// The persistent per-user home + ~/Code checkouts survive across sessions;
|
||||||
|
// release the box (reaped once no other thread/terminal holds it).
|
||||||
|
releaseUserBox(workspace.username);
|
||||||
return {
|
return {
|
||||||
pullRequestUrl: pullRequest.html_url,
|
pullRequestUrl: pullRequest.html_url,
|
||||||
pullRequestNumber: pullRequest.number,
|
pullRequestNumber: pullRequest.number,
|
||||||
@@ -1796,7 +1871,9 @@ export const stopWorkspace = async (jobId: string) => {
|
|||||||
await stopWorkspaceContainer(workspace.containerName);
|
await stopWorkspaceContainer(workspace.containerName);
|
||||||
}
|
}
|
||||||
activeWorkspaces.delete(jobId);
|
activeWorkspaces.delete(jobId);
|
||||||
await rm(workspace.workdir, { recursive: true, force: true });
|
// The persistent per-user home + ~/Code checkouts survive across sessions;
|
||||||
|
// release the box (reaped once no other thread/terminal holds it).
|
||||||
|
releaseUserBox(workspace.username);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
"$schema": "https://v2-10-0.turborepo.dev/schema.json",
|
||||||
"extends": ["//"],
|
"extends": ["//"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
import { DotfilesManager } from '@/components/settings/dotfiles/dotfiles-manager';
|
||||||
|
|
||||||
|
const SettingsDotfilesPage = () => {
|
||||||
|
return (
|
||||||
|
<section className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-semibold'>Dotfiles</h2>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Your personal shell, editor, and tool config — applied to the
|
||||||
|
workspace terminal in every thread. Files are placed relative to your
|
||||||
|
home directory (e.g. <code>.bashrc</code>,{' '}
|
||||||
|
<code>.config/nvim/init.lua</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DotfilesManager />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsDotfilesPage;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
|
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@spoon/ui';
|
import { cn } from '@spoon/ui';
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ const settingsItems = [
|
|||||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||||
|
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import '@spoon/tailwind-config/theme';
|
@import '@spoon/tailwind-config/theme';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nerd Font icons for the workspace terminal + editor. Scoped to the Nerd Font
|
||||||
|
* glyph ranges via unicode-range, so the ~1MB file is only fetched when an icon
|
||||||
|
* actually renders (latin text stays on Victor Mono). Used as a fallback in the
|
||||||
|
* terminal/editor font stacks.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Symbols Nerd Font Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/SymbolsNerdFontMono.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+23fb-23fe, U+2665, U+26a1, U+2b58, U+e000-f8ff, U+f0000-fffff;
|
||||||
|
}
|
||||||
|
|
||||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EDITOR_FONT_FAMILY =
|
const EDITOR_FONT_FAMILY =
|
||||||
"var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||||
|
|
||||||
type MonacoEditorInstance = {
|
type MonacoEditorInstance = {
|
||||||
getModel?: () => unknown;
|
getModel?: () => unknown;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Button } from '@spoon/ui';
|
|||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
const TERMINAL_FONT =
|
const TERMINAL_FONT =
|
||||||
"var(--font-victor-mono), 'Geist Mono', ui-monospace, monospace";
|
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
||||||
|
|
||||||
@@ -146,6 +146,15 @@ export const WorkspaceTerminal = ({
|
|||||||
fit.fit();
|
fit.fit();
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
|
|
||||||
|
// Pull in the Nerd Font icon glyphs (loaded lazily by unicode-range) and
|
||||||
|
// repaint once ready so powerline/oh-my-posh/eza icons render.
|
||||||
|
void document.fonts
|
||||||
|
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
|
||||||
|
.then(() => {
|
||||||
|
if (!isAborted()) term.refresh(0, term.rows - 1);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
const sendResize = () => {
|
const sendResize = () => {
|
||||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { FileTreeNode } from '@/components/agent-workspace/types';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CodeEditor } from '@/components/agent-workspace/code-editor';
|
||||||
|
import { FileTree } from '@/components/agent-workspace/file-tree';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
import { FilePlus, FolderUp, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Button, Card, Input, Label } from '@spoon/ui';
|
||||||
|
|
||||||
|
type DotfileMeta = {
|
||||||
|
_id: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
isExecutable: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadFile = { path: string; content: string; isExecutable?: boolean };
|
||||||
|
|
||||||
|
// Minimal typed surface of the drag-and-drop FileSystem entry API.
|
||||||
|
type FsEntry = {
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
name: string;
|
||||||
|
file?: (cb: (f: File) => void, err: (e: unknown) => void) => void;
|
||||||
|
createReader?: () => {
|
||||||
|
readEntries: (
|
||||||
|
cb: (e: FsEntry[]) => void,
|
||||||
|
err: (e: unknown) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTree = (files: DotfileMeta[], rootLabel: string): FileTreeNode => {
|
||||||
|
const root: FileTreeNode = {
|
||||||
|
name: rootLabel,
|
||||||
|
path: '',
|
||||||
|
type: 'directory',
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||||
|
const segments = file.path.split('/');
|
||||||
|
let node = root;
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
const isLeaf = index === segments.length - 1;
|
||||||
|
const childPath = segments.slice(0, index + 1).join('/');
|
||||||
|
node.children ??= [];
|
||||||
|
let child = node.children.find((c) => c.path === childPath);
|
||||||
|
if (!child) {
|
||||||
|
child = {
|
||||||
|
name: segment,
|
||||||
|
path: childPath,
|
||||||
|
type: isLeaf ? 'file' : 'directory',
|
||||||
|
children: isLeaf ? undefined : [],
|
||||||
|
};
|
||||||
|
node.children.push(child);
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAllEntries = (reader: {
|
||||||
|
readEntries: (cb: (e: FsEntry[]) => void, err: (e: unknown) => void) => void;
|
||||||
|
}) =>
|
||||||
|
new Promise<FsEntry[]>((resolve, reject) => {
|
||||||
|
const all: FsEntry[] = [];
|
||||||
|
const next = () =>
|
||||||
|
reader.readEntries((batch) => {
|
||||||
|
if (batch.length === 0) resolve(all);
|
||||||
|
else {
|
||||||
|
all.push(...batch);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}, reject);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectEntry = async (
|
||||||
|
entry: FsEntry,
|
||||||
|
prefix: string,
|
||||||
|
out: UploadFile[],
|
||||||
|
) => {
|
||||||
|
if (entry.isFile && entry.file) {
|
||||||
|
const file = await new Promise<File>((res, rej) => entry.file?.(res, rej));
|
||||||
|
out.push({ path: `${prefix}${entry.name}`, content: await file.text() });
|
||||||
|
} else if (entry.isDirectory && entry.createReader) {
|
||||||
|
const entries = await readAllEntries(entry.createReader());
|
||||||
|
for (const child of entries) {
|
||||||
|
await collectEntry(child, `${prefix}${entry.name}/`, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DotfilesManager = () => {
|
||||||
|
const settings = useQuery(api.userEnvironment.getMine);
|
||||||
|
const filesQuery = useQuery(api.userDotfiles.listMine);
|
||||||
|
const files = useMemo(
|
||||||
|
() => (filesQuery ?? []) as DotfileMeta[],
|
||||||
|
[filesQuery],
|
||||||
|
);
|
||||||
|
const getFileContent = useAction(api.userDotfilesNode.getFileContent);
|
||||||
|
const putFile = useAction(api.userDotfilesNode.putFile);
|
||||||
|
const importFiles = useAction(api.userDotfilesNode.importFiles);
|
||||||
|
const removeFile = useMutation(api.userDotfiles.remove);
|
||||||
|
const updateEnv = useMutation(api.userEnvironment.updateMine);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<DotfileMeta>();
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [savedContent, setSavedContent] = useState('');
|
||||||
|
const [expandedOverride, setExpandedOverride] = useState<string[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const filesInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const firstName = settings?.firstName ?? 'you';
|
||||||
|
const tree = useMemo(
|
||||||
|
() => buildTree(files, `home/${firstName}`),
|
||||||
|
[files, firstName],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Directories default to expanded; once the user toggles, their choice wins.
|
||||||
|
const allDirs = useMemo(
|
||||||
|
() =>
|
||||||
|
files
|
||||||
|
.flatMap((f) => {
|
||||||
|
const segs = f.path.split('/');
|
||||||
|
return segs
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((_, i) => segs.slice(0, i + 1).join('/'));
|
||||||
|
})
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i),
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
const expanded = expandedOverride ?? allDirs;
|
||||||
|
|
||||||
|
const openFile = async (path: string) => {
|
||||||
|
const file = files.find((f) => f.path === path);
|
||||||
|
if (!file) return; // directory
|
||||||
|
setSelected(file);
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
try {
|
||||||
|
const { content: text } = await getFileContent({
|
||||||
|
fileId: file._id as never,
|
||||||
|
});
|
||||||
|
setContent(text);
|
||||||
|
setSavedContent(text);
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not open file.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSelected = async (next: string) => {
|
||||||
|
if (!selected) return;
|
||||||
|
await putFile({
|
||||||
|
path: selected.path,
|
||||||
|
content: next,
|
||||||
|
isExecutable: selected.isExecutable,
|
||||||
|
});
|
||||||
|
setSavedContent(next);
|
||||||
|
toast.success('Saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAll = async (incoming: UploadFile[]) => {
|
||||||
|
const valid = incoming.filter((f) => f.path.trim());
|
||||||
|
if (valid.length === 0) return;
|
||||||
|
try {
|
||||||
|
await importFiles({ files: valid });
|
||||||
|
toast.success(`Imported ${valid.length} file(s).`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Import failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = async (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const out: UploadFile[] = [];
|
||||||
|
const entries: FsEntry[] = [];
|
||||||
|
for (const item of Array.from(event.dataTransfer.items)) {
|
||||||
|
const entry = item.webkitGetAsEntry() as FsEntry | null;
|
||||||
|
if (entry) entries.push(entry);
|
||||||
|
}
|
||||||
|
if (entries.length > 0) {
|
||||||
|
for (const entry of entries) await collectEntry(entry, '', out);
|
||||||
|
} else {
|
||||||
|
for (const file of Array.from(event.dataTransfer.files)) {
|
||||||
|
out.push({ path: file.name, content: await file.text() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await importAll(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickFiles = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
stripFirstSegment: boolean,
|
||||||
|
) => {
|
||||||
|
const picked = Array.from(event.target.files ?? []);
|
||||||
|
const out: UploadFile[] = [];
|
||||||
|
for (const file of picked) {
|
||||||
|
const relative =
|
||||||
|
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||||
|
file.name;
|
||||||
|
const path = stripFirstSegment
|
||||||
|
? relative.split('/').slice(1).join('/')
|
||||||
|
: relative;
|
||||||
|
out.push({ path, content: await file.text() });
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
await importAll(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newFile = async () => {
|
||||||
|
const path = window.prompt('New file path (relative to home):', '.bashrc');
|
||||||
|
if (!path?.trim()) return;
|
||||||
|
try {
|
||||||
|
await putFile({ path: path.trim(), content: '' });
|
||||||
|
toast.success('Created.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Could not create.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
await removeFile({ fileId: selected._id as never });
|
||||||
|
setSelected(undefined);
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
toast.success('Deleted.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card className='gap-0 overflow-hidden p-0 shadow-none'>
|
||||||
|
<div className='border-border flex flex-wrap items-center gap-2 border-b p-2'>
|
||||||
|
<Button type='button' variant='outline' size='sm' onClick={newFile}>
|
||||||
|
<FilePlus className='size-4' /> New file
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FolderUp className='size-4' /> Upload folder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => filesInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className='size-4' /> Upload files
|
||||||
|
</Button>
|
||||||
|
{selected ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='text-destructive ml-auto'
|
||||||
|
onClick={() => void deleteSelected()}
|
||||||
|
>
|
||||||
|
<Trash2 className='size-4' /> Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type='file'
|
||||||
|
// @ts-expect-error non-standard but widely supported folder picker
|
||||||
|
webkitdirectory=''
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => void onPickFiles(e, true)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={filesInputRef}
|
||||||
|
type='file'
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => void onPickFiles(e, false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid min-h-[28rem] grid-cols-1 md:grid-cols-[16rem_1fr]'>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => void onDrop(e)}
|
||||||
|
className={`border-border min-h-0 overflow-auto border-b md:border-r md:border-b-0 ${
|
||||||
|
dragOver ? 'bg-primary/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileTree
|
||||||
|
tree={tree}
|
||||||
|
selectedPath={selected?.path}
|
||||||
|
expandedPaths={expanded}
|
||||||
|
onSelect={(path) => void openFile(path)}
|
||||||
|
onToggleDirectory={(path) =>
|
||||||
|
setExpandedOverride(
|
||||||
|
expanded.includes(path)
|
||||||
|
? expanded.filter((p) => p !== path)
|
||||||
|
: [...expanded, path],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className='text-muted-foreground p-4 text-center text-xs'>
|
||||||
|
Drag files or folders here, or use the buttons above. They land
|
||||||
|
relative to your home directory.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className='min-h-0'>
|
||||||
|
{selected ? (
|
||||||
|
<CodeEditor
|
||||||
|
path={selected.path}
|
||||||
|
content={content}
|
||||||
|
savedContent={savedContent}
|
||||||
|
readOnly={false}
|
||||||
|
vimEnabled={false}
|
||||||
|
onSave={saveSelected}
|
||||||
|
onChange={setContent}
|
||||||
|
onVimEnabledChange={() => undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='text-muted-foreground flex h-full items-center justify-center p-6 text-sm'>
|
||||||
|
Select a file to edit, or add files to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RepoPanel
|
||||||
|
settings={settings}
|
||||||
|
onSave={async (values) => {
|
||||||
|
await updateEnv(values);
|
||||||
|
toast.success('Saved.');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Dotfiles are encrypted at rest. For real API keys or tokens, use the
|
||||||
|
Secrets feature on a Spoon instead — those are injected as environment
|
||||||
|
variables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RepoPanel = ({
|
||||||
|
settings,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
settings:
|
||||||
|
| {
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
onSave: (values: {
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const [repoUrl, setRepoUrl] = useState('');
|
||||||
|
const [repoRef, setRepoRef] = useState('');
|
||||||
|
const [setupCommand, setSetupCommand] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings || hydrated) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setRepoUrl(settings.dotfilesRepoUrl ?? '');
|
||||||
|
setRepoRef(settings.dotfilesRepoRef ?? '');
|
||||||
|
setSetupCommand(settings.setupCommand ?? '');
|
||||||
|
setHydrated(true);
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [settings, hydrated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='space-y-3 p-4 shadow-none'>
|
||||||
|
<div>
|
||||||
|
<h3 className='font-medium'>Dotfiles repo (optional)</h3>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
A public git repo cloned to <code>~/.dotfiles</code> on start. The
|
||||||
|
setup command runs in the container afterwards (e.g.{' '}
|
||||||
|
<code>install</code> to symlink, like a dotfiles bootstrap). Your
|
||||||
|
edited files above are applied on top.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 sm:grid-cols-2'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<Label htmlFor='repoUrl'>Public repo URL</Label>
|
||||||
|
<Input
|
||||||
|
id='repoUrl'
|
||||||
|
placeholder='https://github.com/you/dotfiles'
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={(e) => setRepoUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<Label htmlFor='repoRef'>Branch / ref (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id='repoRef'
|
||||||
|
placeholder='main'
|
||||||
|
value={repoRef}
|
||||||
|
onChange={(e) => setRepoRef(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<Label htmlFor='setupCommand'>Setup script path (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id='setupCommand'
|
||||||
|
placeholder='install.sh'
|
||||||
|
value={setupCommand}
|
||||||
|
onChange={(e) => setSetupCommand(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setSaving(true);
|
||||||
|
void onSave({
|
||||||
|
dotfilesRepoUrl: repoUrl,
|
||||||
|
dotfilesRepoRef: repoRef,
|
||||||
|
setupCommand,
|
||||||
|
}).finally(() => setSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save repo settings'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -582,57 +582,57 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="],
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="],
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="],
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="],
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="],
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="],
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="],
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="],
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="],
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="],
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="],
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="],
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="],
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="],
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="],
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="],
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="],
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="],
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="],
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="],
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="],
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="],
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="],
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
@@ -984,7 +984,7 @@
|
|||||||
|
|
||||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-s9OcS7pubNCimS98B9ERJ/59veOj1SSGHD0qGBxGIx+164wSspUlHsAWhQIihvF8eZe16F5VY1XUQIEXGBTm2Q=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -1502,19 +1502,19 @@
|
|||||||
|
|
||||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||||
|
|
||||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="],
|
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg=="],
|
||||||
|
|
||||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="],
|
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg=="],
|
||||||
|
|
||||||
"@turbo/gen": ["@turbo/gen@2.9.18", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "esbuild": "^0.25.0" }, "bin": { "gen": "dist/cli.js" } }, "sha512-9Ry3V2eqFANYI7A5dyjehq2EOuLTY30XQSg4aDR7F3cJOuiP/Ad2KXwrxD3AnwNDkuSDVbJjlbES7yfJ0y7dhw=="],
|
"@turbo/gen": ["@turbo/gen@2.10.0", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "esbuild": "^0.28.1" }, "bin": { "gen": "dist/cli.js" } }, "sha512-QrnFiSKpKjijnQhde4VgEsg+WA8dQRc6EzO4iLy1+n7R8QZ3BCeVR7NePVOhhYcewoD8GZHnSPwrzu9cOvTdOA=="],
|
||||||
|
|
||||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="],
|
"@turbo/linux-64": ["@turbo/linux-64@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sZBtjMuufitanjzi6UssoUpJMnnPlLMcdcJj3m3ptNsSq31Xh7MnjhwA5nWvLDTfEFg8GPcbYFXMo8vSdKRfqQ=="],
|
||||||
|
|
||||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="],
|
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vkq/Z8R+1DQ+kifWFa810IjRy2NNBVvha3cg9sWA3nFh6nnGrHSMnnJKrzH7c/No9kq4Jb55Ru44YKsCSBgrKg=="],
|
||||||
|
|
||||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="],
|
"@turbo/windows-64": ["@turbo/windows-64@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CRUEguLWxFQHptYZS7HjPhNhAFawfea07iR+xAQ5e4klgLrPCMdexBkXwSCwOxqTFknJ7RZFN3gOaADsw+Gttg=="],
|
||||||
|
|
||||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="],
|
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-dVHGaf9F8twzgibcBqKoADT/LLqf9++jDb+hq/LPWWaOmRpp4M+/pVOm7vy4z9D++xg8eaxWLT0+wQxFwhYu9A=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.8.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.8.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q=="],
|
||||||
|
|
||||||
@@ -2118,7 +2118,7 @@
|
|||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
@@ -3282,7 +3282,7 @@
|
|||||||
|
|
||||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||||
|
|
||||||
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
|
"turbo": ["turbo@2.10.0", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.0", "@turbo/darwin-arm64": "2.10.0", "@turbo/linux-64": "2.10.0", "@turbo/linux-arm64": "2.10.0", "@turbo/windows-64": "2.10.0", "@turbo/windows-arm64": "2.10.0" }, "bin": { "turbo": "bin/turbo" } }, "sha512-o016H9PPtuH2deb3mh3Vci3Avfi9UYgM/RONQisY7HnloupP0IFSbFS3gFYJgFJP8nwBrByHWFQIDa8T2zIXPw=="],
|
||||||
|
|
||||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Spoon container — neutral interactive shell defaults (system-wide).
|
||||||
|
# Tools here benefit everyone; a user's ~/.bashrc (loaded via ~/.bash_profile,
|
||||||
|
# which the worker ensures) layers on top and can override any of this.
|
||||||
|
|
||||||
|
# Interactive shells only.
|
||||||
|
case $- in
|
||||||
|
*i*) ;;
|
||||||
|
*) return ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export EDITOR="${EDITOR:-nvim}"
|
||||||
|
export PAGER="${PAGER:-less}"
|
||||||
|
# User-local + bun install locations.
|
||||||
|
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"
|
||||||
|
|
||||||
|
if command -v zoxide >/dev/null 2>&1; then
|
||||||
|
eval "$(zoxide init bash)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v eza >/dev/null 2>&1; then
|
||||||
|
alias ls='eza --group-directories-first --icons'
|
||||||
|
alias ll='eza -lh --group-directories-first --icons --git'
|
||||||
|
alias la='eza -lha --group-directories-first --icons --git'
|
||||||
|
alias lt='eza --tree --level=2 --icons --git'
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never --style=plain'
|
||||||
|
alias n='nvim'
|
||||||
|
alias g='git'
|
||||||
|
alias cl='clear'
|
||||||
|
|
||||||
|
# fzf keybindings + completion when present.
|
||||||
|
for f in /usr/share/fzf/shell/key-bindings.bash \
|
||||||
|
/usr/share/bash-completion/completions/fzf; do
|
||||||
|
[ -f "$f" ] && . "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v oh-my-posh >/dev/null 2>&1 && [ -f /etc/spoon/omp.json ]; then
|
||||||
|
eval "$(oh-my-posh init bash --config /etc/spoon/omp.json)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
|
||||||
|
"version": 3,
|
||||||
|
"final_space": true,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "prompt",
|
||||||
|
"alignment": "left",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"type": "path",
|
||||||
|
"style": "plain",
|
||||||
|
"foreground": "#5fd0e0",
|
||||||
|
"template": " {{ .Path }} ",
|
||||||
|
"properties": { "style": "agnoster_short", "max_depth": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "git",
|
||||||
|
"style": "plain",
|
||||||
|
"foreground": "#8fd6b4",
|
||||||
|
"template": "{{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} ",
|
||||||
|
"properties": {
|
||||||
|
"fetch_status": true,
|
||||||
|
"branch_icon": " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "prompt",
|
||||||
|
"alignment": "left",
|
||||||
|
"newline": true,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"style": "plain",
|
||||||
|
"foreground": "#1fb895",
|
||||||
|
"foreground_templates": ["{{ if gt .Code 0 }}#f3625d{{ end }}"],
|
||||||
|
"template": "❯ "
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Spoon container — system tmux defaults. A user's ~/.config/tmux/tmux.conf (or
|
||||||
|
# ~/.tmux.conf) is read after this and overrides it.
|
||||||
|
|
||||||
|
# Login shells so /etc/profile.d/spoon.sh (tools) and ~/.bash_profile load.
|
||||||
|
set -g default-command "exec bash -l"
|
||||||
|
set -g default-terminal "tmux-256color"
|
||||||
|
set -ag terminal-overrides ",xterm-256color:RGB"
|
||||||
|
set -g mouse on
|
||||||
|
set -g history-limit 50000
|
||||||
|
set -g escape-time 10
|
||||||
|
set -g focus-events on
|
||||||
|
setw -g mode-keys vi
|
||||||
+42
-11
@@ -1,30 +1,61 @@
|
|||||||
FROM docker.io/library/node:22-bookworm
|
FROM registry.fedoraproject.org/fedora:41
|
||||||
|
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
ENV LANG=en_US.UTF-8
|
||||||
|
|
||||||
RUN apt-get update \
|
# Core toolchain + interactive/QoL CLI tooling. Everything below is in the
|
||||||
&& apt-get install -y --no-install-recommends \
|
# default Fedora repos (no COPR needed). The QoL set mirrors the user's Panama
|
||||||
|
# workstation setup so the terminal feels like a real dev box for everyone.
|
||||||
|
RUN dnf install -y --setopt=install_weak_deps=False --nodocs \
|
||||||
bash \
|
bash \
|
||||||
|
bash-completion \
|
||||||
|
bat \
|
||||||
bubblewrap \
|
bubblewrap \
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
|
eza \
|
||||||
|
fd-find \
|
||||||
|
findutils \
|
||||||
|
fzf \
|
||||||
|
gcc \
|
||||||
|
gcc-c++ \
|
||||||
|
gh \
|
||||||
git \
|
git \
|
||||||
|
glibc-langpack-en \
|
||||||
|
gum \
|
||||||
|
gzip \
|
||||||
jq \
|
jq \
|
||||||
less \
|
less \
|
||||||
locales \
|
make \
|
||||||
|
ncurses \
|
||||||
neovim \
|
neovim \
|
||||||
openssh-client \
|
nodejs \
|
||||||
|
nodejs-npm \
|
||||||
|
openssh-clients \
|
||||||
|
procps-ng \
|
||||||
python3 \
|
python3 \
|
||||||
|
python3-pip \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
|
tar \
|
||||||
tmux \
|
tmux \
|
||||||
unzip \
|
unzip \
|
||||||
wget \
|
wget \
|
||||||
&& corepack enable \
|
which \
|
||||||
&& corepack prepare pnpm@latest --activate \
|
zoxide \
|
||||||
&& corepack prepare yarn@stable --activate \
|
&& dnf clean all \
|
||||||
&& npm install -g bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
|
&& rm -rf /var/cache/dnf
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
# Package managers + pinned agent CLIs (kept identical to the prior image).
|
||||||
|
# Fedora's nodejs-npm doesn't ship corepack, so install pnpm/yarn via npm.
|
||||||
|
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme).
|
||||||
|
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
|
||||||
|
&& oh-my-posh version
|
||||||
|
|
||||||
|
# Neutral system-wide defaults: /etc/profile.d/spoon.sh, /etc/tmux.conf, theme.
|
||||||
|
COPY docker/agent-job-rootfs/ /
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Personalized dev environment (dotfiles + persistent home)
|
||||||
|
|
||||||
|
Makes the workspace terminal feel like the user's own machine: a Fedora image
|
||||||
|
preloaded with QoL CLI tooling, a persistent per-user home, and user dotfiles.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
- **Persistent per-user home.** Each user gets a home directory on the worker
|
||||||
|
host at `${SPOON_AGENT_WORKDIR}/homes/{username}`, bind-mounted into every
|
||||||
|
job/terminal container at `/home/{username}` (`HOME`). It survives across
|
||||||
|
sessions, so dotfiles, installed tools, nvim plugins, and shell history persist.
|
||||||
|
`username` is derived from the user's profile first name (sanitized).
|
||||||
|
- **Threads as folders.** Each thread's checkout lives at
|
||||||
|
`~/Code/{spoon}/{branch}` inside that home, so every thread shows up as a
|
||||||
|
folder in one home. The agent (`codex --cd …`) and the terminal both open there.
|
||||||
|
- **Neutral defaults (everyone).** The Fedora job image
|
||||||
|
(`docker/agent-job.Dockerfile`) ships zoxide, eza, bat, fzf, fd, ripgrep, gh,
|
||||||
|
gum, neovim, tmux, oh-my-posh, etc., plus system-wide defaults that work even
|
||||||
|
with an empty home: `/etc/profile.d/spoon.sh` (tool init + aliases),
|
||||||
|
`/etc/tmux.conf` (login-shell panes), `/etc/spoon/omp.json` (prompt theme).
|
||||||
|
- **User dotfiles (per-user).** Configured in **Settings → Dotfiles**, applied on
|
||||||
|
top of the neutral defaults.
|
||||||
|
|
||||||
|
## Settings → Dotfiles
|
||||||
|
|
||||||
|
A mini file-browser workspace rooted at `home/{firstName}`:
|
||||||
|
|
||||||
|
- **Editable overlay tree** — drag in files/folders (or use Upload folder/files),
|
||||||
|
edit them in the Monaco editor, add/delete. Files are placed **relative to
|
||||||
|
`$HOME`** (`.bashrc` → `~/.bashrc`, `.config/nvim/…` → `~/.config/nvim/…`).
|
||||||
|
Stored encrypted at rest (`userDotfiles`, AES-256-GCM via `secretCrypto`).
|
||||||
|
- **Dotfiles repo (optional)** — a **public** git repo URL + optional ref + a
|
||||||
|
setup script path. On start the container clones it to `~/.dotfiles` and runs
|
||||||
|
`bash ~/.dotfiles/<setup>` (e.g. a bootstrap that symlinks configs, like the
|
||||||
|
user's Panama `install`).
|
||||||
|
- **Precedence (hybrid):** repo clone + setup runs first; then the editable
|
||||||
|
overlay is written on top — **overlay wins**.
|
||||||
|
|
||||||
|
Secrets: dotfiles are encrypted, but real API keys/tokens belong in a Spoon's
|
||||||
|
**Secrets** feature (injected as env vars), not in dotfiles. The UI nudges this.
|
||||||
|
|
||||||
|
## Materialization (worker)
|
||||||
|
|
||||||
|
`apps/agent-worker/src/user-environment.ts`:
|
||||||
|
|
||||||
|
1. `fetchUserEnvironment(jobId)` — a worker-token Convex action
|
||||||
|
(`userDotfilesNode.getEnvironmentForJob`) returns the owner's decrypted
|
||||||
|
dotfiles + repo/setup config.
|
||||||
|
2. `materializeUserHome` — ensures `~/.bash_profile` (so login shells source
|
||||||
|
`~/.bashrc` in a mounted home with no `/etc/skel`); clones the repo + runs the
|
||||||
|
setup command **inside the job image** (so the user's tools/paths apply), only
|
||||||
|
when the config hash changes (`~/.spoon/env-hash`); writes the overlay files.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `SPOON_AGENT_WORKDIR` | `.local/agent-work` (dev) / `/var/lib/spoon-agent/work` (prod) | Per-user homes live under `homes/{username}`; reuses the existing host-path translation. |
|
||||||
|
| `SPOON_ENCRYPTION_KEY` / `INSTANCE_SECRET` | — | Already required; encrypts dotfiles like other secrets. |
|
||||||
|
|
||||||
|
No new required env. The home is a host directory under the existing workdir, so
|
||||||
|
the prod bind-mount + `SPOON_AGENT_HOST_WORKDIR` translation already covers it.
|
||||||
|
|
||||||
|
## Notes / limits (Phase 1)
|
||||||
|
|
||||||
|
- **Repo auth:** public repos only. Private/self-hosted (e.g. Gitea) dotfiles
|
||||||
|
repos are a follow-up (store a token/deploy key).
|
||||||
|
- **Binary files:** the overlay is text-first.
|
||||||
|
- **Cleanup:** `~/Code/{spoon}/{branch}` checkouts persist (threads as folders);
|
||||||
|
a per-thread "delete checkout" action is a follow-up.
|
||||||
|
- **Concurrency:** jobs share one home; fine at the default
|
||||||
|
`SPOON_AGENT_MAX_CONCURRENT_JOBS=1`.
|
||||||
|
|
||||||
|
## Phase 2 north star
|
||||||
|
|
||||||
|
A single long-running per-user container that every thread `exec`s into (agent
|
||||||
|
via `docker exec`, not `docker run --rm`). The per-user home + `~/Code/{spoon}/
|
||||||
|
{branch}` layout built here is its foundation.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Server deploy changes (terminal + dotfiles + Fedora + Phase 2)
|
||||||
|
|
||||||
|
Everything the production host / compose / `.env` needs for the workspace
|
||||||
|
terminal, personalized dev environment, Nerd Font, and the per-user container.
|
||||||
|
Most items have safe defaults; the **Required** ones are the only must-dos.
|
||||||
|
|
||||||
|
## Required
|
||||||
|
|
||||||
|
1. **Build-time env for the Next image** — add to the build env file (the one CI /
|
||||||
|
`scripts/build-next-app` passes as build args; e.g. `DOTENV_PROD`):
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a `NEXT_PUBLIC` (build-time) var — it must be present **when the
|
||||||
|
`spoon-next` image is built**, not just at runtime. Already wired into
|
||||||
|
`docker/Dockerfile` + `docker/compose.yml` build args. Without it, the
|
||||||
|
workspace **Terminal** tab shows "not configured".
|
||||||
|
|
||||||
|
2. **nginx: expose the worker for the terminal WebSocket.** Add a TLS server
|
||||||
|
block proxying the worker domain to the worker on the shared network, with WS
|
||||||
|
upgrade:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name worker.spoon.gbrown.org; # + your ssl_certificate lines
|
||||||
|
location / {
|
||||||
|
proxy_pass http://spoon-agent-worker:3921;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Rebuild + redeploy all three images** (CI does this on push to `main`):
|
||||||
|
`spoon-next`, `spoon-agent-worker`, and `spoon-agent-job` (now **Fedora**).
|
||||||
|
The worker auto-`docker pull`s the job image once per process, so a worker
|
||||||
|
restart picks up the new Fedora job image. Make sure the prod registry has the
|
||||||
|
new `spoon-agent-job:latest`.
|
||||||
|
|
||||||
|
4. **Deploy Convex functions** (new tables `userDotfiles`, `userEnvironment`).
|
||||||
|
`SPOON_ENCRYPTION_KEY` (or `INSTANCE_SECRET`) is already required and is what
|
||||||
|
encrypts dotfiles at rest — no change, just confirm it's set.
|
||||||
|
|
||||||
|
5. **Confirm `SPOON_AGENT_HOST_WORKDIR`** on the `spoon-agent-worker` service is
|
||||||
|
the absolute host path backing `SPOON_AGENT_WORKDIR` (the fix from the terminal
|
||||||
|
work). The per-user homes live under `${SPOON_AGENT_WORKDIR}/homes/{username}`
|
||||||
|
and are bind-mounted into the box via the host daemon — this only resolves if
|
||||||
|
the host-workdir translation is correct. (No new var; just verify.)
|
||||||
|
|
||||||
|
## Optional (safe defaults — only set to override)
|
||||||
|
|
||||||
|
On the `spoon-agent-worker` service:
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
| ------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `SPOON_AGENT_TERMINAL_SECRET` | falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | HMAC secret for terminal tokens (must match the Next app's, which also falls back). Leave unset to use the shared token. |
|
||||||
|
| `SPOON_AGENT_BOX_IDLE_MS` | `1800000` (30m) | How long a per-user box survives idle before being reaped. |
|
||||||
|
| `SPOON_AGENT_TERMINAL_IDLE_MS` | `1800000` | (Legacy; box idle now governs cleanup.) |
|
||||||
|
|
||||||
|
No new env is needed for dotfiles, the per-user home, or the Nerd Font.
|
||||||
|
|
||||||
|
## Notes / one-time cleanup
|
||||||
|
|
||||||
|
- **Layout change:** thread checkouts moved from `${WORKDIR}/{jobId}/repo` to
|
||||||
|
`${WORKDIR}/homes/{username}/Code/{spoon}/{branch}` (persistent). Old per-job
|
||||||
|
dirs are orphaned and safe to delete.
|
||||||
|
- **Containers:** per-thread agent containers (`docker run --rm`) and per-job
|
||||||
|
terminal containers (`spoon-agent-term-*`) are gone; everything runs in one
|
||||||
|
`spoon-box-{username}` per user. Any lingering `spoon-agent-term-*` containers
|
||||||
|
can be removed.
|
||||||
|
- **Resources:** each active user holds one box (4 GB mem cap, `sleep infinity`)
|
||||||
|
until 30m idle. Single-user = one box.
|
||||||
|
- Compose already mounts `/var/run/docker.sock` into the worker (unchanged) — the
|
||||||
|
box is created/exec'd through it.
|
||||||
|
|
||||||
|
## Quick post-deploy checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec spoon-agent-worker docker --version # CLI present (29.x)
|
||||||
|
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest codex --version # 0.142
|
||||||
|
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest bash -lc 'eza --version; zoxide --version; oh-my-posh --version'
|
||||||
|
# then: open a thread → Terminal tab; Settings → Dotfiles add a .bashrc alias.
|
||||||
|
```
|
||||||
@@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => {
|
|||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Linux username for the per-user container home (/home/<username>). Derived
|
||||||
|
// from the first token of the profile name, sanitized; falls back to "user".
|
||||||
|
export const deriveHomeUsername = (name?: string): string => {
|
||||||
|
const first = (name ?? '').trim().split(/\s+/)[0] ?? '';
|
||||||
|
const sanitized = first.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||||
|
return sanitized || 'user';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalizes a dotfile path to a safe HOME-relative path (no leading slash, no
|
||||||
|
// "..", no empty segments). Throws on anything that would escape HOME.
|
||||||
|
export const normalizeDotfilePath = (rawPath: string): string => {
|
||||||
|
const cleaned = rawPath
|
||||||
|
.trim()
|
||||||
|
.replace(/^\.\/+/, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
const segments = cleaned.split('/').filter((s) => s.length > 0);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
throw new ConvexError('A dotfile path is required.');
|
||||||
|
}
|
||||||
|
if (segments.some((s) => s === '..' || s === '.')) {
|
||||||
|
throw new ConvexError(`Invalid dotfile path: ${rawPath}`);
|
||||||
|
}
|
||||||
|
return segments.join('/');
|
||||||
|
};
|
||||||
|
|||||||
@@ -348,6 +348,30 @@ const applicationTables = {
|
|||||||
})
|
})
|
||||||
.index('by_user', ['userId'])
|
.index('by_user', ['userId'])
|
||||||
.index('by_user_provider', ['userId', 'provider']),
|
.index('by_user_provider', ['userId', 'provider']),
|
||||||
|
// Per-user dotfiles: one row per file, materialized into the workspace
|
||||||
|
// container's HOME. Content is encrypted at rest (reuses secretCrypto).
|
||||||
|
// `path` is relative to HOME, e.g. ".bashrc" or ".config/nvim/init.lua".
|
||||||
|
userDotfiles: defineTable({
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
path: v.string(),
|
||||||
|
encryptedContent: v.string(),
|
||||||
|
size: v.number(),
|
||||||
|
isExecutable: v.optional(v.boolean()),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_owner', ['ownerId'])
|
||||||
|
.index('by_owner_path', ['ownerId', 'path']),
|
||||||
|
// Per-user environment config: the persistent home username + an optional
|
||||||
|
// public dotfiles repo and setup command run in the container.
|
||||||
|
userEnvironment: defineTable({
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
enabled: v.boolean(),
|
||||||
|
homeUsername: v.optional(v.string()),
|
||||||
|
dotfilesRepoUrl: v.optional(v.string()),
|
||||||
|
dotfilesRepoRef: v.optional(v.string()),
|
||||||
|
setupCommand: v.optional(v.string()),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
}).index('by_owner', ['ownerId']),
|
||||||
aiProviderProfiles: defineTable({
|
aiProviderProfiles: defineTable({
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
import {
|
||||||
|
internalMutation,
|
||||||
|
internalQuery,
|
||||||
|
mutation,
|
||||||
|
query,
|
||||||
|
} from './_generated/server';
|
||||||
|
import { getRequiredUserId, normalizeDotfilePath } from './model';
|
||||||
|
|
||||||
|
const fileMeta = (file: Doc<'userDotfiles'>) => ({
|
||||||
|
_id: file._id,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
isExecutable: file.isExecutable ?? false,
|
||||||
|
updatedAt: file.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Lists the user's dotfile tree (metadata only; content is fetched per-file). */
|
||||||
|
export const listMine = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const files = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { fileId: v.id('userDotfiles') },
|
||||||
|
handler: async (ctx, { fileId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const file = await ctx.db.get(fileId);
|
||||||
|
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
||||||
|
await ctx.db.delete(fileId);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */
|
||||||
|
export const removeDirectory = mutation({
|
||||||
|
args: { prefix: v.string() },
|
||||||
|
handler: async (ctx, { prefix }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const normalized = normalizeDotfilePath(prefix);
|
||||||
|
const files = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
const matches = files.filter(
|
||||||
|
(f) => f.path === normalized || f.path.startsWith(`${normalized}/`),
|
||||||
|
);
|
||||||
|
await Promise.all(matches.map((f) => ctx.db.delete(f._id)));
|
||||||
|
return { removed: matches.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rename = mutation({
|
||||||
|
args: { fileId: v.id('userDotfiles'), path: v.string() },
|
||||||
|
handler: async (ctx, { fileId, path }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const file = await ctx.db.get(fileId);
|
||||||
|
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
||||||
|
const normalized = normalizeDotfilePath(path);
|
||||||
|
const clash = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner_path', (q) =>
|
||||||
|
q.eq('ownerId', ownerId).eq('path', normalized),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (clash && clash._id !== fileId) {
|
||||||
|
throw new ConvexError(`A dotfile already exists at ${normalized}.`);
|
||||||
|
}
|
||||||
|
await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read by the decrypting Node action (userDotfilesNode.getFileContent).
|
||||||
|
export const getRawFileInternal = internalQuery({
|
||||||
|
args: { fileId: v.id('userDotfiles') },
|
||||||
|
handler: async (ctx, { fileId }) => await ctx.db.get(fileId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Called by the encrypting Node action (userDotfilesNode). Upserts one file by
|
||||||
|
// (owner, path).
|
||||||
|
export const upsertFileInternal = internalMutation({
|
||||||
|
args: {
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
path: v.string(),
|
||||||
|
encryptedContent: v.string(),
|
||||||
|
size: v.number(),
|
||||||
|
isExecutable: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner_path', (q) =>
|
||||||
|
q.eq('ownerId', args.ownerId).eq('path', args.path),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
const now = Date.now();
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
encryptedContent: args.encryptedContent,
|
||||||
|
size: args.size,
|
||||||
|
isExecutable: args.isExecutable,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
return await ctx.db.insert('userDotfiles', {
|
||||||
|
...args,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use node';
|
||||||
|
|
||||||
|
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import type { ActionCtx } from './_generated/server';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { action } from './_generated/server';
|
||||||
|
import { normalizeDotfilePath } from './model';
|
||||||
|
import { decryptSecret, encryptSecret } from './secretCrypto';
|
||||||
|
|
||||||
|
const MAX_FILE_BYTES = 512 * 1024; // 512 KB per dotfile
|
||||||
|
|
||||||
|
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
|
return userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireWorkerToken = (workerToken: string) => {
|
||||||
|
const expected = process.env.SPOON_WORKER_TOKEN;
|
||||||
|
if (!expected) throw new ConvexError('Worker token is not configured.');
|
||||||
|
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const putOne = async (
|
||||||
|
ctx: ActionCtx,
|
||||||
|
ownerId: Id<'users'>,
|
||||||
|
rawPath: string,
|
||||||
|
content: string,
|
||||||
|
isExecutable?: boolean,
|
||||||
|
) => {
|
||||||
|
const path = normalizeDotfilePath(rawPath);
|
||||||
|
const size = Buffer.byteLength(content, 'utf8');
|
||||||
|
if (size > MAX_FILE_BYTES) {
|
||||||
|
throw new ConvexError(`${path} is too large (max 512 KB).`);
|
||||||
|
}
|
||||||
|
await ctx.runMutation(internal.userDotfiles.upsertFileInternal, {
|
||||||
|
ownerId,
|
||||||
|
path,
|
||||||
|
encryptedContent: encryptSecret(content),
|
||||||
|
size,
|
||||||
|
isExecutable,
|
||||||
|
});
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Create/update a single dotfile (used by the in-app editor and "new file"). */
|
||||||
|
export const putFile = action({
|
||||||
|
args: {
|
||||||
|
path: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
isExecutable: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<{ path: string }> => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const path = await putOne(
|
||||||
|
ctx,
|
||||||
|
ownerId,
|
||||||
|
args.path,
|
||||||
|
args.content,
|
||||||
|
args.isExecutable,
|
||||||
|
);
|
||||||
|
return { path };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bulk import (drag-and-drop folder/files). */
|
||||||
|
export const importFiles = action({
|
||||||
|
args: {
|
||||||
|
files: v.array(
|
||||||
|
v.object({
|
||||||
|
path: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
isExecutable: v.optional(v.boolean()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<{ imported: number }> => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
for (const file of args.files) {
|
||||||
|
await putOne(ctx, ownerId, file.path, file.content, file.isExecutable);
|
||||||
|
}
|
||||||
|
return { imported: args.files.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Decrypts one file's content for the editor (owner only). */
|
||||||
|
export const getFileContent = action({
|
||||||
|
args: { fileId: v.id('userDotfiles') },
|
||||||
|
handler: async (ctx, { fileId }): Promise<{ content: string }> => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const file = await ctx.runQuery(internal.userDotfiles.getRawFileInternal, {
|
||||||
|
fileId,
|
||||||
|
});
|
||||||
|
if (file?.ownerId !== ownerId) {
|
||||||
|
throw new ConvexError('Dotfile not found.');
|
||||||
|
}
|
||||||
|
return { content: decryptSecret(file.encryptedContent) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type WorkerEnvironment = {
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
files: { path: string; content: string; isExecutable: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Worker-facing: the job owner's full environment with dotfiles decrypted. */
|
||||||
|
export const getEnvironmentForJob = action({
|
||||||
|
args: { workerToken: v.string(), jobId: v.id('agentJobs') },
|
||||||
|
handler: async (ctx, args): Promise<WorkerEnvironment | null> => {
|
||||||
|
requireWorkerToken(args.workerToken);
|
||||||
|
const raw = await ctx.runQuery(
|
||||||
|
internal.userEnvironment.getRawEnvironmentForJobInternal,
|
||||||
|
{ jobId: args.jobId },
|
||||||
|
);
|
||||||
|
if (!raw) return null;
|
||||||
|
return {
|
||||||
|
username: raw.username,
|
||||||
|
enabled: raw.enabled,
|
||||||
|
dotfilesRepoUrl: raw.dotfilesRepoUrl,
|
||||||
|
dotfilesRepoRef: raw.dotfilesRepoRef,
|
||||||
|
setupCommand: raw.setupCommand,
|
||||||
|
files: raw.files.map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
content: decryptSecret(f.encryptedContent),
|
||||||
|
isExecutable: f.isExecutable,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import type { QueryCtx } from './_generated/server';
|
||||||
|
import { internalQuery, mutation, query } from './_generated/server';
|
||||||
|
import { deriveHomeUsername, getRequiredUserId, optionalText } from './model';
|
||||||
|
|
||||||
|
const loadSettings = async (ctx: QueryCtx, ownerId: Id<'users'>) =>
|
||||||
|
await ctx.db
|
||||||
|
.query('userEnvironment')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
/** Current user's environment settings + the resolved home username/first name. */
|
||||||
|
export const getMine = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const [user, settings] = await Promise.all([
|
||||||
|
ctx.db.get(ownerId),
|
||||||
|
loadSettings(ctx, ownerId),
|
||||||
|
]);
|
||||||
|
const firstName = (user?.name ?? '').trim().split(/\s+/)[0] || 'you';
|
||||||
|
const username = settings?.homeUsername ?? deriveHomeUsername(user?.name);
|
||||||
|
return {
|
||||||
|
enabled: settings?.enabled ?? true,
|
||||||
|
username,
|
||||||
|
firstName,
|
||||||
|
dotfilesRepoUrl: settings?.dotfilesRepoUrl,
|
||||||
|
dotfilesRepoRef: settings?.dotfilesRepoRef,
|
||||||
|
setupCommand: settings?.setupCommand,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMine = mutation({
|
||||||
|
args: {
|
||||||
|
enabled: v.optional(v.boolean()),
|
||||||
|
dotfilesRepoUrl: v.optional(v.string()),
|
||||||
|
dotfilesRepoRef: v.optional(v.string()),
|
||||||
|
setupCommand: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const repoUrl = optionalText(args.dotfilesRepoUrl);
|
||||||
|
if (repoUrl && !/^https?:\/\//.test(repoUrl)) {
|
||||||
|
throw new ConvexError('Dotfiles repo must be a public http(s) URL.');
|
||||||
|
}
|
||||||
|
const existing = await loadSettings(ctx, ownerId);
|
||||||
|
const patch = {
|
||||||
|
enabled: args.enabled ?? existing?.enabled ?? true,
|
||||||
|
dotfilesRepoUrl: repoUrl,
|
||||||
|
dotfilesRepoRef: optionalText(args.dotfilesRepoRef),
|
||||||
|
setupCommand: optionalText(args.setupCommand),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
await ctx.db.insert('userEnvironment', { ownerId, ...patch });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Worker-facing: everything needed to materialize a job's owner's environment.
|
||||||
|
// Content stays encrypted here; the Node action decrypts it. Resolves the owner
|
||||||
|
// from the job.
|
||||||
|
export const getRawEnvironmentForJobInternal = internalQuery({
|
||||||
|
args: { jobId: v.id('agentJobs') },
|
||||||
|
handler: async (ctx, { jobId }) => {
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (!job) return null;
|
||||||
|
const ownerId = job.ownerId;
|
||||||
|
const [user, settings, files] = await Promise.all([
|
||||||
|
ctx.db.get(ownerId),
|
||||||
|
loadSettings(ctx, ownerId),
|
||||||
|
ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
username: settings?.homeUsername ?? deriveHomeUsername(user?.name),
|
||||||
|
enabled: settings?.enabled ?? true,
|
||||||
|
dotfilesRepoUrl: settings?.dotfilesRepoUrl,
|
||||||
|
dotfilesRepoRef: settings?.dotfilesRepoRef,
|
||||||
|
setupCommand: settings?.setupCommand,
|
||||||
|
files: files.map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
encryptedContent: f.encryptedContent,
|
||||||
|
isExecutable: f.isExecutable ?? false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ export SPOON_AGENT_WORKER_URL="${SPOON_AGENT_WORKER_URL:-http://localhost:${SPOO
|
|||||||
export SPOON_AGENT_WORKER_INTERNAL_TOKEN="${SPOON_AGENT_WORKER_INTERNAL_TOKEN:-${SPOON_WORKER_TOKEN:-}}"
|
export SPOON_AGENT_WORKER_INTERNAL_TOKEN="${SPOON_AGENT_WORKER_INTERNAL_TOKEN:-${SPOON_WORKER_TOKEN:-}}"
|
||||||
export SPOON_AGENT_WORKDIR="${SPOON_AGENT_LOCAL_WORKDIR:-.local/agent-work/${WITH_ENV_ENVIRONMENT:-dev}}"
|
export SPOON_AGENT_WORKDIR="${SPOON_AGENT_LOCAL_WORKDIR:-.local/agent-work/${WITH_ENV_ENVIRONMENT:-dev}}"
|
||||||
export SPOON_AGENT_JOB_IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-spoon-agent-job:latest}"
|
export SPOON_AGENT_JOB_IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-spoon-agent-job:latest}"
|
||||||
|
# Self-terminate if the dev runner dies, so the worker never orphans on port 3921.
|
||||||
|
export SPOON_AGENT_DEV_WATCHDOG="${SPOON_AGENT_DEV_WATCHDOG:-1}"
|
||||||
|
|
||||||
if [[ "$SPOON_AGENT_CONTAINER_ACCESS" == "host_port" && -z "${SPOON_AGENT_KEEP_NETWORK:-}" ]]; then
|
if [[ "$SPOON_AGENT_CONTAINER_ACCESS" == "host_port" && -z "${SPOON_AGENT_KEEP_NETWORK:-}" ]]; then
|
||||||
unset SPOON_AGENT_NETWORK
|
unset SPOON_AGENT_NETWORK
|
||||||
|
|||||||
+18
-3
@@ -24,10 +24,25 @@ fi
|
|||||||
command -v infisical >/dev/null 2>&1 || { echo "export-env: Infisical CLI is required." >&2; exit 1; }
|
command -v infisical >/dev/null 2>&1 || { echo "export-env: Infisical CLI is required." >&2; exit 1; }
|
||||||
"$ROOT_DIR/scripts/infisical-account" ensure
|
"$ROOT_DIR/scripts/infisical-account" ensure
|
||||||
|
|
||||||
(cd "$ROOT_DIR" && infisical export --env="$INFISICAL_ENV" --format=dotenv --silent) || {
|
# Retry transient Infisical failures (e.g. 500s when several dev tasks fetch
|
||||||
echo "export-env: failed to export '$INFISICAL_ENV'; check login and project access." >&2
|
# concurrently at startup) so one flaky response doesn't kill the dev server.
|
||||||
|
attempt=0
|
||||||
|
while :; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if EXPORT_OUT=$(cd "$ROOT_DIR" && infisical export --env="$INFISICAL_ENV" --format=dotenv --silent 2>"/tmp/export-env.$$.err"); then
|
||||||
|
printf '%s\n' "$EXPORT_OUT"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -ge 5 ]; then
|
||||||
|
cat "/tmp/export-env.$$.err" >&2 2>/dev/null || true
|
||||||
|
rm -f "/tmp/export-env.$$.err"
|
||||||
|
echo "export-env: failed to export '$INFISICAL_ENV' after $attempt attempts; check login and project access." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
fi
|
||||||
|
echo "export-env: Infisical export failed (attempt $attempt/5), retrying in 2s..." >&2
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
rm -f "/tmp/export-env.$$.err"
|
||||||
|
|
||||||
if [ -f "$STATE_FILE" ]; then
|
if [ -f "$STATE_FILE" ]; then
|
||||||
printf '\n'
|
printf '\n'
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"SPOON_AGENT_TERMINAL_IMAGE",
|
"SPOON_AGENT_TERMINAL_IMAGE",
|
||||||
"SPOON_AGENT_TERMINAL_SECRET",
|
"SPOON_AGENT_TERMINAL_SECRET",
|
||||||
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
||||||
|
"SPOON_AGENT_BOX_IDLE_MS",
|
||||||
|
"SPOON_AGENT_DEV_WATCHDOG",
|
||||||
"SPOON_AGENT_RUNTIME",
|
"SPOON_AGENT_RUNTIME",
|
||||||
"SPOON_AGENT_CONTAINER_RUNTIME",
|
"SPOON_AGENT_CONTAINER_RUNTIME",
|
||||||
"SPOON_AGENT_CONTAINER_VOLUME_OPTIONS",
|
"SPOON_AGENT_CONTAINER_VOLUME_OPTIONS",
|
||||||
|
|||||||
Reference in New Issue
Block a user