Compare commits
22 Commits
40a6dd78e4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b09295570d | |||
| 3f1fee4e44 | |||
| 573246ce98 | |||
| 5fc1e2caf6 | |||
| ca5c623392 | |||
| 8d2a089268 | |||
| c6b27063a4 | |||
| c103430c7d | |||
| c0ff6d8bed | |||
| 2cd03b6a83 | |||
| 4c0de2cbf3 | |||
| 683fc62129 | |||
| 32a71f00ca | |||
| 65aae85369 | |||
| 5f7d56369f | |||
| fd48dcfc28 | |||
| 24a516c2b5 | |||
| 15407e7e9c | |||
| c1263b2e69 | |||
| 1072cf10cd | |||
| ae90681d9b | |||
| bb471a0917 |
+2
-1
@@ -45,7 +45,8 @@ packages/backend/.convex
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
docker
|
||||
docker/*
|
||||
!docker/agent-job-rootfs
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
bunx lint-staged --concurrent 1
|
||||
infisical scan git-changes --staged
|
||||
|
||||
@@ -19,14 +19,18 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"dockerode": "^4.0.7",
|
||||
"execa": "latest",
|
||||
"ws": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/dockerode": "^3.3.42",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -71,7 +71,8 @@ const textFromPart = (part: Record<string, unknown>) => {
|
||||
};
|
||||
|
||||
const commandString = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.map((part) => stringify(part)).join(' ');
|
||||
if (Array.isArray(value))
|
||||
return value.map((part) => stringify(part)).join(' ');
|
||||
return stringify(value);
|
||||
};
|
||||
|
||||
@@ -82,8 +83,7 @@ const toolNameFromRecord = (record: Record<string, unknown> | null) =>
|
||||
record?.toolName ??
|
||||
record?.name ??
|
||||
record?.function ??
|
||||
(stringify(record?.type).toLowerCase().includes('exec') ||
|
||||
record?.command
|
||||
(stringify(record?.type).toLowerCase().includes('exec') || record?.command
|
||||
? 'Command'
|
||||
: record?.type) ??
|
||||
'tool',
|
||||
@@ -132,7 +132,9 @@ const recordLooksLikeTool = (
|
||||
recordType.includes('exec_command') ||
|
||||
recordType.includes('command') ||
|
||||
recordType.includes('mcp') ||
|
||||
Boolean(record?.tool ?? record?.tool_name ?? record?.name ?? record?.command)
|
||||
Boolean(
|
||||
record?.tool ?? record?.tool_name ?? record?.name ?? record?.command,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -153,7 +155,10 @@ const normalizeCodexMsgEvent = (
|
||||
);
|
||||
if (sessionId) events.push({ kind: 'session', sessionId });
|
||||
}
|
||||
if (msgType === 'agent_message_delta' || msgType === 'agent_reasoning_delta') {
|
||||
if (
|
||||
msgType === 'agent_message_delta' ||
|
||||
msgType === 'agent_reasoning_delta'
|
||||
) {
|
||||
const delta = stringify(msg.delta ?? msg.text);
|
||||
if (delta) events.push({ kind: 'assistant_delta', content: delta });
|
||||
}
|
||||
@@ -177,7 +182,11 @@ const normalizeCodexMsgEvent = (
|
||||
output: toolOutputFromRecord(msg),
|
||||
});
|
||||
}
|
||||
if (msgType === 'error' || msgType === 'turn_failed' || msgType === 'task_error') {
|
||||
if (
|
||||
msgType === 'error' ||
|
||||
msgType === 'turn_failed' ||
|
||||
msgType === 'task_error'
|
||||
) {
|
||||
const message = stringify(msg.message ?? msg.error ?? msg);
|
||||
if (isCodexConfigWarning(message)) {
|
||||
events.push({ kind: 'status', status: message });
|
||||
@@ -354,7 +363,8 @@ export const normalizeOpenCodeEvent = (
|
||||
const event = asRecord(input);
|
||||
if (!event) return [];
|
||||
const type = stringify(event.type);
|
||||
const properties = asRecord(event.properties) ?? asRecord(event.data) ?? event;
|
||||
const properties =
|
||||
asRecord(event.properties) ?? asRecord(event.data) ?? event;
|
||||
const events: NormalizedAgentEvent[] = [];
|
||||
const sessionId = properties.sessionID ?? properties.sessionId;
|
||||
if (typeof sessionId === 'string' && type.includes('session')) {
|
||||
@@ -408,7 +418,8 @@ export const normalizeOpenCodeEvent = (
|
||||
}
|
||||
if (type === 'file.edited') {
|
||||
const file = properties.file;
|
||||
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file });
|
||||
if (typeof file === 'string')
|
||||
events.push({ kind: 'file_edited', path: file });
|
||||
}
|
||||
if (type === 'command.executed') {
|
||||
events.push({
|
||||
@@ -422,7 +433,9 @@ export const normalizeOpenCodeEvent = (
|
||||
kind: 'permission_requested',
|
||||
externalRequestId: stringify(properties.permissionID ?? properties.id),
|
||||
title: 'Permission requested',
|
||||
body: stringify(properties.permission ?? properties.message ?? properties),
|
||||
body: stringify(
|
||||
properties.permission ?? properties.message ?? properties,
|
||||
),
|
||||
metadata: stringify(properties),
|
||||
});
|
||||
}
|
||||
@@ -443,7 +456,11 @@ export const normalizeOpenCodeEvent = (
|
||||
});
|
||||
}
|
||||
if (events.length === 0 && type) {
|
||||
events.push({ kind: 'status', status: type, metadata: stringify(properties) });
|
||||
events.push({
|
||||
kind: 'status',
|
||||
status: type,
|
||||
metadata: stringify(properties),
|
||||
});
|
||||
}
|
||||
return events;
|
||||
};
|
||||
|
||||
@@ -25,13 +25,32 @@ export const env = {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
|
||||
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
|
||||
'docker',
|
||||
containerVolumeOptions: process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||
containerVolumeOptions:
|
||||
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||
containerAccess:
|
||||
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||
? 'host_port'
|
||||
: 'network',
|
||||
jobImage:
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||
// Interactive terminal: image for the persistent shell container (defaults to
|
||||
// the job image), the secret shared with the Next app for verifying terminal
|
||||
// tokens, and how long an idle terminal container survives before cleanup.
|
||||
terminalImage:
|
||||
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
|
||||
'spoon-agent-job:latest',
|
||||
terminalSecret:
|
||||
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||
'',
|
||||
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',
|
||||
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||
|
||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
||||
workBranch: string;
|
||||
redact: (value: string) => string;
|
||||
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 });
|
||||
const dirName = args.dirName ?? 'repo';
|
||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||
const clone = await run(
|
||||
'git',
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||
{
|
||||
cwd: args.workdir,
|
||||
redact: args.redact,
|
||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
||||
if (clone.exitCode !== 0) {
|
||||
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], {
|
||||
cwd: repoDir,
|
||||
redact: args.redact,
|
||||
@@ -155,8 +159,7 @@ export const getWorktreeDiff = async (
|
||||
if (diff.output.trim()) untrackedDiffs.push(diff.output);
|
||||
}
|
||||
return {
|
||||
exitCode:
|
||||
trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
|
||||
exitCode: trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
|
||||
output: [trackedDiff.output, ...untrackedDiffs]
|
||||
.filter((part) => part.trim())
|
||||
.join('\n'),
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { env } from './env';
|
||||
import { startWorkerServer } from './server';
|
||||
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();
|
||||
await startWorker();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk';
|
||||
import type { OpencodeClient } from '@opencode-ai/sdk';
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk';
|
||||
|
||||
import type { NormalizedAgentEvent } from './agent-events';
|
||||
import { normalizeOpenCodeEvent } from './agent-events';
|
||||
@@ -115,11 +115,13 @@ export const replyOpenCodePermission = async (args: {
|
||||
response: 'once' | 'always' | 'reject';
|
||||
directory: string;
|
||||
}) => {
|
||||
const result = await args.session.client.postSessionIdPermissionsPermissionId({
|
||||
const result = await args.session.client.postSessionIdPermissionsPermissionId(
|
||||
{
|
||||
path: { id: args.session.sessionId, permissionID: args.permissionId },
|
||||
query: { directory: args.directory },
|
||||
body: { response: args.response },
|
||||
});
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
throw new Error('OpenCode permission response was rejected.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { execa } from 'execa';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
|
||||
@@ -74,18 +76,29 @@ const hostWorkspacePath = (workdir: string) => {
|
||||
return path.join(env.hostWorkdir, relative);
|
||||
};
|
||||
|
||||
export const jobWorkspaceVolumeSpec = (workdir: string) => {
|
||||
export const containerVolumeSuffix = () =>
|
||||
env.containerVolumeOptions ??
|
||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||
|
||||
export { hostWorkspacePath };
|
||||
|
||||
export const jobWorkspaceVolumeSpec = (
|
||||
workdir: string,
|
||||
containerHome = '/workspace',
|
||||
) => {
|
||||
const volumeOptions =
|
||||
env.containerVolumeOptions ??
|
||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||
const source = hostWorkspacePath(workdir);
|
||||
return volumeOptions
|
||||
? `${source}:/workspace:${volumeOptions}`
|
||||
: `${source}:/workspace`;
|
||||
? `${source}:${containerHome}:${volumeOptions}`
|
||||
: `${source}:${containerHome}`;
|
||||
};
|
||||
|
||||
export const runInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
@@ -104,9 +117,9 @@ export const runInJobContainer = async (args: {
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
@@ -122,21 +135,17 @@ export const runInJobContainer = async (args: {
|
||||
|
||||
export const startWorkspaceContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
containerName: string;
|
||||
environment: Record<string, string>;
|
||||
command?: string[];
|
||||
publishTcpPort?: number;
|
||||
}) => {
|
||||
await ensureJobImagePulled();
|
||||
await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'rm',
|
||||
'-f',
|
||||
args.containerName,
|
||||
],
|
||||
{ reject: false },
|
||||
);
|
||||
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
|
||||
reject: false,
|
||||
});
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
@@ -154,9 +163,9 @@ export const startWorkspaceContainer = async (args: {
|
||||
: []),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...(args.command ?? ['sleep', 'infinity']),
|
||||
],
|
||||
@@ -171,7 +180,10 @@ export const startWorkspaceContainer = async (args: {
|
||||
};
|
||||
};
|
||||
|
||||
const getPublishedPort = async (containerName: string, containerPort: number) => {
|
||||
const getPublishedPort = async (
|
||||
containerName: string,
|
||||
containerPort: number,
|
||||
) => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
['port', containerName, `${containerPort}/tcp`],
|
||||
@@ -215,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: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
@@ -237,9 +312,9 @@ export const streamInJobContainer = async (args: {
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
@@ -250,58 +325,114 @@ export const streamInJobContainer = async (args: {
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
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(args.redact(line));
|
||||
}
|
||||
}
|
||||
};
|
||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stdout', args.onStdoutLine),
|
||||
return streamSubprocess(
|
||||
subprocess,
|
||||
args.redact,
|
||||
args.onStdoutLine,
|
||||
args.onStderrLine,
|
||||
);
|
||||
});
|
||||
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;
|
||||
if (stdoutBuffer && args.onStdoutLine) {
|
||||
await args.onStdoutLine(args.redact(stdoutBuffer));
|
||||
}
|
||||
if (stderrBuffer && args.onStderrLine) {
|
||||
await args.onStderrLine(args.redact(stderrBuffer));
|
||||
}
|
||||
return normalizeRunResult(result, output.join(''), args.redact);
|
||||
|
||||
// Per-user persistent "box" container that all of a user's threads exec into
|
||||
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||
export const userContainerName = (username: string) =>
|
||||
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||
|
||||
export const ensureUserContainer = async (args: {
|
||||
username: string;
|
||||
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) => {
|
||||
@@ -311,14 +442,10 @@ export const stopWorkspaceContainer = async (containerName: string) => {
|
||||
};
|
||||
|
||||
export const inspectWorkspaceContainer = async (containerName: string) => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
['inspect', containerName],
|
||||
{
|
||||
const result = await execa(containerRuntime(), ['inspect', containerName], {
|
||||
all: true,
|
||||
reject: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
return {
|
||||
exists: result.exitCode === 0,
|
||||
output: result.all,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
import { env } from './env';
|
||||
import { attachTerminalServer } from './terminal';
|
||||
import {
|
||||
abortWorkspaceAgent,
|
||||
cleanupOrphanedWorkspaces,
|
||||
@@ -127,8 +128,9 @@ export const startWorkerServer = () => {
|
||||
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
|
||||
return;
|
||||
}
|
||||
const interactionMatch =
|
||||
/^interactions\/([^/]+)\/reply$/.exec(route.action);
|
||||
const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
|
||||
route.action,
|
||||
);
|
||||
if (request.method === 'POST' && interactionMatch?.[1]) {
|
||||
const body = await parseJson<{
|
||||
externalRequestId?: string;
|
||||
@@ -182,6 +184,7 @@ export const startWorkerServer = () => {
|
||||
}
|
||||
})();
|
||||
});
|
||||
attachTerminalServer(server);
|
||||
server.listen(env.httpPort, () => {
|
||||
console.log(
|
||||
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// Short-lived, job-scoped token authorizing a browser terminal connection.
|
||||
// Minted server-side by the Next app (which has verified job ownership) and
|
||||
// verified here so the browser never sees the shared worker secret. Format:
|
||||
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
|
||||
const signature = (payload: string, secret: string) =>
|
||||
createHmac('sha256', secret).update(payload).digest('hex');
|
||||
|
||||
export const verifyTerminalToken = (
|
||||
token: string,
|
||||
jobId: string,
|
||||
secret: string,
|
||||
): boolean => {
|
||||
if (!token || !secret) return false;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
const [expRaw, tokenJobId, provided] = parts;
|
||||
if (tokenJobId !== jobId) return false;
|
||||
const exp = Number.parseInt(expRaw ?? '', 10);
|
||||
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
|
||||
const providedBuf = Buffer.from(provided ?? '', 'hex');
|
||||
const expectedBuf = Buffer.from(expected, 'hex');
|
||||
return (
|
||||
providedBuf.length === expectedBuf.length &&
|
||||
timingSafeEqual(providedBuf, expectedBuf)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import type { Server } from 'node:http';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { env } from './env';
|
||||
import { verifyTerminalToken } from './terminal-token';
|
||||
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||
import { getTerminalWorkspace } from './worker';
|
||||
|
||||
const clampDimension = (value: unknown) => {
|
||||
const n = Math.trunc(Number(value));
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return Math.min(Math.max(n, 1), 1000);
|
||||
};
|
||||
|
||||
// Single-quote a string for a POSIX shell.
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
|
||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
const workspace = getTerminalWorkspace(jobId);
|
||||
if (!workspace) {
|
||||
ws.close(1011, 'Workspace is not active.');
|
||||
return;
|
||||
}
|
||||
|
||||
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
|
||||
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
|
||||
// *inside* the container with `script`, bridging the plain pipes to the socket.
|
||||
//
|
||||
// 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
|
||||
// resize and early keystrokes aren't dropped.
|
||||
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
|
||||
const pendingInput: Buffer[] = [];
|
||||
let cols = 80;
|
||||
let rows = 24;
|
||||
|
||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (!isBinary) {
|
||||
// Text frames are control messages (resize); anything else is raw input.
|
||||
try {
|
||||
const message = JSON.parse(data.toString('utf8')) as {
|
||||
type?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
if (message.type === 'resize') {
|
||||
const c = clampDimension(message.cols);
|
||||
const r = clampDimension(message.rows);
|
||||
if (c && r) {
|
||||
cols = c;
|
||||
rows = r;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through: treat as raw input
|
||||
}
|
||||
}
|
||||
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 = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
procHolder.current?.kill();
|
||||
if (acquired) releaseUserBox(workspace.username);
|
||||
};
|
||||
ws.on('close', 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();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
|
||||
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
||||
* token minted by the Next app (which has already verified job ownership).
|
||||
*/
|
||||
export const attachTerminalServer = (server: Server) => {
|
||||
if (env.runtime !== 'docker') return;
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
||||
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
||||
if (!match?.[1]) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const jobId = decodeURIComponent(match[1]);
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
void bridge(ws, jobId);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Spoon agent worker terminal WebSocket endpoint enabled.');
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
+143
-41
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
|
||||
@@ -15,12 +15,9 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import type { NormalizedAgentEvent } from './agent-events';
|
||||
import type { OpenCodeSession } from './opencode-session';
|
||||
import { normalizeCodexJsonLine } from './agent-events';
|
||||
import {
|
||||
codexContainerRepo,
|
||||
codexContainerWorkspace,
|
||||
prepareCodexWorkspaceFiles,
|
||||
} from './codex-runtime';
|
||||
import { prepareCodexWorkspaceFiles } from './codex-runtime';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
@@ -30,7 +27,6 @@ import {
|
||||
run,
|
||||
} from './git';
|
||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||
import type { OpenCodeSession } from './opencode-session';
|
||||
import {
|
||||
abortOpenCodeSession,
|
||||
createOpenCodeSession,
|
||||
@@ -40,11 +36,13 @@ import {
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import {
|
||||
listWorkspaceContainerNames,
|
||||
runInJobContainer,
|
||||
runExecInContainer,
|
||||
startWorkspaceContainer,
|
||||
stopWorkspaceContainer,
|
||||
streamInJobContainer,
|
||||
streamExecInContainer,
|
||||
} from './runtime/docker';
|
||||
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
||||
|
||||
type Claim = {
|
||||
job: {
|
||||
@@ -98,8 +96,17 @@ type Claim = {
|
||||
|
||||
type ActiveWorkspace = {
|
||||
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;
|
||||
homeDir: string;
|
||||
username: string;
|
||||
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
|
||||
containerHome: string;
|
||||
containerRepo: string;
|
||||
repoDir: string;
|
||||
// Phase 2: the per-user box container this thread execs into.
|
||||
boxName: string;
|
||||
githubToken: string;
|
||||
redact: (value: string) => string;
|
||||
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
||||
@@ -432,7 +439,7 @@ const opencodeModel = (claim: Claim) => {
|
||||
|
||||
const codexModel = (claim: Claim) => {
|
||||
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
|
||||
return model.includes('/') ? model.split('/').at(-1) ?? model : model;
|
||||
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
|
||||
};
|
||||
|
||||
const codexModelArgs = (claim: Claim) =>
|
||||
@@ -531,8 +538,7 @@ const handleAgentEvent = async (args: {
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
|
||||
const detail =
|
||||
event.kind === 'tool_started' ? event.input : event.output;
|
||||
const detail = event.kind === 'tool_started' ? event.input : event.output;
|
||||
await appendMessage({
|
||||
jobId,
|
||||
role: 'tool',
|
||||
@@ -621,6 +627,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
||||
);
|
||||
const container = await startWorkspaceContainer({
|
||||
workdir: workspace.workdir,
|
||||
containerHome: workspace.containerHome,
|
||||
containerCwd: workspace.containerRepo,
|
||||
containerName,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
@@ -650,17 +658,20 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
||||
const session = await createOpenCodeSession({
|
||||
baseUrl,
|
||||
password,
|
||||
directory: '/workspace/repo',
|
||||
directory: workspace.containerRepo,
|
||||
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
||||
onEvent: async (event) => {
|
||||
const messageId = workspaceCurrentMessage.get(workspace.claim.job._id);
|
||||
const messageId = workspaceCurrentMessage.get(
|
||||
workspace.claim.job._id,
|
||||
);
|
||||
if (!messageId) return;
|
||||
await handleAgentEvent({
|
||||
workspace,
|
||||
event,
|
||||
assistantMessageId: messageId,
|
||||
assistantContent:
|
||||
workspaceCurrentContent.get(workspace.claim.job._id) ?? {
|
||||
assistantContent: workspaceCurrentContent.get(
|
||||
workspace.claim.job._id,
|
||||
) ?? {
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
@@ -719,7 +730,7 @@ const runCodexTurn = async (args: {
|
||||
outputFileName,
|
||||
);
|
||||
const outputFileContainerPath = path.posix.join(
|
||||
codexContainerWorkspace,
|
||||
workspace.containerHome,
|
||||
'.codex',
|
||||
outputFileName,
|
||||
);
|
||||
@@ -745,15 +756,16 @@ const runCodexTurn = async (args: {
|
||||
'--output-last-message',
|
||||
outputFileContainerPath,
|
||||
'--cd',
|
||||
codexContainerRepo,
|
||||
workspace.containerRepo,
|
||||
prompt,
|
||||
];
|
||||
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
|
||||
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result = await streamInJobContainer({
|
||||
workdir: workspace.workdir,
|
||||
const result = await streamExecInContainer({
|
||||
containerName: workspace.boxName,
|
||||
containerCwd: workspace.containerRepo,
|
||||
command,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
@@ -864,7 +876,7 @@ const runOpenCodeTurn = async (args: {
|
||||
session,
|
||||
prompt,
|
||||
model: opencodeModel(workspace.claim),
|
||||
directory: '/workspace/repo',
|
||||
directory: workspace.containerRepo,
|
||||
});
|
||||
await turnDone;
|
||||
};
|
||||
@@ -996,27 +1008,29 @@ const runProjectCommand = async (args: {
|
||||
command: string;
|
||||
phase: 'install' | 'check' | 'test';
|
||||
claim: Claim;
|
||||
workdir: string;
|
||||
boxName: string;
|
||||
containerHome: string;
|
||||
containerCwd: string;
|
||||
repoDir: string;
|
||||
redact: (value: string) => string;
|
||||
}) => {
|
||||
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 =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir: args.workdir,
|
||||
? await runExecInContainer({
|
||||
containerName: args.boxName,
|
||||
command: commandToShell(args.command),
|
||||
environment: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
containerCwd: args.containerCwd,
|
||||
environment: { HOME: args.containerHome, ...secretEnv },
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run('bash', ['-lc', args.command], {
|
||||
cwd: args.repoDir,
|
||||
env: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
env: secretEnv,
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
@@ -1266,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 jobId = claim.job._id;
|
||||
const workdir = path.resolve(env.workdir, jobId);
|
||||
const secretValues = [
|
||||
claim.openai.apiKey ?? '',
|
||||
claim.aiProviderProfile?.secret ?? '',
|
||||
@@ -1276,6 +1296,7 @@ const runClaim = async (claim: Claim) => {
|
||||
...claim.secrets.map((secret) => secret.value),
|
||||
].filter(Boolean);
|
||||
const redact = createRedactor(secretValues);
|
||||
let acquiredBoxUser: string | undefined;
|
||||
try {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
@@ -1286,8 +1307,36 @@ const runClaim = async (claim: Claim) => {
|
||||
throw new Error('GitHub installation ID is missing.');
|
||||
}
|
||||
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({
|
||||
workdir,
|
||||
workdir: checkoutParent,
|
||||
dirName: branchSlug,
|
||||
token: githubToken,
|
||||
owner: claim.job.forkOwner,
|
||||
repo: claim.job.forkRepo,
|
||||
@@ -1298,11 +1347,31 @@ const runClaim = async (claim: Claim) => {
|
||||
});
|
||||
const workspace: ActiveWorkspace = {
|
||||
claim,
|
||||
workdir,
|
||||
workdir: homeDir,
|
||||
homeDir,
|
||||
username,
|
||||
containerHome,
|
||||
containerRepo,
|
||||
repoDir,
|
||||
boxName,
|
||||
githubToken,
|
||||
redact,
|
||||
};
|
||||
if (userEnv) {
|
||||
await appendEvent(
|
||||
jobId,
|
||||
'info',
|
||||
'clone',
|
||||
'Applying your dotfiles and environment.',
|
||||
);
|
||||
await materializeUserHome({
|
||||
homeDir,
|
||||
containerHome,
|
||||
boxName,
|
||||
userEnv,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
await prepareCodexAuth(workspace);
|
||||
}
|
||||
@@ -1364,6 +1433,7 @@ const runClaim = async (claim: Claim) => {
|
||||
).catch((stopError: unknown) => {
|
||||
console.error(stopError);
|
||||
});
|
||||
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1449,7 +1519,9 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
command,
|
||||
phase: command.includes('test') ? 'test' : 'check',
|
||||
claim: workspace.claim,
|
||||
workdir: workspace.workdir,
|
||||
boxName: workspace.boxName,
|
||||
containerHome: workspace.containerHome,
|
||||
containerCwd: workspace.containerRepo,
|
||||
repoDir: workspace.repoDir,
|
||||
redact: workspace.redact,
|
||||
});
|
||||
@@ -1462,6 +1534,20 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
// Non-throwing accessor for the interactive terminal: returns the active
|
||||
// workspace's mount info, or null when the workspace is not active here.
|
||||
export const getTerminalWorkspace = (jobId: string) => {
|
||||
const workspace = activeWorkspaces.get(jobId);
|
||||
if (!workspace) return null;
|
||||
return {
|
||||
workdir: workspace.workdir,
|
||||
containerHome: workspace.containerHome,
|
||||
containerRepo: workspace.containerRepo,
|
||||
username: workspace.username,
|
||||
secrets: workspace.claim.secrets,
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaceAgentStatus = (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
return {
|
||||
@@ -1480,7 +1566,12 @@ export const abortWorkspaceAgent = async (jobId: string) => {
|
||||
workspace.agentTurnActive = false;
|
||||
workspace.resolveTurn?.();
|
||||
workspace.resolveTurn = undefined;
|
||||
await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.');
|
||||
await appendEvent(
|
||||
workspace.claim.job._id,
|
||||
'warn',
|
||||
'cleanup',
|
||||
'Agent turn aborted.',
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
if (workspace.runtimeMode === 'codex_exec') {
|
||||
@@ -1514,7 +1605,7 @@ export const replyToInteraction = async (
|
||||
session: workspace.opencodeSession,
|
||||
permissionId: args.externalRequestId,
|
||||
response: mapped,
|
||||
directory: '/workspace/repo',
|
||||
directory: workspace.containerRepo,
|
||||
});
|
||||
await patchInteractionRequest({
|
||||
interactionId: args.interactionId,
|
||||
@@ -1594,7 +1685,13 @@ export const sendWorkspaceMessage = async (
|
||||
const secretEnv = Object.fromEntries(
|
||||
claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result = await run('bash', ['-lc', `opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`], {
|
||||
const result = await run(
|
||||
'bash',
|
||||
[
|
||||
'-lc',
|
||||
`opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
|
||||
],
|
||||
{
|
||||
cwd: workspace.repoDir,
|
||||
env: {
|
||||
...aiEnv,
|
||||
@@ -1602,7 +1699,8 @@ export const sendWorkspaceMessage = async (
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
},
|
||||
);
|
||||
await updateMessage({
|
||||
messageId: assistantMessageId,
|
||||
status: result.exitCode === 0 ? 'completed' : 'failed',
|
||||
@@ -1756,7 +1854,9 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
||||
await stopWorkspaceContainer(workspace.containerName);
|
||||
}
|
||||
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 {
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
@@ -1771,7 +1871,9 @@ export const stopWorkspace = async (jobId: string) => {
|
||||
await stopWorkspaceContainer(workspace.containerName);
|
||||
}
|
||||
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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -271,7 +271,8 @@ describe('agent event normalization', () => {
|
||||
externalRequestId: 'perm-1',
|
||||
title: 'Permission requested',
|
||||
body: 'Run bun test?',
|
||||
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
|
||||
metadata:
|
||||
'{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
@@ -3,7 +3,6 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
type TestWorkspace = {
|
||||
@@ -53,7 +52,9 @@ const writeConfig = async (
|
||||
config: Record<string, unknown> | string,
|
||||
) => {
|
||||
const content =
|
||||
typeof config === 'string' ? config : `${JSON.stringify(config, null, 2)}\n`;
|
||||
typeof config === 'string'
|
||||
? config
|
||||
: `${JSON.stringify(config, null, 2)}\n`;
|
||||
await writeFile(configPath(workspace), content);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { verifyTerminalToken } from '../../src/terminal-token';
|
||||
|
||||
const mint = (jobId: string, expiresAt: number, secret: string) => {
|
||||
const payload = `${expiresAt}.${jobId}`;
|
||||
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
return `${payload}.${sig}`;
|
||||
};
|
||||
|
||||
describe('verifyTerminalToken', () => {
|
||||
const secret = 'test-secret';
|
||||
|
||||
test('accepts a valid, unexpired, job-matched token', () => {
|
||||
const token = mint('job1', Date.now() + 60_000, secret);
|
||||
expect(verifyTerminalToken(token, 'job1', secret)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects an expired token', () => {
|
||||
const token = mint('job1', Date.now() - 1, secret);
|
||||
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a token minted for another job', () => {
|
||||
const token = mint('job1', Date.now() + 60_000, secret);
|
||||
expect(verifyTerminalToken(token, 'job2', secret)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a token signed with a different secret', () => {
|
||||
const token = mint('job1', Date.now() + 60_000, 'other-secret');
|
||||
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects malformed input and an empty secret', () => {
|
||||
expect(verifyTerminalToken('garbage', 'job1', secret)).toBe(false);
|
||||
expect(verifyTerminalToken('', 'job1', secret)).toBe(false);
|
||||
expect(
|
||||
verifyTerminalToken(mint('job1', Date.now() + 1000, ''), 'job1', ''),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
||||
"$schema": "https://v2-10-0.turborepo.dev/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
|
||||
@@ -21,16 +21,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@git-diff-view/react": "^0.1.6",
|
||||
"@monaco-editor/react": "latest",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"@spoon/ui": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"convex": "catalog:convex",
|
||||
"monaco-editor": "latest",
|
||||
"monaco-vim": "latest",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
|
||||
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 Link from 'next/link';
|
||||
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';
|
||||
|
||||
@@ -11,6 +11,7 @@ const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ 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/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { mintTerminalToken, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(context, (jobId) => {
|
||||
const minted = mintTerminalToken(jobId);
|
||||
return Promise.resolve(
|
||||
minted
|
||||
? NextResponse.json(minted)
|
||||
: NextResponse.json(
|
||||
{ error: 'Terminal is not configured on this deployment.' },
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { Geist, Geist_Mono, Victor_Mono } from 'next/font/google';
|
||||
import { env } from '@/env';
|
||||
|
||||
import '@/app/styles.css';
|
||||
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
// Used by the workspace code editor (and, later, the terminal). Includes the
|
||||
// italic cursive style for comments via Monaco's italic token styling.
|
||||
const victorMono = Victor_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-victor-mono',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const RootLayout = ({
|
||||
children,
|
||||
@@ -44,7 +51,7 @@ const RootLayout = ({
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
@import 'tw-animate-css';
|
||||
@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}';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -14,7 +14,8 @@ import { toast } from 'sonner';
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Button, Textarea } from '@spoon/ui';
|
||||
|
||||
import { extractFileDiff } from './diff-utils';
|
||||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||
import { parseDiffFileForPath } from './diff-utils';
|
||||
|
||||
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||
|
||||
@@ -67,6 +68,7 @@ export const AgentThread = ({
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replying, setReplying] = useState<string>();
|
||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||
const diffTheme = useDiffTheme();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const chatMessages = useMemo(
|
||||
() =>
|
||||
@@ -317,7 +319,13 @@ export const AgentThread = ({
|
||||
</pre>
|
||||
</article>
|
||||
))}
|
||||
{visibleChanges.map((change) => (
|
||||
{visibleChanges.map((change) => {
|
||||
const changedFile = parseDiffFileForPath(change.diff, change.path);
|
||||
const hasDiff = Boolean(changedFile && !changedFile.isBinary);
|
||||
const hasRenderableHunk = Boolean(
|
||||
changedFile && hasDiff && changedFile.hunkText.includes('@@'),
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={change._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
@@ -332,10 +340,20 @@ export const AgentThread = ({
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs capitalize'>
|
||||
{change.source} {change.changeType}
|
||||
{changedFile ? (
|
||||
<span className='ml-2 font-mono normal-case'>
|
||||
<span className='text-emerald-500'>
|
||||
+{changedFile.additions}
|
||||
</span>{' '}
|
||||
<span className='text-red-500'>
|
||||
−{changedFile.deletions}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-none items-center gap-2'>
|
||||
{extractFileDiff(change.diff, change.path) ? (
|
||||
{hasDiff ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -357,18 +375,24 @@ export const AgentThread = ({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{extractFileDiff(change.diff, change.path) ? (
|
||||
{hasRenderableHunk && changedFile ? (
|
||||
<details className='mt-3'>
|
||||
<summary className='text-muted-foreground cursor-pointer text-xs'>
|
||||
File diff
|
||||
</summary>
|
||||
<pre className='bg-muted mt-2 max-h-72 overflow-auto rounded p-2 text-xs whitespace-pre-wrap'>
|
||||
{extractFileDiff(change.diff, change.path)}
|
||||
</pre>
|
||||
<div className='border-border mt-2 max-h-72 overflow-auto rounded border'>
|
||||
<DiffFileView
|
||||
file={changedFile}
|
||||
mode='unified'
|
||||
theme={diffTheme}
|
||||
fontSize={11}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{visibleEvents.slice(-80).map((event) => (
|
||||
<article
|
||||
key={event._id}
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { FileCode, GitCompare, MessagesSquare } from 'lucide-react';
|
||||
import {
|
||||
FileCode,
|
||||
GitCompare,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
SquareTerminal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -34,6 +40,9 @@ import { FileTabs } from './file-tabs';
|
||||
import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
import { WorkspaceActions } from './workspace-actions';
|
||||
import { WorkspaceTerminal } from './workspace-terminal';
|
||||
|
||||
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
||||
|
||||
type OpenFileState = {
|
||||
path: string;
|
||||
@@ -81,9 +90,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
|
||||
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
||||
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
|
||||
'editor' | 'diff' | 'thread'
|
||||
>('editor');
|
||||
const [activeWorkspaceTab, setActiveWorkspaceTab] =
|
||||
useState<WorkspaceTab>('editor');
|
||||
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
||||
|
||||
@@ -94,6 +102,25 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
// The worker only exposes the live runtime (tree/diff/file endpoints) once it
|
||||
// has claimed the job and materialized the workspace (workspaceStatus
|
||||
// 'active'/'idle'). Before that, hitting those endpoints returns "workspace is
|
||||
// not active" — which is expected startup, not an error.
|
||||
const workspaceReady = ['active', 'idle'].includes(
|
||||
job?.workspaceStatus ?? '',
|
||||
);
|
||||
const workspaceFailed =
|
||||
['failed', 'cancelled', 'timed_out'].includes(job?.status ?? '') ||
|
||||
['stopped', 'expired', 'failed'].includes(job?.workspaceStatus ?? '');
|
||||
// Waiting for a worker to pick up the job and start the runtime.
|
||||
const workspacePending =
|
||||
Boolean(job) &&
|
||||
!workspaceReady &&
|
||||
!workspaceFailed &&
|
||||
['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
|
||||
job?.status ?? '',
|
||||
);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
@@ -181,31 +208,61 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
if (!workspaceReady) return;
|
||||
const handleError = (error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(error instanceof Error ? error.message : String(error));
|
||||
};
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadTree().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
void loadDiff().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
void loadTree().catch(handleError);
|
||||
void loadDiff().catch(handleError);
|
||||
void loadAgentStatus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [job, loadAgentStatus, loadDiff, loadTree]);
|
||||
}, [workspaceReady, loadAgentStatus, loadDiff, loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceReady) return;
|
||||
const interval = window.setInterval(() => {
|
||||
void loadAgentStatus();
|
||||
}, 5_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [loadAgentStatus]);
|
||||
}, [workspaceReady, loadAgentStatus]);
|
||||
|
||||
// Surface a gentle "taking longer than usual" hint if a worker never picks the
|
||||
// job up (e.g. the worker is offline) instead of spinning forever.
|
||||
const [pendingTooLong, setPendingTooLong] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!workspacePending) return;
|
||||
const timer = window.setTimeout(() => setPendingTooLong(true), 90_000);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
setPendingTooLong(false);
|
||||
};
|
||||
}, [workspacePending]);
|
||||
|
||||
// Refresh the tree and diff whenever the agent records a workspace change
|
||||
// (file edit / tool call that touched files) or a turn starts/ends, so the
|
||||
// diff viewer stays current without a manual refresh. Rapid bursts of changes
|
||||
// debounce into a single reload via the timeout cleanup.
|
||||
const workspaceChangeSignature = workspaceChanges.reduce(
|
||||
(latest, change) => Math.max(latest, change._creationTime),
|
||||
0,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!workspaceReady) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadDiff().catch(() => undefined);
|
||||
void loadTree().catch(() => undefined);
|
||||
}, 200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [
|
||||
workspaceChangeSignature,
|
||||
agentTurnActive,
|
||||
workspaceReady,
|
||||
loadDiff,
|
||||
loadTree,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiState || hydratedUiState) return;
|
||||
@@ -418,6 +475,31 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
return (
|
||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
||||
<JobStatusBar job={job} />
|
||||
{workspacePending && !workspaceError ? (
|
||||
<div className='border-border bg-background border-b p-4'>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-md border p-4 ${
|
||||
pendingTooLong
|
||||
? 'border-amber-500/40 bg-amber-500/5'
|
||||
: 'border-border bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<Loader2 className='text-muted-foreground size-5 flex-none animate-spin' />
|
||||
<div>
|
||||
<p className='font-medium'>
|
||||
{pendingTooLong
|
||||
? 'Still waiting for a worker…'
|
||||
: 'Setting up your workspace…'}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{pendingTooLong
|
||||
? 'This is taking longer than usual — the worker may be busy or offline. It will start automatically once a worker is available.'
|
||||
: 'Waiting for a worker to pick up this job. Files and diffs will appear automatically once the agent starts.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{workspaceError ? (
|
||||
<div className='border-border bg-background border-b p-4'>
|
||||
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
|
||||
@@ -501,7 +583,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<Tabs
|
||||
value={activeWorkspaceTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread')
|
||||
setActiveWorkspaceTab(value as WorkspaceTab)
|
||||
}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
@@ -520,6 +602,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<GitCompare className='size-4' />
|
||||
Diff viewer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='terminal'
|
||||
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||
>
|
||||
<SquareTerminal className='size-4' />
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='thread'
|
||||
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
|
||||
@@ -568,6 +657,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value='terminal'
|
||||
className='m-0 min-h-0 flex-1 overflow-hidden'
|
||||
>
|
||||
<WorkspaceTerminal
|
||||
jobId={jobId}
|
||||
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||
<DiffViewer
|
||||
diff={diff}
|
||||
|
||||
@@ -2,15 +2,26 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button, Switch } from '@spoon/ui';
|
||||
|
||||
import type { MonacoLike } from './monaco-theme';
|
||||
import { languageForPath } from './languages';
|
||||
import {
|
||||
configureSpoonMonaco,
|
||||
remeasureFontsWhenReady,
|
||||
SPOON_DARK,
|
||||
SPOON_LIGHT,
|
||||
} from './monaco-theme';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const EDITOR_FONT_FAMILY =
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
};
|
||||
@@ -42,6 +53,8 @@ export const CodeEditor = ({
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
@@ -115,14 +128,23 @@ export const CodeEditor = ({
|
||||
path={path}
|
||||
language={languageForPath(path)}
|
||||
value={content}
|
||||
theme='vs-dark'
|
||||
theme={editorTheme}
|
||||
beforeMount={(monaco) => {
|
||||
configureSpoonMonaco(monaco as unknown as MonacoLike);
|
||||
}}
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
fontLigatures: true,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
smoothScrolling: true,
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
padding: { top: 12, bottom: 12 },
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
@@ -131,8 +153,9 @@ export const CodeEditor = ({
|
||||
bracketPairColorization: { enabled: true },
|
||||
renderWhitespace: 'selection',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor as MonacoEditorInstance;
|
||||
remeasureFontsWhenReady(monaco as unknown as MonacoLike);
|
||||
}}
|
||||
onChange={(next) => {
|
||||
const nextValue = next ?? '';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import '@git-diff-view/react/styles/diff-view.css';
|
||||
|
||||
import type { ParsedDiffFile } from './diff-utils';
|
||||
|
||||
export type DiffMode = 'unified' | 'split';
|
||||
|
||||
/** Resolves the git-diff-view theme from next-themes, defaulting to dark. */
|
||||
export const useDiffTheme = (): 'light' | 'dark' => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return resolvedTheme === 'light' ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders one file's diff with syntax highlighting. Falls back to a short note
|
||||
* for binary files and metadata-only changes (pure renames / mode changes) that
|
||||
* have no hunks to display.
|
||||
*/
|
||||
export const DiffFileView = ({
|
||||
file,
|
||||
mode,
|
||||
theme,
|
||||
fontSize = 12,
|
||||
wrap = false,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
mode: DiffMode;
|
||||
theme: 'light' | 'dark';
|
||||
fontSize?: number;
|
||||
wrap?: boolean;
|
||||
}) => {
|
||||
if (file.isBinary) {
|
||||
return (
|
||||
<div className='text-muted-foreground px-3 py-2 text-xs'>
|
||||
Binary file not shown.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!file.hunkText.includes('@@')) {
|
||||
return (
|
||||
<div className='text-muted-foreground px-3 py-2 text-xs'>
|
||||
{file.status === 'renamed'
|
||||
? 'Renamed with no content changes.'
|
||||
: 'No content changes.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiffView
|
||||
data={{
|
||||
oldFile: { fileName: file.oldPath || file.displayPath },
|
||||
newFile: { fileName: file.newPath || file.displayPath },
|
||||
hunks: [file.hunkText],
|
||||
}}
|
||||
diffViewMode={
|
||||
mode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified
|
||||
}
|
||||
diffViewTheme={theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={wrap}
|
||||
diffViewFontSize={fontSize}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,112 @@
|
||||
export type DiffFileStatus = 'added' | 'deleted' | 'modified' | 'renamed';
|
||||
|
||||
export type ParsedDiffFile = {
|
||||
id: string;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
/** Path to show in the UI (new path, or old path for deletions). */
|
||||
displayPath: string;
|
||||
status: DiffFileStatus;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
isBinary: boolean;
|
||||
/** The full per-file unified diff section, fed as-is to the diff renderer. */
|
||||
hunkText: string;
|
||||
};
|
||||
|
||||
const stripABPrefix = (value: string) => value.replace(/^[ab]\//, '');
|
||||
|
||||
/**
|
||||
* Splits a raw unified git diff into structured, per-file entries. Replaces the
|
||||
* "one giant blob" rendering: each file can be shown, counted, and highlighted
|
||||
* independently.
|
||||
*/
|
||||
export const parseDiffFiles = (diff: string | undefined): ParsedDiffFile[] => {
|
||||
if (!diff?.trim()) return [];
|
||||
const sections: string[][] = [];
|
||||
let current: string[] | null = null;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
if (current) sections.push(current);
|
||||
current = [line];
|
||||
continue;
|
||||
}
|
||||
current?.push(line);
|
||||
}
|
||||
if (current) sections.push(current);
|
||||
|
||||
return sections.map((sectionLines, index) => {
|
||||
const header = sectionLines[0] ?? '';
|
||||
const gitMatch = /^diff --git a\/(.+?) b\/(.+)$/.exec(header);
|
||||
let oldPath = gitMatch?.[1] ?? '';
|
||||
let newPath = gitMatch?.[2] ?? oldPath;
|
||||
let status: DiffFileStatus = 'modified';
|
||||
let isBinary = false;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let renameFrom = '';
|
||||
let renameTo = '';
|
||||
|
||||
for (const line of sectionLines) {
|
||||
if (line.startsWith('new file mode')) status = 'added';
|
||||
else if (line.startsWith('deleted file mode')) status = 'deleted';
|
||||
else if (line.startsWith('rename from ')) {
|
||||
renameFrom = line.slice('rename from '.length);
|
||||
} else if (line.startsWith('rename to ')) {
|
||||
renameTo = line.slice('rename to '.length);
|
||||
} else if (
|
||||
line.startsWith('Binary files') ||
|
||||
line.startsWith('GIT binary patch')
|
||||
) {
|
||||
isBinary = true;
|
||||
} else if (line.startsWith('--- ')) {
|
||||
const value = line.slice(4).trim();
|
||||
if (value !== '/dev/null') oldPath = stripABPrefix(value);
|
||||
} else if (line.startsWith('+++ ')) {
|
||||
const value = line.slice(4).trim();
|
||||
if (value !== '/dev/null') newPath = stripABPrefix(value);
|
||||
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
additions += 1;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
deletions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (renameFrom || renameTo) {
|
||||
status = 'renamed';
|
||||
oldPath = renameFrom || oldPath;
|
||||
newPath = renameTo || newPath;
|
||||
}
|
||||
|
||||
const displayPath = status === 'deleted' ? oldPath : newPath;
|
||||
return {
|
||||
id: `${index}-${displayPath}`,
|
||||
oldPath,
|
||||
newPath,
|
||||
displayPath,
|
||||
status,
|
||||
additions,
|
||||
deletions,
|
||||
isBinary,
|
||||
hunkText: sectionLines.join('\n'),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** Returns the single parsed file matching a path, if present in the diff. */
|
||||
export const parseDiffFileForPath = (
|
||||
diff: string | undefined,
|
||||
filePath: string,
|
||||
): ParsedDiffFile | undefined => {
|
||||
const normalized = filePath.replace(/^\.\/+/, '');
|
||||
return parseDiffFiles(diff).find(
|
||||
(file) =>
|
||||
file.displayPath === normalized ||
|
||||
file.newPath === normalized ||
|
||||
file.oldPath === normalized,
|
||||
);
|
||||
};
|
||||
|
||||
export const extractFileDiff = (diff: string | undefined, filePath: string) => {
|
||||
if (!diff?.trim() || filePath === '.') return '';
|
||||
const lines = diff.split('\n');
|
||||
|
||||
@@ -1,25 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { extractFileDiff } from './diff-utils';
|
||||
import type { DiffMode } from './diff-file-view';
|
||||
import type { DiffFileStatus, ParsedDiffFile } from './diff-utils';
|
||||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||
import { parseDiffFiles } from './diff-utils';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
const statusBadge: Record<
|
||||
DiffFileStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
added: { label: 'Added', className: 'bg-emerald-500/15 text-emerald-500' },
|
||||
deleted: { label: 'Deleted', className: 'bg-red-500/15 text-red-500' },
|
||||
modified: { label: 'Modified', className: 'bg-amber-500/15 text-amber-500' },
|
||||
renamed: { label: 'Renamed', className: 'bg-sky-500/15 text-sky-500' },
|
||||
};
|
||||
|
||||
const diffStats = (diff: string) => {
|
||||
const files = new Set<string>();
|
||||
let additions = 0;
|
||||
let removals = 0;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) files.add(line);
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
|
||||
}
|
||||
return { files: files.size, additions, removals };
|
||||
const totals = (files: ParsedDiffFile[]) =>
|
||||
files.reduce(
|
||||
(acc, file) => ({
|
||||
additions: acc.additions + file.additions,
|
||||
deletions: acc.deletions + file.deletions,
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
|
||||
const FileCard = ({
|
||||
file,
|
||||
mode,
|
||||
theme,
|
||||
defaultOpen,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
mode: DiffMode;
|
||||
theme: 'light' | 'dark';
|
||||
defaultOpen: boolean;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const badge = statusBadge[file.status];
|
||||
return (
|
||||
<div className='border-border overflow-hidden rounded-md border'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className='bg-muted/40 hover:bg-muted/70 flex w-full items-center gap-2 px-3 py-2 text-left transition-colors'
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className='text-muted-foreground size-4 flex-none' />
|
||||
) : (
|
||||
<ChevronRight className='text-muted-foreground size-4 flex-none' />
|
||||
)}
|
||||
<span className='min-w-0 flex-1 truncate font-mono text-xs'>
|
||||
{file.status === 'renamed' && file.oldPath !== file.newPath ? (
|
||||
<>
|
||||
<span className='text-muted-foreground'>{file.oldPath} → </span>
|
||||
{file.newPath}
|
||||
</>
|
||||
) : (
|
||||
file.displayPath
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-none rounded px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className='flex-none font-mono text-xs'>
|
||||
{file.additions > 0 ? (
|
||||
<span className='text-emerald-500'>+{file.additions}</span>
|
||||
) : null}{' '}
|
||||
{file.deletions > 0 ? (
|
||||
<span className='text-red-500'>−{file.deletions}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className='overflow-x-auto'>
|
||||
<DiffFileView file={file} mode={mode} theme={theme} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiffViewer = ({
|
||||
@@ -33,25 +98,65 @@ export const DiffViewer = ({
|
||||
onRefresh: () => Promise<void>;
|
||||
onClearFocusedPath?: () => void;
|
||||
}) => {
|
||||
const focusedDiff = focusedPath ? extractFileDiff(diff, focusedPath) : '';
|
||||
const visibleDiff = focusedPath ? focusedDiff : diff;
|
||||
const stats = diffStats(visibleDiff);
|
||||
const [mode, setMode] = useState<DiffMode>('unified');
|
||||
const theme = useDiffTheme();
|
||||
|
||||
const files = useMemo(() => parseDiffFiles(diff), [diff]);
|
||||
const normalizedFocus = focusedPath?.replace(/^\.\/+/, '');
|
||||
const visibleFiles = useMemo(
|
||||
() =>
|
||||
normalizedFocus
|
||||
? files.filter(
|
||||
(file) =>
|
||||
file.displayPath === normalizedFocus ||
|
||||
file.newPath === normalizedFocus ||
|
||||
file.oldPath === normalizedFocus,
|
||||
)
|
||||
: files,
|
||||
[files, normalizedFocus],
|
||||
);
|
||||
const stats = totals(visibleFiles);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>
|
||||
{focusedPath ? `Diff viewer: ${focusedPath}` : 'Diff viewer'}
|
||||
{focusedPath ? `Diff: ${focusedPath}` : 'Diff viewer'}
|
||||
</p>
|
||||
<p className='text-muted-foreground truncate text-xs'>
|
||||
{visibleDiff.trim()
|
||||
? `${stats.files} files, +${stats.additions} -${stats.removals}`
|
||||
: focusedPath
|
||||
? 'No diff for this file'
|
||||
: 'Current git diff'}
|
||||
{visibleFiles.length > 0
|
||||
? `${visibleFiles.length} ${visibleFiles.length === 1 ? 'file' : 'files'}, `
|
||||
: ''}
|
||||
<span className='text-emerald-500'>+{stats.additions}</span>{' '}
|
||||
<span className='text-red-500'>−{stats.deletions}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-none items-center gap-2'>
|
||||
<div className='border-border flex items-center rounded-md border p-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setMode('unified')}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
mode === 'unified'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setMode('split')}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
mode === 'split'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
{focusedPath ? (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -67,22 +172,18 @@ export const DiffViewer = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{visibleDiff.trim() ? (
|
||||
<MonacoEditor
|
||||
height='100%'
|
||||
width='100%'
|
||||
language='diff'
|
||||
theme='vs-dark'
|
||||
value={visibleDiff}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
{visibleFiles.length > 0 ? (
|
||||
<div className='flex flex-1 flex-col gap-3 overflow-y-auto p-3'>
|
||||
{visibleFiles.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
mode={mode}
|
||||
theme={theme}
|
||||
defaultOpen={visibleFiles.length <= 10 || index < 5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||
{focusedPath
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
export const SPOON_DARK = 'spoon-dark';
|
||||
export const SPOON_LIGHT = 'spoon-light';
|
||||
|
||||
type ThemeRule = { token: string; foreground?: string; fontStyle?: string };
|
||||
type ThemeData = {
|
||||
base: 'vs' | 'vs-dark';
|
||||
inherit: boolean;
|
||||
rules: ThemeRule[];
|
||||
colors: Record<string, string>;
|
||||
};
|
||||
type DiagnosticsDefaults = {
|
||||
setDiagnosticsOptions: (options: {
|
||||
noSemanticValidation?: boolean;
|
||||
noSyntaxValidation?: boolean;
|
||||
noSuggestionDiagnostics?: boolean;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// Minimal typed surface of the bits of the Monaco namespace we touch. Avoids
|
||||
// depending on monaco-editor's full (and, under our eslint program, unresolved)
|
||||
// type graph while keeping these calls fully type-checked.
|
||||
export type MonacoLike = {
|
||||
editor: {
|
||||
defineTheme: (name: string, data: ThemeData) => void;
|
||||
remeasureFonts: () => void;
|
||||
};
|
||||
languages: {
|
||||
typescript: {
|
||||
typescriptDefaults: DiagnosticsDefaults;
|
||||
javascriptDefaults: DiagnosticsDefaults;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Hex equivalents of the site's oklch design tokens (tools/tailwind/theme.css),
|
||||
// so the editor matches the rest of the app. Brand accent is the teal --primary.
|
||||
const dark = {
|
||||
bg: '#080e14', // --background
|
||||
surface: '#10171e', // --card
|
||||
surfaceAlt: '#192028', // --muted
|
||||
border: '#29313a', // --border
|
||||
fg: '#eef3f5', // --foreground
|
||||
fgDim: '#cdd6dc',
|
||||
muted: '#93a1a9', // --muted-foreground
|
||||
comment: '#6b7d88',
|
||||
teal: '#1fb895', // --primary
|
||||
mint: '#8fd6b4',
|
||||
cyan: '#5fd0e0',
|
||||
blue: '#6aa6ff',
|
||||
amber: '#e3b341',
|
||||
red: '#f3625d', // --destructive
|
||||
};
|
||||
|
||||
const light = {
|
||||
bg: '#f7fbfa', // --background
|
||||
surface: '#ffffff', // --card
|
||||
surfaceAlt: '#eaeff3', // --muted
|
||||
border: '#d4dce2', // --border
|
||||
fg: '#0d1218', // --foreground
|
||||
fgDim: '#26323c',
|
||||
muted: '#555f68', // --muted-foreground
|
||||
comment: '#6b7680',
|
||||
teal: '#007560', // --primary
|
||||
mint: '#2f8f6e',
|
||||
cyan: '#0f7d92',
|
||||
blue: '#2f6bd8',
|
||||
amber: '#9a6b00',
|
||||
red: '#d73337', // --destructive
|
||||
};
|
||||
|
||||
const hex = (value: string) => value.slice(1);
|
||||
|
||||
const themeData = (p: typeof dark, base: 'vs' | 'vs-dark'): ThemeData => ({
|
||||
base,
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: '', foreground: hex(p.fg) },
|
||||
{ token: 'comment', foreground: hex(p.comment), fontStyle: 'italic' },
|
||||
{ token: 'keyword', foreground: hex(p.teal) },
|
||||
{ token: 'keyword.control', foreground: hex(p.teal) },
|
||||
{ token: 'storage', foreground: hex(p.teal) },
|
||||
{ token: 'string', foreground: hex(p.mint) },
|
||||
{ token: 'string.key.json', foreground: hex(p.cyan) },
|
||||
{ token: 'string.value.json', foreground: hex(p.mint) },
|
||||
{ token: 'number', foreground: hex(p.amber) },
|
||||
{ token: 'constant', foreground: hex(p.amber) },
|
||||
{ token: 'regexp', foreground: hex(p.amber) },
|
||||
{ token: 'type', foreground: hex(p.cyan) },
|
||||
{ token: 'type.identifier', foreground: hex(p.cyan) },
|
||||
{ token: 'interface', foreground: hex(p.cyan) },
|
||||
{ token: 'namespace', foreground: hex(p.cyan) },
|
||||
{ token: 'function', foreground: hex(p.blue) },
|
||||
{ token: 'variable', foreground: hex(p.fgDim) },
|
||||
{ token: 'variable.parameter', foreground: hex(p.fgDim) },
|
||||
{ token: 'property', foreground: hex(p.fgDim) },
|
||||
{ token: 'operator', foreground: hex(p.muted) },
|
||||
{ token: 'delimiter', foreground: hex(p.muted) },
|
||||
{ token: 'tag', foreground: hex(p.teal) },
|
||||
{ token: 'attribute.name', foreground: hex(p.amber) },
|
||||
{ token: 'attribute.value', foreground: hex(p.mint) },
|
||||
{ token: 'metatag', foreground: hex(p.teal) },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': p.bg,
|
||||
'editor.foreground': p.fg,
|
||||
'editorCursor.foreground': p.teal,
|
||||
'editorLineNumber.foreground': p.border,
|
||||
'editorLineNumber.activeForeground': p.muted,
|
||||
'editor.lineHighlightBackground': p.surface,
|
||||
'editor.selectionBackground': `${p.teal}33`,
|
||||
'editor.inactiveSelectionBackground': `${p.teal}22`,
|
||||
'editor.findMatchBackground': `${p.teal}55`,
|
||||
'editor.findMatchHighlightBackground': `${p.teal}33`,
|
||||
'editorWhitespace.foreground': p.border,
|
||||
'editorIndentGuide.background1': p.surfaceAlt,
|
||||
'editorIndentGuide.activeBackground1': p.border,
|
||||
'editorGutter.background': p.bg,
|
||||
'editorWidget.background': p.surface,
|
||||
'editorWidget.border': p.border,
|
||||
'editorHoverWidget.background': p.surface,
|
||||
'editorHoverWidget.border': p.border,
|
||||
'editorSuggestWidget.background': p.surface,
|
||||
'editorSuggestWidget.border': p.border,
|
||||
'editorSuggestWidget.selectedBackground': p.surfaceAlt,
|
||||
'editorBracketMatch.background': `${p.teal}22`,
|
||||
'editorBracketMatch.border': p.teal,
|
||||
'editorError.foreground': p.red,
|
||||
'scrollbarSlider.background': `${p.border}aa`,
|
||||
'scrollbarSlider.hoverBackground': p.border,
|
||||
'scrollbarSlider.activeBackground': p.muted,
|
||||
},
|
||||
});
|
||||
|
||||
let configured = false;
|
||||
|
||||
/**
|
||||
* Defines the site-matched editor themes and quiets the in-browser TypeScript
|
||||
* service. Monaco's TS worker has no access to the project's node_modules or the
|
||||
* `~`/`@` path aliases, so its semantic diagnostics (e.g. "Cannot find module
|
||||
* '~/server/auth'") are always false positives here. We keep real syntax errors
|
||||
* and disable the unresolvable semantic noise. Runs once per page load.
|
||||
*/
|
||||
export const configureSpoonMonaco = (monaco: MonacoLike) => {
|
||||
monaco.editor.defineTheme(SPOON_DARK, themeData(dark, 'vs-dark'));
|
||||
monaco.editor.defineTheme(SPOON_LIGHT, themeData(light, 'vs'));
|
||||
if (configured) return;
|
||||
configured = true;
|
||||
for (const defaults of [
|
||||
monaco.languages.typescript.typescriptDefaults,
|
||||
monaco.languages.typescript.javascriptDefaults,
|
||||
]) {
|
||||
defaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: true,
|
||||
noSuggestionDiagnostics: true,
|
||||
noSyntaxValidation: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Re-measures glyph widths once the web font finishes loading so they align. */
|
||||
export const remeasureFontsWhenReady = (monaco: MonacoLike) => {
|
||||
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const TERMINAL_FONT =
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||
|
||||
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
||||
|
||||
const darkTheme: ITheme = {
|
||||
background: '#080e14',
|
||||
foreground: '#eef3f5',
|
||||
cursor: '#1fb895',
|
||||
cursorAccent: '#080e14',
|
||||
selectionBackground: '#1fb89544',
|
||||
black: '#10171e',
|
||||
red: '#f3625d',
|
||||
green: '#8fd6b4',
|
||||
yellow: '#e3b341',
|
||||
blue: '#6aa6ff',
|
||||
magenta: '#b692e8',
|
||||
cyan: '#5fd0e0',
|
||||
white: '#cdd6dc',
|
||||
brightBlack: '#93a1a9',
|
||||
brightRed: '#f3625d',
|
||||
brightGreen: '#8fd6b4',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#6aa6ff',
|
||||
brightMagenta: '#b692e8',
|
||||
brightCyan: '#5fd0e0',
|
||||
brightWhite: '#eef3f5',
|
||||
};
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
background: '#f7fbfa',
|
||||
foreground: '#0d1218',
|
||||
cursor: '#007560',
|
||||
cursorAccent: '#f7fbfa',
|
||||
selectionBackground: '#00756033',
|
||||
black: '#0d1218',
|
||||
red: '#d73337',
|
||||
green: '#2f8f6e',
|
||||
yellow: '#9a6b00',
|
||||
blue: '#2f6bd8',
|
||||
magenta: '#7c4dd1',
|
||||
cyan: '#0f7d92',
|
||||
white: '#26323c',
|
||||
brightBlack: '#555f68',
|
||||
brightRed: '#d73337',
|
||||
brightGreen: '#2f8f6e',
|
||||
brightYellow: '#9a6b00',
|
||||
brightBlue: '#2f6bd8',
|
||||
brightMagenta: '#7c4dd1',
|
||||
brightCyan: '#0f7d92',
|
||||
brightWhite: '#0d1218',
|
||||
};
|
||||
|
||||
export const WorkspaceTerminal = ({
|
||||
jobId,
|
||||
active,
|
||||
}: {
|
||||
jobId: string;
|
||||
active: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const themeIsLight = resolvedTheme === 'light';
|
||||
const [status, setStatus] = useState<Status>('connecting');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [reconnectKey, setReconnectKey] = useState(0);
|
||||
|
||||
// Update the live terminal's theme without tearing down the session.
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
|
||||
}
|
||||
}, [themeIsLight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
// Read through a function so TS doesn't narrow `aborted` to a constant after
|
||||
// the first guard (it changes asynchronously, on cleanup).
|
||||
const isAborted = () => signal.aborted;
|
||||
let ws: WebSocket | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const start = async () => {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
|
||||
[
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-web-links'),
|
||||
],
|
||||
);
|
||||
if (isAborted()) return;
|
||||
|
||||
setStatus('connecting');
|
||||
setErrorText(undefined);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
|
||||
signal,
|
||||
});
|
||||
} catch {
|
||||
if (!isAborted()) setStatus('error');
|
||||
return;
|
||||
}
|
||||
if (isAborted()) return;
|
||||
if (response.status === 503) {
|
||||
setStatus('unconfigured');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
setErrorText(await response.text().catch(() => undefined));
|
||||
return;
|
||||
}
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: TERMINAL_FONT,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
theme: themeIsLight ? lightTheme : darkTheme,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(container);
|
||||
fit.fit();
|
||||
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 = () => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||||
);
|
||||
};
|
||||
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
if (isAborted()) return;
|
||||
setStatus('connected');
|
||||
sendResize();
|
||||
term.focus();
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
|
||||
if (typeof event.data === 'string') term.write(event.data);
|
||||
else term.write(new Uint8Array(event.data));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!isAborted()) setStatus('closed');
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (!isAborted()) setStatus('error');
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
||||
});
|
||||
term.onResize(() => sendResize());
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
resizeObserver?.disconnect();
|
||||
ws?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, jobId, reconnectKey]);
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{status === 'connected'
|
||||
? 'Connected · workspace shell'
|
||||
: status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'closed'
|
||||
? 'Session ended'
|
||||
: status === 'unconfigured'
|
||||
? 'Terminal not configured'
|
||||
: 'Connection error'}
|
||||
</p>
|
||||
{status === 'closed' || status === 'error' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setReconnectKey((key) => key + 1)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{status === 'unconfigured' ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||
The terminal is not enabled on this deployment.
|
||||
<br />
|
||||
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
||||
<div ref={containerRef} className='h-full w-full' />
|
||||
{errorText ? (
|
||||
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
||||
{errorText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,9 @@ export const env = createEnv({
|
||||
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
||||
SPOON_WORKER_TOKEN: z.string().optional(),
|
||||
// Secret shared with the worker for signing short-lived terminal tokens.
|
||||
// Falls back (in code) to the worker internal token.
|
||||
SPOON_AGENT_TERMINAL_SECRET: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -36,6 +39,10 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_SENTRY_URL: z.string(),
|
||||
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
|
||||
// Browser-facing WebSocket base URL of the agent worker, e.g.
|
||||
// `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev).
|
||||
// When unset, the workspace Terminal tab is disabled.
|
||||
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: z.string().optional(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
@@ -59,6 +66,9 @@ export const env = createEnv({
|
||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
||||
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
|
||||
SPOON_AGENT_TERMINAL_SECRET: process.env.SPOON_AGENT_TERMINAL_SECRET,
|
||||
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:
|
||||
process.env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { env } from '@/env';
|
||||
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
||||
@@ -8,6 +9,26 @@ import { fetchQuery } from 'convex/nextjs';
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
const terminalSecret = () =>
|
||||
env.SPOON_AGENT_TERMINAL_SECRET ??
|
||||
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
||||
env.SPOON_WORKER_TOKEN;
|
||||
|
||||
// Mints a short-lived, job-scoped terminal token + the worker WS URL. Returns
|
||||
// null when the terminal feature is not configured. The 2-minute expiry is a
|
||||
// connect window only; an established PTY session persists past it.
|
||||
export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
|
||||
const secret = terminalSecret();
|
||||
const base = env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL;
|
||||
if (!secret || !base) return null;
|
||||
const expiresAt = Date.now() + 2 * 60 * 1000;
|
||||
const payload = `${expiresAt}.${jobId}`;
|
||||
const signature = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
const token = `${payload}.${signature}`;
|
||||
const url = `${base.replace(/\/$/, '')}/jobs/${encodeURIComponent(jobId)}/terminal?token=${encodeURIComponent(token)}`;
|
||||
return { url, expiresAt };
|
||||
};
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ jobId: string }> | { jobId: string };
|
||||
};
|
||||
|
||||
@@ -23,14 +23,18 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"dockerode": "^4.0.7",
|
||||
"execa": "latest",
|
||||
"ws": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/dockerode": "^3.3.42",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -97,16 +101,21 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@git-diff-view/react": "^0.1.6",
|
||||
"@monaco-editor/react": "latest",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"@spoon/ui": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"convex": "catalog:convex",
|
||||
"monaco-editor": "latest",
|
||||
"monaco-vim": "latest",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
@@ -537,6 +546,8 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||
|
||||
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
|
||||
|
||||
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
|
||||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
|
||||
@@ -571,57 +582,57 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -705,6 +716,16 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@git-diff-view/core": ["@git-diff-view/core@0.1.6", "", { "dependencies": { "@git-diff-view/lowlight": "^0.1.6", "fast-diff": "^1.3.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0" } }, "sha512-q2Ch8jURF6pL7VeNpOgHBRVY9gsGLXCOYpKXHG3BqpXe0kv6GNSUux8SmAYsDrakBzfgDClODxDtsM2rfiWpnA=="],
|
||||
|
||||
"@git-diff-view/lowlight": ["@git-diff-view/lowlight@0.1.6", "", { "dependencies": { "@types/hast": "^3.0.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0" } }, "sha512-YIsiAc2aWAePWaDNi3k8xI0Vs/ZItt5J6nrftTIFbMFN3GwDOsyJFm2L7o8XWKTJkV2yItaz28KUI9CWj0MVZA=="],
|
||||
|
||||
"@git-diff-view/react": ["@git-diff-view/react@0.1.6", "", { "dependencies": { "@git-diff-view/core": "^0.1.6", "@types/hast": "^3.0.0", "fast-diff": "^1.3.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0", "reactivity-store": "^0.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-koABBon5bNKh6/WnWSxggK9ojw+cvWAPnY2/ciOkwlR+8dm0h6A7Qa5kP2HFDxqYHwZ2imkGMcSLgXMOnWHRFA=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
@@ -831,6 +852,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
|
||||
|
||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
||||
@@ -961,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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1041,6 +1064,24 @@
|
||||
|
||||
"@prisma/instrumentation": ["@prisma/instrumentation@7.4.2", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
@@ -1461,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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1513,6 +1554,10 @@
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
|
||||
|
||||
"@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
@@ -1525,6 +1570,8 @@
|
||||
|
||||
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
|
||||
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
|
||||
@@ -1547,12 +1594,16 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
@@ -1605,6 +1656,10 @@
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||
@@ -1637,6 +1692,12 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
|
||||
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
|
||||
|
||||
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
|
||||
|
||||
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
|
||||
|
||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||
|
||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
@@ -1701,6 +1762,8 @@
|
||||
|
||||
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||
@@ -1751,6 +1814,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
|
||||
@@ -1761,6 +1826,8 @@
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
||||
|
||||
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
||||
@@ -1777,6 +1844,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
@@ -1799,7 +1868,7 @@
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
|
||||
|
||||
@@ -1881,6 +1950,8 @@
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
|
||||
|
||||
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@@ -1965,6 +2036,12 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
|
||||
|
||||
"dockerode": ["dockerode@4.0.12", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
@@ -2003,6 +2080,8 @@
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
@@ -2039,7 +2118,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -2157,6 +2236,8 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -2205,6 +2286,8 @@
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
@@ -2277,6 +2360,8 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
||||
@@ -2523,6 +2608,8 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
@@ -2533,8 +2620,12 @@
|
||||
|
||||
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||
@@ -2617,6 +2708,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||
|
||||
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||
@@ -2635,6 +2728,8 @@
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="],
|
||||
@@ -2805,8 +2900,12 @@
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
||||
@@ -2877,6 +2976,10 @@
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"reactivity-store": ["reactivity-store@0.4.0", "", { "dependencies": { "@vue/reactivity": "~3.5.30", "@vue/shared": "~3.5.30", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-uL9uoREOBg2o4zUa8vMU0AbvAOk0osPloizscmyZqMvJzcuuKX3ELFYYr1DX8gAcfvlhPduz4QuLZn1eChCu4Q=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||
@@ -3031,10 +3134,14 @@
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
|
||||
|
||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
@@ -3073,6 +3180,8 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
@@ -3119,6 +3228,10 @@
|
||||
|
||||
"tar": ["tar@7.5.12", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
|
||||
|
||||
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
|
||||
@@ -3169,10 +3282,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
|
||||
@@ -3231,9 +3346,11 @@
|
||||
|
||||
"usesend-js": ["usesend-js@1.6.3", "", { "dependencies": { "@react-email/render": "^1.0.6", "react": "^19.1.0" } }, "sha512-HKhW4F+RbAnp6izWxo2sjISmxhYQvxAjAsBFvdn0P25oVnZ8kXTMjvEqKyvkhgRrzXALu0N6NUyQjVOdOsjnoA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
|
||||
|
||||
@@ -3297,7 +3414,7 @@
|
||||
|
||||
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
||||
|
||||
@@ -3399,8 +3516,6 @@
|
||||
|
||||
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"@expo/cli/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||
|
||||
"@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
@@ -3455,6 +3570,8 @@
|
||||
|
||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||
|
||||
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@ianvs/prettier-plugin-sort-imports/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||
|
||||
"@ianvs/prettier-plugin-sort-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||
@@ -3707,6 +3824,8 @@
|
||||
|
||||
"@sentry/vercel-edge/@sentry/core": ["@sentry/core@10.46.0", "", {}, "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q=="],
|
||||
|
||||
"@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -3745,6 +3864,8 @@
|
||||
|
||||
"@types/pg/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
||||
|
||||
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/tedious/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
@@ -3823,6 +3944,8 @@
|
||||
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
|
||||
"convex/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
|
||||
@@ -3899,8 +4022,6 @@
|
||||
|
||||
"happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"happy-dom/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
@@ -4079,6 +4200,8 @@
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -4427,6 +4550,8 @@
|
||||
|
||||
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@types/tedious/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
@@ -15,6 +15,7 @@ ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_SENTRY_URL
|
||||
ARG NEXT_PUBLIC_SENTRY_ORG
|
||||
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
||||
ARG NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
|
||||
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD
|
||||
@@ -25,6 +26,7 @@ ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL
|
||||
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
|
||||
ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
||||
ENV NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=$NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
|
||||
|
||||
# Copy source code (node_modules excluded via .dockerignore)
|
||||
COPY . .
|
||||
|
||||
@@ -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
|
||||
+47
-10
@@ -1,24 +1,61 @@
|
||||
FROM docker.io/library/node:22-bookworm
|
||||
FROM registry.fedoraproject.org/fedora:41
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV LANG=en_US.UTF-8
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# Core toolchain + interactive/QoL CLI tooling. Everything below is in the
|
||||
# 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-completion \
|
||||
bat \
|
||||
bubblewrap \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
eza \
|
||||
fd-find \
|
||||
findutils \
|
||||
fzf \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
gh \
|
||||
git \
|
||||
glibc-langpack-en \
|
||||
gum \
|
||||
gzip \
|
||||
jq \
|
||||
openssh-client \
|
||||
less \
|
||||
make \
|
||||
ncurses \
|
||||
neovim \
|
||||
nodejs \
|
||||
nodejs-npm \
|
||||
openssh-clients \
|
||||
procps-ng \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ripgrep \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@latest --activate \
|
||||
&& corepack prepare yarn@stable --activate \
|
||||
&& npm install -g bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
tar \
|
||||
tmux \
|
||||
unzip \
|
||||
wget \
|
||||
which \
|
||||
zoxide \
|
||||
&& dnf clean all \
|
||||
&& rm -rf /var/cache/dnf
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
|
||||
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
|
||||
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: ${NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:-}
|
||||
image: spoon-next:latest
|
||||
#image: git.gbrown.org/gib/spoon-next:latest
|
||||
container_name: ${NEXT_CONTAINER_NAME}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Workspace interactive terminal
|
||||
|
||||
A real shell inside the agent workspace, shown as the **Terminal** tab in the
|
||||
workspace UI. It's an xterm.js front end bridged to a bash/tmux PTY running in a
|
||||
persistent per-job container (the agent job image), mounting the same workspace
|
||||
the editor and agent use.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser (xterm.js)
|
||||
│ 1. GET /api/agent-jobs/:id/terminal-token (Convex-auth'd, owner only)
|
||||
│ → { url: "wss://worker…/jobs/:id/terminal?token=…", expiresAt }
|
||||
│
|
||||
│ 2. WebSocket wss://worker.<domain>/jobs/:id/terminal?token=…
|
||||
▼
|
||||
nginx ── upgrade ──► spoon-agent-worker :3921
|
||||
│ verifyTerminalToken(token, jobId, secret)
|
||||
│ dockerode exec -t → bash/tmux PTY
|
||||
▼
|
||||
spoon-agent-term-<jobId> (job image, mounts the workspace)
|
||||
```
|
||||
|
||||
- The browser **never** holds the worker secret. The Next app (which has already
|
||||
verified job ownership) mints a short-lived HMAC token; the worker verifies it.
|
||||
- Frames: **binary** = stdin/stdout bytes; **text JSON** `{type:"resize",cols,rows}`
|
||||
= resize. The token's 2-minute expiry is a _connect_ window; an established
|
||||
session persists.
|
||||
- The shell runs `tmux new-session -A -s spoon` (falls back to `bash -l`), so
|
||||
reconnecting reattaches the same session. Idle containers are removed after
|
||||
`SPOON_AGENT_TERMINAL_IDLE_MS` (default 30m).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Where | Variable | Required? | Notes |
|
||||
| -------- | --------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Next app | `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | **Yes** | Browser-facing worker WS base, e.g. `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev). **Build-time** (`NEXT_PUBLIC`): for the Docker image it must be passed as a build arg (wired in `docker/Dockerfile` + `docker/compose.yml`, sourced from the build env file), not a runtime env. Unset → the Terminal tab shows "not configured". |
|
||||
| Next app | `SPOON_AGENT_TERMINAL_SECRET` | No | HMAC secret for signing tokens. Falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN`. |
|
||||
| Worker | `SPOON_AGENT_TERMINAL_SECRET` | No | Must match the Next app's. Falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` (already shared), so by default **no new secret is needed**. |
|
||||
| Worker | `SPOON_AGENT_TERMINAL_IMAGE` | No | Shell container image. Defaults to `SPOON_AGENT_JOB_IMAGE`. |
|
||||
| Worker | `SPOON_AGENT_TERMINAL_IDLE_MS` | No | Idle-container reap delay (default `1800000`). |
|
||||
|
||||
Because the secret defaults to the already-shared worker token, the **only**
|
||||
required step is setting `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` and exposing the
|
||||
worker over nginx (prod).
|
||||
|
||||
## Exposing the worker (prod, nginx)
|
||||
|
||||
The worker and nginx are on the same `nginx-bridge` network, so nginx can reach
|
||||
`spoon-agent-worker:3921` directly — no published port needed. Add a server block
|
||||
that upgrades WebSockets:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name worker.spoon.gbrown.org;
|
||||
# ssl_certificate ... ; ssl_certificate_key ... ;
|
||||
|
||||
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; # keep idle terminals open
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then set on the Next app: `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org`.
|
||||
|
||||
> The worker's HTTP routes (`/jobs/:id/tree` etc.) require the internal bearer
|
||||
> token, so exposing the worker host only usefully exposes the token-gated
|
||||
> `/jobs/:id/terminal` upgrade. Still, restrict the server block to TLS.
|
||||
|
||||
## Dev testing (no nginx)
|
||||
|
||||
The dev worker runs on the host at `localhost:3921` (`bun dev:next:worker`), so
|
||||
the browser can hit it directly:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=ws://localhost:3921
|
||||
```
|
||||
|
||||
Note: the terminal uses **dockerode** against the Docker socket. In dev with
|
||||
Podman, point it at the Podman socket (run `podman system service` and set
|
||||
`DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock`), or run the worker in
|
||||
Docker mode. Prod (Docker socket mounted) works as-is.
|
||||
|
||||
## Security
|
||||
|
||||
- Owner-only: the token route uses Convex auth + `assertOwned`.
|
||||
- Tokens are short-lived (2m connect window), job-scoped, HMAC-signed.
|
||||
- A shell in the workspace can reach the network and the repo's git credentials.
|
||||
This is intended for the single-user self-hosted deployment; do not expose the
|
||||
worker domain without TLS, and keep the deployment single-tenant.
|
||||
|
||||
## Tools in the shell
|
||||
|
||||
The job image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
|
||||
`node`, `bun`, `pnpm`, `yarn`, `curl`/`wget`, `unzip`. Bring your own dotfiles by
|
||||
cloning them in-session (e.g. `git clone <dotfiles> ~/.config/...`); persistent
|
||||
auto-cloning of a dotfiles repo is a planned follow-up.
|
||||
@@ -46,6 +46,11 @@ services:
|
||||
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
|
||||
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
|
||||
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
|
||||
# NOTE: the Terminal tab needs NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL, which is
|
||||
# a NEXT_PUBLIC (build-time) var — it must be baked into the spoon-next image
|
||||
# at build (via the build env file / CI), NOT set as a runtime env here. Also
|
||||
# requires nginx to proxy worker.<domain> → spoon-agent-worker:3921 with WS
|
||||
# upgrade. See docs/agent-terminal.md.
|
||||
depends_on: ['spoon-backend', 'spoon-postgres']
|
||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||
tty: true
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -119,6 +119,10 @@
|
||||
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
|
||||
"prettier --write"
|
||||
],
|
||||
"apps/agent-worker/**/*.{ts,tsx}": [
|
||||
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
|
||||
"prettier --write"
|
||||
],
|
||||
"packages/backend/**/*.{ts,tsx}": [
|
||||
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config packages/backend/eslint.config.ts",
|
||||
"prettier --write"
|
||||
|
||||
@@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => {
|
||||
if (!trimmed) return undefined;
|
||||
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_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({
|
||||
ownerId: v.id('users'),
|
||||
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_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}"
|
||||
# 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
|
||||
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; }
|
||||
"$ROOT_DIR/scripts/infisical-account" ensure
|
||||
|
||||
(cd "$ROOT_DIR" && infisical export --env="$INFISICAL_ENV" --format=dotenv --silent) || {
|
||||
echo "export-env: failed to export '$INFISICAL_ENV'; check login and project access." >&2
|
||||
# Retry transient Infisical failures (e.g. 500s when several dev tasks fetch
|
||||
# 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
|
||||
}
|
||||
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
|
||||
printf '\n'
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"SPOON_WORKER_TOKEN",
|
||||
"SPOON_AGENT_WORKER_ID",
|
||||
"SPOON_AGENT_JOB_IMAGE",
|
||||
"SPOON_AGENT_TERMINAL_IMAGE",
|
||||
"SPOON_AGENT_TERMINAL_SECRET",
|
||||
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
||||
"SPOON_AGENT_BOX_IDLE_MS",
|
||||
"SPOON_AGENT_DEV_WATCHDOG",
|
||||
"SPOON_AGENT_RUNTIME",
|
||||
"SPOON_AGENT_CONTAINER_RUNTIME",
|
||||
"SPOON_AGENT_CONTAINER_VOLUME_OPTIONS",
|
||||
|
||||
Reference in New Issue
Block a user