Add agent workflows & stuff
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 21:15:15 -05:00
parent cf7ff2ee4e
commit 2dfa97ee4f
102 changed files with 8488 additions and 161 deletions
+11
View File
@@ -3,12 +3,17 @@
## Architecture ## Architecture
- `apps/next`: Next.js 16 frontend. - `apps/next`: Next.js 16 frontend.
- `apps/agent-worker`: optional server-side coding-agent worker. It polls
Convex for queued jobs and may control Docker/Podman to run ephemeral job
containers.
- `apps/expo`: Expo scaffold; only work here when explicitly requested. - `apps/expo`: Expo scaffold; only work here when explicitly requested.
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth. - `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
- `packages/ui`: shared shadcn-based UI components. - `packages/ui`: shared shadcn-based UI components.
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. - `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
- Local development uses host-run apps, local Convex on ports 3210/3211, local - Local development uses host-run apps, local Convex on ports 3210/3211, local
Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791. Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791.
Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as
`spoon-agent-job:latest` before running Docker-backed jobs.
## Protected and generated files ## Protected and generated files
@@ -26,6 +31,10 @@
- Run `infisical login` and `infisical init` before local development. - Run `infisical login` and `infisical init` before local development.
- Machine-generated values belong in `.local/<env>.generated.env`; never put - Machine-generated values belong in `.local/<env>.generated.env`; never put
the generated Convex admin key in shared Infisical. the generated Convex admin key in shared Infisical.
- `scripts/sync-convex-env <dev|staging>` copies Authentik, GitHub App,
UseSend, `SITE_URL`, `SPOON_WORKER_TOKEN`, encryption, and Convex Auth signing
variables from Infisical into the selected Convex deployment. Backend
dev/setup scripts run it before `convex dev`.
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical. - CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
- App code imports validated variables from `@/env`, never `process.env`. - App code imports validated variables from `@/env`, never `process.env`.
- Add cache-relevant variables to `turbo.json` `globalEnv`. - Add cache-relevant variables to `turbo.json` `globalEnv`.
@@ -47,6 +56,8 @@
```sh ```sh
bun db:up # start Postgres, Convex, and dashboard bun db:up # start Postgres, Convex, and dashboard
bun dev:next # host Next + deploy/watch local Convex functions bun dev:next # host Next + deploy/watch local Convex functions
bun dev:agent # run the optional coding-agent worker on the host
bun sync:convex # sync Infisical values into Convex
bun db:down # stop and preserve local data bun db:down # stop and preserve local data
bun db:down:wipe # remove local data volumes and generated admin key bun db:down:wipe # remove local data volumes and generated admin key
``` ```
+204 -38
View File
@@ -1,11 +1,77 @@
# Spoon # Spoon
A reusable Bun/Turborepo template with Next.js 16, Expo, self-hosted Convex, Spoon is a self-hostable fork maintenance dashboard.
shared UI/config packages, Vitest, and Docker deployment.
The product goal is simple: make it practical to fork a project, customize it,
and still stay close to upstream. Spoon tracks managed forks, called
**Spoons**, and lays the foundation for upstream update checks, AI-assisted
change review, and agent-authored merge requests.
This repository is the Spoon application itself, not a generic starter.
## Current scope
Implemented today:
- Public Spoon landing page in Next.js.
- Authenticated web dashboard routes:
- `/dashboard`
- `/spoons`
- `/spoons/new`
- `/updates`
- `/spoons/[spoonId]`
- `/settings`
- Manual and GitHub-created Spoon records stored in Convex.
- GitHub App connection, repository listing, fork creation, drift refresh,
commit/PR cache, and safe manual sync foundation.
- Per-user OpenAI settings for upstream compatibility review.
- Per-Spoon encrypted project secrets and agent runtime settings.
- Optional `apps/agent-worker` service that can claim queued jobs, clone the
GitHub fork, ask OpenAI for bounded file edits, run checks, push a branch, and
open a draft PR.
- Password auth and Authentik OAuth through Convex Auth.
- Expo companion app shell with password and Authentik sign-in.
- Self-hosted local Convex using Postgres storage.
Not implemented yet:
- Browser IDE/editor.
- Automatic merge.
- Additional Git provider automation beyond preserving provider-neutral fields.
- Additional remotes as push targets.
- Long-running service-stack orchestration inside agent jobs.
- Production mobile build/release setup.
## Architecture
- `apps/next`: Next.js 16 web app and primary product UI.
- `apps/agent-worker`: optional server-side worker for queued coding-agent jobs.
- `apps/expo`: Expo companion app.
- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and
HTTP routes.
- `packages/ui`: shared shadcn-based UI components.
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
- `docker`: local and production Compose files.
- `scripts`: environment, database, and CI helpers.
The core domain objects are:
- `spoons`: managed fork records.
- `gitConnections`: future Git provider connection metadata.
- `syncRuns`: future upstream checks, merge attempts, and AI reviews.
- `agentRequests`: prompt-driven agent work requests.
- `agentJobs`: worker-executed coding-agent jobs and their PR lifecycle.
- `spoonSecrets`: encrypted per-Spoon environment variables.
- `spoonAgentSettings`: per-Spoon agent model, branch, and command settings.
## Local setup ## Local setup
Requirements: Bun 1.3.10, Docker or Podman, Node 22, and the Infisical CLI. Requirements:
- Bun 1.3.10
- Node 22
- Docker or Podman
- Infisical CLI
```sh ```sh
bun install --frozen-lockfile bun install --frozen-lockfile
@@ -15,72 +81,172 @@ bun db:up
bun dev:next bun dev:next
``` ```
The committed `.infisical.json` links this repository to its own Infisical
project. Local commands read `dev` by default and never fall back to `.env`
files. Select staging with `INFISICAL_ENV=staging bun dev:next`.
Local services: Local services:
- Next.js: `http://localhost:3000` - Next.js: `http://localhost:3000`
- Convex API: `http://localhost:3210` - Convex API: `http://localhost:3210`
- Convex site HTTP routes: `http://localhost:3211`
- Convex dashboard: `http://localhost:6791` - Convex dashboard: `http://localhost:6791`
- Convex Postgres: `localhost:5432` - Convex Postgres: `localhost:5432`
Next and Expo run on the host. Spoon runs local Convex with Postgres storage by Next and Expo run on the host. Local Convex runs in containers with Postgres
default. Local Compose creates the database named by `LOCAL_INSTANCE_NAME` storage. Normal `bun db:up` never contacts staging; it starts local Postgres,
because the Convex backend opens that database inside the Postgres cluster. Convex, and the dashboard, generates a machine-local Convex admin key in
Convex receives a database-cluster URL without a path. `.local/dev.generated.env` when needed, deploys functions/schema, and
configures local Convex Auth keys.
```sh ```sh
bun db:down # stop; preserve local data bun db:down # stop; preserve local data
bun db:down:wipe # remove local data volumes and generated admin key bun db:down:wipe # remove local data volumes and generated admin key
``` ```
Normal `bun db:up` never contacts staging. It starts local Postgres, Convex, and Use staging services explicitly:
the dashboard, generates a machine-local Convex admin key in
`.local/dev.generated.env` when needed, deploys functions/schema, and configures
local Convex Auth keys.
Physical devices cannot resolve their own `localhost`; override the public ```sh
Convex URL with the development host's LAN address when testing Expo on-device. INFISICAL_ENV=staging bun dev:next
```
Run the optional local agent worker in a separate terminal:
```sh
bun dev:agent
```
The Docker Compose local worker service is disabled by default behind the
`agent` profile. Build the job image before using Docker-backed jobs:
```sh
docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest .
docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker
```
## Environment model ## Environment model
- Local `dev` and `staging`: Infisical. Local `dev` and `staging` values come from Infisical through `scripts/with-env`.
- Generated local state: `.local/<environment>.generated.env`. App commands do not fall back to root `.env` files.
- CI/CD: Gitea `DOTENV_PROD`, materialized only as a temporary runner file.
- Docker compilation: explicit Compose build args; `.env*` stays outside the
image context.
Run `sh scripts/with-env dev -- <command>` for an environment-aware command or Generated local state belongs in:
`sh scripts/export-env dev` to materialize a temporary merged dotenv stream.
Do not commit or maintain root `.env` files.
## Development and quality ```txt
.local/<environment>.generated.env
```
CI uses Gitea-provided secrets or `CI_ENV_FILE` and must not call Infisical.
Useful helpers:
```sh
sh scripts/with-env dev -- <command>
sh scripts/export-env dev
bun sync:convex
```
### Convex deployment env
Convex functions and HTTP actions read environment variables from the Convex
deployment environment, not directly from the host process. For OAuth providers,
that means Infisical values must also be present in local Convex env.
`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so
`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
values into the selected Convex deployment first. Run it manually when needed:
```sh
sh scripts/sync-convex-env dev
sh scripts/sync-convex-env staging
INFISICAL_ENV=staging bun sync:convex
```
The sync includes:
```txt
AUTH_AUTHENTIK_ID
AUTH_AUTHENTIK_SECRET
AUTH_AUTHENTIK_ISSUER
AUTH_GITHUB_ID
AUTH_GITHUB_SECRET
GITHUB_APP_ID
GITHUB_APP_CLIENT_ID
GITHUB_APP_CLIENT_SECRET
GITHUB_APP_PRIVATE_KEY
GITHUB_APP_WEBHOOK_SECRET
GITHUB_APP_SLUG
GITHUB_APP_INSTALLATION_ID
GITHUB_APP_OWNER
SPOON_ENCRYPTION_KEY
SPOON_WORKER_TOKEN
USESEND_API_KEY
USESEND_URL
USESEND_FROM_EMAIL
JWT_PRIVATE_KEY
JWKS
SITE_URL
```
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`, and
`SPOON_WORKER_TOKEN` are generated automatically if they are not already present
in Convex. The generated Convex admin key remains machine-local in
`.local/dev.generated.env`; do not put it in Infisical.
The local OAuth callback URLs are:
```txt
http://localhost:3211/api/auth/callback/authentik
http://localhost:3211/api/auth/callback/github
```
If GitHub App actions fail with `GITHUB_APP_PRIVATE_KEY is not configured`, add
the full PEM contents to Infisical as `GITHUB_APP_PRIVATE_KEY` and rerun the
sync command.
## Development
```sh ```sh
bun dev:next bun dev:next
bun dev:expo bun dev:expo
```
Physical devices cannot resolve their own `localhost`; override the public
Convex URL with the development host's LAN address when testing Expo on-device.
Shared dependency versions belong in root catalogs. Edit the root catalog, run
`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace.
## Validation
Routine checks:
```sh
bun lint:ws bun lint:ws
bun format bun format
bun lint bun lint
bun typecheck bun typecheck
bun test:unit bun run test
bun test:integration ```
bun test:component
Full local gate without e2e:
```sh
SKIP_E2E=1 bun run ci:check SKIP_E2E=1 bun run ci:check
``` ```
`bun test:e2e` starts the isolated local stack and currently performs generic Local-stack smoke e2e:
stack smoke checks. It stops the stack afterward only when the stack was not
already running. It skips in CI and when `SKIP_E2E=1` is set.
Shared dependency versions belong in root catalogs. Edit the root catalog, run ```sh
`bun install`, then `bun lint:ws`; do not run `bun update` inside a workspace. bun test:e2e
```
`bun test:e2e` starts the isolated local stack when needed and stops it
afterward only when it was not already running.
Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in
test runner instead of the repo's Turbo/Vitest test script.
## Deployment ## Deployment
Production Compose retains the self-hosted Convex backend/dashboard and expects Production Compose keeps the self-hosted Convex backend/dashboard and expects
`POSTGRES_URL` to be a database-cluster URL without a database path. Gitea runs `POSTGRES_URL` to be a database-cluster URL without a database path.
the quality gate first, builds the Next image from a temporary Gitea-secret env
file, then pushes SHA and `latest` tags. CI never installs or invokes Infisical. Gitea runs the quality gate first, builds the Next image from a temporary
Gitea-secret env file, then pushes SHA and `latest` tags. CI never installs or
invokes Infisical.
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@spoon/eslint-config/base';
export default defineConfig(baseConfig, {
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
});
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@spoon/agent-worker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun with-env src/index.ts",
"start": "bun src/index.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"typecheck": "tsc --noEmit",
"test:unit": "vitest run --project unit --passWithNoTests",
"test:integration": "vitest run --project integration --passWithNoTests",
"test:component": "vitest run --project component --passWithNoTests",
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
},
"dependencies": {
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@openai/agents": "latest",
"convex": "catalog:convex",
"execa": "latest",
"openai": "^6.44.0",
"zod": "catalog:"
},
"devDependencies": {
"@spoon/eslint-config": "workspace:*",
"@spoon/prettier-config": "workspace:*",
"@spoon/tsconfig": "workspace:*",
"@types/node": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:test"
},
"prettier": "@spoon/prettier-config"
}
+190
View File
@@ -0,0 +1,190 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { execa } from 'execa';
import OpenAI from 'openai';
const editSchema = {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'string' },
files: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string' },
content: { type: 'string' },
},
required: ['path', 'content'],
},
},
commands: {
type: 'array',
items: { type: 'string' },
},
limitations: {
type: 'array',
items: { type: 'string' },
},
},
required: ['summary', 'files', 'commands', 'limitations'],
} as const;
type AgentEdit = {
summary: string;
files: { path: string; content: string }[];
commands: string[];
limitations: string[];
};
const maxContextFiles = 40;
const maxFileBytes = 12_000;
const safeContextFile = (file: string) =>
!file.includes('node_modules/') &&
!file.includes('.git/') &&
!file.includes('dist/') &&
!file.includes('build/') &&
!file.includes('.next/') &&
!file.endsWith('.lock') &&
!file.endsWith('.png') &&
!file.endsWith('.jpg') &&
!file.endsWith('.jpeg') &&
!file.endsWith('.webp') &&
!file.endsWith('.gif') &&
!file.endsWith('.pdf');
const listFiles = async (repoDir: string) => {
const result = await execa('git', ['ls-files'], {
cwd: repoDir,
all: true,
reject: false,
});
return result.all
.split('\n')
.map((file) => file.trim())
.filter(Boolean)
.filter(safeContextFile);
};
const chooseContextFiles = (files: string[], prompt: string) => {
const promptWords = new Set(
prompt
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 3),
);
const scored = files.map((file) => {
const lower = file.toLowerCase();
const score = [...promptWords].reduce(
(sum, word) => sum + (lower.includes(word) ? 2 : 0),
/(readme|package\.json|auth|env|config|route|provider)/i.exec(file)
? 3
: 0,
);
return { file, score };
});
return scored
.sort((a, b) => b.score - a.score)
.slice(0, maxContextFiles)
.map((item) => item.file);
};
const readContext = async (repoDir: string, files: string[]) => {
const chunks = [];
for (const file of files) {
try {
const content = await readFile(path.join(repoDir, file), 'utf8');
chunks.push({
path: file,
content:
content.length > maxFileBytes
? `${content.slice(0, maxFileBytes)}\n[truncated]`
: content,
});
} catch {
// Ignore files that disappeared while context was being gathered.
}
}
return chunks;
};
const parseEdit = (value: string): AgentEdit => {
const parsed = JSON.parse(value) as AgentEdit;
if (!Array.isArray(parsed.files)) {
throw new Error('OpenAI returned an edit without a files array.');
}
return parsed;
};
const safePath = (repoDir: string, filePath: string) => {
const resolved = path.resolve(repoDir, filePath);
if (!resolved.startsWith(path.resolve(repoDir))) {
throw new Error(`Refusing to write outside the repository: ${filePath}`);
}
return resolved;
};
export const runOpenAiEdit = async (args: {
repoDir: string;
apiKey: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
prompt: string;
secretNames: string[];
spoonName: string;
upstreamFullName: string;
forkFullName: string;
}) => {
const files = await listFiles(args.repoDir);
const selectedFiles = chooseContextFiles(files, args.prompt);
const contextFiles = await readContext(args.repoDir, selectedFiles);
const response = await new OpenAI({ apiKey: args.apiKey }).responses.create({
model: args.model,
store: false,
reasoning:
args.reasoningEffort === 'none'
? undefined
: { effort: args.reasoningEffort },
input: [
{
role: 'system',
content:
'You are a conservative coding agent working in a fork. Return complete replacement contents only for files that must change. Keep the diff minimal. Do not include secrets. Do not claim commands passed unless they are listed for the worker to run. If the context is insufficient, make the safest small change and describe limitations.',
},
{
role: 'user',
content: JSON.stringify(
{
task: args.prompt,
spoon: args.spoonName,
upstream: args.upstreamFullName,
fork: args.forkFullName,
availableSecretNames: args.secretNames,
repositoryFiles: files.slice(0, 500),
contextFiles,
},
null,
2,
),
},
],
text: {
format: {
type: 'json_schema',
name: 'spoon_agent_file_edits',
strict: true,
schema: editSchema,
},
},
});
const edit = parseEdit(response.output_text);
for (const file of edit.files) {
const target = safePath(args.repoDir, file.path);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content);
}
return edit;
};
+34
View File
@@ -0,0 +1,34 @@
const intEnv = (name: string, fallback: number) => {
const value = process.env[name];
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
const requiredEnv = (name: string) => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required.`);
return value;
};
export const env = {
convexUrl:
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
'http://localhost:3210',
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
network: process.env.SPOON_AGENT_NETWORK?.trim(),
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
githubAppId: requiredEnv('GITHUB_APP_ID'),
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
'\\n',
'\n',
),
};
+144
View File
@@ -0,0 +1,144 @@
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { execa } from 'execa';
export type RunOptions = {
cwd: string;
env?: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
};
export const run = async (
command: string,
args: string[],
options: RunOptions,
) => {
const result = await execa(command, args, {
cwd: options.cwd,
env: options.env,
all: true,
reject: false,
timeout: options.timeoutMs,
});
return {
exitCode: result.exitCode ?? 0,
output: options.redact(result.all),
};
};
export const cloneRepository = async (args: {
workdir: string;
token: string;
owner: string;
repo: string;
baseBranch: string;
workBranch: string;
redact: (value: string) => string;
timeoutMs: number;
}) => {
await mkdir(args.workdir, { recursive: true });
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'],
{
cwd: args.workdir,
redact: args.redact,
timeoutMs: args.timeoutMs,
},
);
if (clone.exitCode !== 0) {
throw new Error(`git clone failed:\n${clone.output}`);
}
const repoDir = path.join(args.workdir, 'repo');
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
cwd: repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
});
if (checkout.exitCode !== 0) {
throw new Error(`git checkout failed:\n${checkout.output}`);
}
return repoDir;
};
export const commitAndPush = async (args: {
repoDir: string;
workBranch: string;
message: string;
redact: (value: string) => string;
timeoutMs: number;
}) => {
await run('git', ['config', 'user.name', 'Spoon Agent'], {
cwd: args.repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
});
await run(
'git',
['config', 'user.email', 'spoon-agent@users.noreply.github.com'],
{
cwd: args.repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
},
);
await run('git', ['add', '-A'], {
cwd: args.repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
});
const commit = await run('git', ['commit', '-m', args.message], {
cwd: args.repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
});
if (commit.exitCode !== 0) {
throw new Error(`git commit failed:\n${commit.output}`);
}
const sha = await run('git', ['rev-parse', 'HEAD'], {
cwd: args.repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
});
const push = await run('git', ['push', 'origin', args.workBranch], {
cwd: args.repoDir,
redact: args.redact,
timeoutMs: args.timeoutMs,
});
if (push.exitCode !== 0) {
throw new Error(`git push failed:\n${push.output}`);
}
return sha.output.trim();
};
export const getDiff = async (
repoDir: string,
redact: (value: string) => string,
) =>
await run('git', ['diff', '--cached', '--', '.'], {
cwd: repoDir,
redact,
timeoutMs: 60_000,
});
export const getWorktreeDiff = async (
repoDir: string,
redact: (value: string) => string,
) =>
await run('git', ['diff', '--', '.'], {
cwd: repoDir,
redact,
timeoutMs: 60_000,
});
export const getStatus = async (
repoDir: string,
redact: (value: string) => string,
) =>
await run('git', ['status', '--short'], {
cwd: repoDir,
redact,
timeoutMs: 60_000,
});
+52
View File
@@ -0,0 +1,52 @@
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
import { env } from './env';
export const getInstallationToken = async (installationId: string) => {
const auth = createAppAuth({
appId: env.githubAppId,
privateKey: env.githubPrivateKey,
installationId,
});
const result = await auth({ type: 'installation' });
return result.token;
};
export const getInstallationOctokit = (installationId: string) =>
new Octokit({
authStrategy: createAppAuth,
auth: {
appId: env.githubAppId,
privateKey: env.githubPrivateKey,
installationId,
},
userAgent: 'Spoon Agent Worker',
request: {
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
},
});
export const openDraftPullRequest = async (args: {
installationId: string;
forkOwner: string;
forkRepo: string;
baseBranch: string;
workBranch: string;
title: string;
body: string;
}) => {
const octokit = getInstallationOctokit(args.installationId);
const result = await octokit.rest.pulls.create({
owner: args.forkOwner,
repo: args.forkRepo,
base: args.baseBranch,
head: args.workBranch,
title: args.title,
body: args.body,
draft: true,
});
return result.data;
};
+3
View File
@@ -0,0 +1,3 @@
import { startWorker } from './worker';
await startWorker();
+26
View File
@@ -0,0 +1,26 @@
const secretPatterns = [
/ghs_[A-Za-z0-9_]+/g,
/github_pat_[A-Za-z0-9_]+/g,
/sk-[A-Za-z0-9_-]+/g,
/(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
];
export const createRedactor = (values: string[]) => {
const secrets = values.filter((value) => value.length >= 3);
return (input: string) => {
let output = input;
for (const secret of secrets) {
output = output.split(secret).join('[redacted]');
}
for (const pattern of secretPatterns) {
output = output.replace(pattern, '$1=[redacted]');
}
return output;
};
};
export const truncate = (value: string, maxBytes: number) => {
const buffer = Buffer.from(value);
if (buffer.byteLength <= maxBytes) return value;
return `${buffer.subarray(0, maxBytes).toString('utf8')}\n[truncated]`;
};
+45
View File
@@ -0,0 +1,45 @@
import { execa } from 'execa';
import { env } from '../env';
export const runInJobContainer = async (args: {
workdir: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}) => {
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
'-e',
`${name}=${value}`,
]);
const networkArgs = env.network ? ['--network', env.network] : [];
const result = await execa(
'docker',
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs,
...envArgs,
'-v',
`${args.workdir}:/workspace`,
'-w',
'/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
timeout: args.timeoutMs,
},
);
return {
exitCode: result.exitCode ?? 0,
output: args.redact(result.all),
};
};
+423
View File
@@ -0,0 +1,423 @@
import { access, readFile, rm } 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 { runOpenAiEdit } from './agent';
import { env } from './env';
import {
cloneRepository,
commitAndPush,
getStatus,
getWorktreeDiff,
run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import { createRedactor, truncate } from './redact';
import { runInJobContainer } from './runtime/docker';
type Claim = {
job: {
_id: Id<'agentJobs'>;
prompt: string;
baseBranch: string;
workBranch: string;
forkOwner: string;
forkRepo: string;
upstreamOwner: string;
upstreamRepo: string;
};
spoon: { name: string };
openai: {
apiKey: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
github: { installationId?: string };
agentSettings?: {
installCommand?: string;
checkCommand?: string;
testCommand?: string;
} | null;
secrets: { name: string; value: string }[];
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new ConvexHttpClient(env.convexUrl);
const appendEvent = async (
jobId: Id<'agentJobs'>,
level: 'debug' | 'info' | 'warn' | 'error',
phase:
| 'queued'
| 'clone'
| 'plan'
| 'edit'
| 'install'
| 'check'
| 'test'
| 'commit'
| 'push'
| 'pr'
| 'cleanup',
message: string,
metadata?: string,
) =>
await client.mutation(api.agentJobs.appendEvent, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
level,
phase,
message,
metadata,
});
const updateStatus = async (
jobId: Id<'agentJobs'>,
status:
| 'queued'
| 'claimed'
| 'preparing'
| 'running'
| 'checks_running'
| 'changes_ready'
| 'draft_pr_opened'
| 'failed'
| 'cancelled'
| 'timed_out',
extra?: { error?: string; summary?: string },
) =>
await client.mutation(api.agentJobs.updateStatus, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
status,
...extra,
});
const addArtifact = async (args: {
jobId: Id<'agentJobs'>;
kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body';
title: string;
content: string;
contentType:
| 'text/markdown'
| 'text/plain'
| 'application/json'
| 'text/x-diff';
}) =>
await client.mutation(api.agentJobs.addArtifact, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const completeWithDraftPr = async (args: {
jobId: Id<'agentJobs'>;
commitSha: string;
pullRequestUrl: string;
pullRequestNumber: number;
summary: string;
}) =>
await client.mutation(api.agentJobs.completeWithDraftPr, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const commandToShell = (command: string) => ['bash', '-lc', command];
const fileExists = async (filePath: string) => {
try {
await access(filePath);
return true;
} catch {
return false;
}
};
const runProjectCommand = async (args: {
command: string;
phase: 'install' | 'check' | 'test';
claim: Claim;
workdir: string;
repoDir: string;
redact: (value: string) => string;
}) => {
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
const result =
env.runtime === 'docker'
? await runInJobContainer({
workdir: args.workdir,
command: commandToShell(args.command),
environment: Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
),
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]),
),
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
});
await addArtifact({
jobId: args.claim.job._id,
kind: args.phase === 'test' ? 'test_output' : 'summary',
title: args.command,
content: truncate(result.output, 100_000),
contentType: 'text/plain',
});
if (result.exitCode !== 0) {
throw new Error(`${args.command} failed:\n${result.output}`);
}
};
const detectPackageCommands = async (
repoDir: string,
): Promise<{ install?: string; check?: string; test?: string }> => {
const packageJsonPath = path.join(repoDir, 'package.json');
try {
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
scripts?: Record<string, string>;
};
const scripts = packageJson.scripts ?? {};
return {
install: (await fileExists(path.join(repoDir, 'bun.lock')))
? 'bun install'
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
? 'pnpm install'
: (await fileExists(path.join(repoDir, 'yarn.lock')))
? 'yarn install'
: 'npm install',
check: scripts.typecheck
? 'npm run typecheck'
: scripts.lint
? 'npm run lint'
: undefined,
test: scripts.test ? 'npm test' : undefined,
};
} catch {
return {};
}
};
const buildPrBody = (args: {
prompt: string;
summary: string;
commands: string[];
limitations: string[];
}) => `## Spoon agent request
${args.prompt}
## Summary
${args.summary}
## Validation
${
args.commands.length
? args.commands.map((command) => `- \`${command}\``).join('\n')
: '- No validation commands were requested by the agent.'
}
## Limitations
${
args.limitations.length
? args.limitations.map((item) => `- ${item}`).join('\n')
: '- No limitations reported.'
}
Generated by Spoon.`;
const runClaim = async (claim: Claim) => {
const jobId = claim.job._id;
const workdir = path.resolve(env.workdir, jobId);
const secretValues = [
claim.openai.apiKey,
...claim.secrets.map((secret) => secret.value),
];
const redact = createRedactor(secretValues);
try {
await updateStatus(jobId, 'preparing');
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
if (!claim.github.installationId) {
throw new Error('GitHub installation ID is missing.');
}
const githubToken = await getInstallationToken(claim.github.installationId);
const repoDir = await cloneRepository({
workdir,
token: githubToken,
owner: claim.job.forkOwner,
repo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
redact,
timeoutMs: env.jobTimeoutMs,
});
await updateStatus(jobId, 'running');
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
const edit = await runOpenAiEdit({
repoDir,
apiKey: claim.openai.apiKey,
model: claim.openai.model,
reasoningEffort: claim.openai.reasoningEffort,
prompt: claim.job.prompt,
secretNames: claim.secrets.map((secret) => secret.name),
spoonName: claim.spoon.name,
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
});
await addArtifact({
jobId,
kind: 'plan',
title: 'Agent plan',
content: edit.summary,
contentType: 'text/markdown',
});
const status = await getStatus(repoDir, redact);
if (!status.output.trim()) {
throw new Error('No changes produced by the agent.');
}
const diff = await getWorktreeDiff(repoDir, redact);
await addArtifact({
jobId,
kind: 'diff',
title: 'Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await updateStatus(jobId, 'checks_running');
const detected = await detectPackageCommands(repoDir);
const settings = claim.agentSettings;
const installCommand = settings?.installCommand ?? detected.install;
const checkCommand = settings?.checkCommand ?? detected.check;
const testCommand = settings?.testCommand ?? detected.test;
if (installCommand) {
await runProjectCommand({
command: installCommand,
phase: 'install',
claim,
workdir,
repoDir,
redact,
});
}
if (checkCommand) {
await runProjectCommand({
command: checkCommand,
phase: 'check',
claim,
workdir,
repoDir,
redact,
});
}
if (testCommand) {
await runProjectCommand({
command: testCommand,
phase: 'test',
claim,
workdir,
repoDir,
redact,
});
}
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
const commitSha = await commitAndPush({
repoDir,
workBranch: claim.job.workBranch,
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
redact,
timeoutMs: env.jobTimeoutMs,
});
const prBody = buildPrBody({
prompt: claim.job.prompt,
summary: edit.summary,
commands: [
installCommand,
checkCommand,
testCommand,
...edit.commands,
].filter((command): command is string => Boolean(command)),
limitations: edit.limitations,
});
await addArtifact({
jobId,
kind: 'pr_body',
title: 'Draft PR body',
content: prBody,
contentType: 'text/markdown',
});
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
const pullRequest = await openDraftPullRequest({
installationId: claim.github.installationId,
forkOwner: claim.job.forkOwner,
forkRepo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
body: prBody,
});
await completeWithDraftPr({
jobId,
commitSha,
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
summary: edit.summary,
});
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
jobId,
'error',
'cleanup',
truncate(redact(message), 20_000),
);
await addArtifact({
jobId,
kind: 'error',
title: 'Failure',
content: truncate(redact(message), 50_000),
contentType: 'text/plain',
});
await updateStatus(
jobId,
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
{ error: truncate(redact(message), 10_000) },
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
};
export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) {
try {
const claim = await client.action(api.agentJobsNode.claimNextForWorker, {
workerId: env.workerId,
workerToken: env.workerToken,
});
if (!claim) {
await sleep(env.pollMs);
continue;
}
await runClaim(claim);
} catch (error) {
console.error(error);
await sleep(env.pollMs);
}
}
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@spoon/tsconfig/base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src", "eslint.config.ts", "vitest.config.ts"],
"exclude": ["node_modules"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import { nodeProject } from '@spoon/vitest-config';
export default defineConfig({
test: {
projects: [
nodeProject('unit', ['tests/unit/**/*.test.ts']),
nodeProject('integration', ['tests/integration/**/*.test.ts']),
nodeProject('component', ['tests/component/**/*.test.ts']),
],
},
});
+6 -1
View File
@@ -120,7 +120,12 @@ const AgentsPage = () => {
<div key={request._id} className='border-border border p-4'> <div key={request._id} className='border-border border p-4'>
<p className='font-medium'>{request.prompt}</p> <p className='font-medium'>{request.prompt}</p>
<p className='text-muted-foreground mt-1 text-sm'> <p className='text-muted-foreground mt-1 text-sm'>
{request.status.replaceAll('_', ' ')} {request.status.replaceAll('_', ' ')} ·{' '}
{(request.requestType ?? 'future_code_change').replaceAll(
'_',
' ',
)}{' '}
· {request.source ?? 'user'}
</p> </p>
</div> </div>
))} ))}
+59 -6
View File
@@ -3,8 +3,15 @@
import Link from 'next/link'; import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card'; import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card'; import { SpoonCard } from '@/components/spoons/spoon-card';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { Bot, GitBranch, GitPullRequest, RefreshCw } from 'lucide-react'; import {
Bot,
GitBranch,
GitPullRequest,
RefreshCw,
ShieldCheck,
} from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
@@ -14,12 +21,18 @@ const DashboardPage = () => {
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const agentRequests = const agentRequests =
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? []; useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
const activeSpoons = spoons.filter( const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active', (spoon) => spoon.status === 'active',
).length; ).length;
const needsReview = syncRuns.filter( const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
(run) => run.status === 'needs_review', const diverged = spoons.filter(
(spoon) => spoon.syncStatus === 'diverged',
).length; ).length;
const openPullRequests = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
0,
);
return ( return (
<main className='space-y-6'> <main className='space-y-6'>
@@ -49,9 +62,9 @@ const DashboardPage = () => {
icon={GitPullRequest} icon={GitPullRequest}
/> />
<MetricCard <MetricCard
label='Needs review' label='Behind upstream'
value={needsReview} value={behind}
note='Upstream updates' note={`${diverged} diverged`}
icon={RefreshCw} icon={RefreshCw}
/> />
<MetricCard <MetricCard
@@ -60,8 +73,19 @@ const DashboardPage = () => {
note='Queued and recent' note='Queued and recent'
icon={Bot} icon={Bot}
/> />
<MetricCard
label='Upstream commits'
value={openPullRequests}
note='Waiting across Spoons'
icon={ShieldCheck}
/>
</div> </div>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
</section>
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<section className='space-y-3'> <section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent Spoons</h2> <h2 className='text-lg font-semibold'>Recent Spoons</h2>
@@ -112,6 +136,35 @@ const DashboardPage = () => {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card className='mt-4 shadow-none'>
<CardHeader>
<CardTitle className='text-base'>AI reviews</CardTitle>
</CardHeader>
<CardContent>
{aiReviews.length ? (
<div className='space-y-3'>
{aiReviews.map((review) => (
<div
key={review._id}
className='border-border border p-3 text-sm'
>
<p className='font-medium capitalize'>
{review.risk} risk
</p>
<p className='text-muted-foreground'>
{review.outputSummary ?? review.inputSummary}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground text-sm'>
OpenAI compatibility reviews will appear here after you run
them on a Spoon.
</p>
)}
</CardContent>
</Card>
</section> </section>
</div> </div>
</main> </main>
@@ -0,0 +1,23 @@
import { GitHubConnectClient } from '@/components/github/github-connect-client';
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ installation_id?: string }>;
}) {
const params = await searchParams;
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>
Connect GitHub
</h1>
<p className='text-muted-foreground mt-2 max-w-2xl'>
Spoon stores the GitHub App installation ID and uses short-lived
installation tokens for repository automation.
</p>
</div>
<GitHubConnectClient installationId={params.installation_id} />
</main>
);
}
@@ -0,0 +1,16 @@
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
const AiSettingsPage = () => (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure the OpenAI key, review model, and thinking level used for
compatibility reviews.
</p>
</div>
<OpenAiStatusPanel />
</section>
);
export default AiSettingsPage;
@@ -0,0 +1,15 @@
import { GithubIntegrationPanel } from '@/components/integrations/github-integration-panel';
const IntegrationsPage = () => (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Integrations</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Provider access used by Spoon maintenance workflows.
</p>
</div>
<GithubIntegrationPanel />
</section>
);
export default IntegrationsPage;
@@ -0,0 +1,54 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, Github, Shield, User } from 'lucide-react';
import { cn } from '@spoon/ui';
const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai', label: 'AI', icon: Brain },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
const SettingsLayout = ({ children }: { children: ReactNode }) => {
const pathname = usePathname();
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 border-b pb-5 lg:flex-row lg:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Settings</h1>
<p className='text-muted-foreground mt-2'>
Account, provider, AI, and security controls for this Spoon
workspace.
</p>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[13rem_1fr]'>
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 xl:flex-col xl:self-start'>
{settingsItems.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'hover:bg-muted flex min-w-fit items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
pathname === href
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className='size-4' />
{label}
</Link>
))}
</nav>
<div className='min-w-0'>{children}</div>
</div>
</main>
);
};
export default SettingsLayout;
@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation';
const SettingsPage = () => {
redirect('/settings/profile');
};
export default SettingsPage;
@@ -0,0 +1,42 @@
'use server';
import {
AvatarUpload,
ProfileHeader,
ResetPasswordForm,
UserInfoForm,
} from '@/components/layout/auth/profile';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, Separator } from '@spoon/ui';
const SettingsProfilePage = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser, {});
const preloadedUserProvider = await preloadQuery(
api.auth.getUserProvider,
{},
);
return (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Profile</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Manage your identity, avatar, and account email.
</p>
</div>
<Card className='shadow-none'>
<ProfileHeader />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator className='my-6' />
<UserInfoForm
preloadedUser={preloadedUser}
preloadedProvider={preloadedUserProvider}
/>
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
</Card>
</section>
);
};
export default SettingsProfilePage;
@@ -0,0 +1,19 @@
import { SignOutForm } from '@/components/layout/auth/profile';
import { Card } from '@spoon/ui';
const SecuritySettingsPage = () => (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Security</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Session controls and security-sensitive account actions.
</p>
</div>
<Card className='shadow-none'>
<SignOutForm />
</Card>
</section>
);
export default SecuritySettingsPage;
@@ -0,0 +1,281 @@
'use client';
import { useParams } from 'next/navigation';
import { AgentJobList } from '@/components/agents/agent-job-list';
import { AgentRequestForm } from '@/components/agents/agent-request-form';
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
import { useQuery } from 'convex/react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@spoon/ui';
const SpoonDetailPage = () => {
const params = useParams<{ spoonId: string }>();
const spoonId = params.spoonId as Id<'spoons'>;
const details = useQuery(api.spoons.getDetails, { spoonId });
const upstreamCommits =
useQuery(api.spoonCommits.listForSpoon, {
spoonId,
side: 'upstream',
limit: 100,
}) ?? [];
const forkCommits =
useQuery(api.spoonCommits.listForSpoon, {
spoonId,
side: 'fork',
limit: 100,
}) ?? [];
const pullRequests =
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
const reviews =
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
const syncRuns =
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
spoonId,
});
const agentJobs =
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
}
return (
<main className='space-y-6'>
<SpoonDetailHeader spoon={details.spoon} state={details.state} />
<SpoonMetrics
spoon={details.spoon}
state={details.state}
latestReview={details.latestReview}
/>
{details.spoon.lastError ? (
<Card className='border-destructive shadow-none'>
<CardContent className='p-4 text-sm'>
{details.spoon.lastError}
</CardContent>
</Card>
) : null}
<Tabs defaultValue='overview' className='flex flex-col gap-5'>
<TabsList
variant='line'
className='border-border flex h-auto w-full justify-start overflow-x-auto rounded-none border-b p-0'
>
<TabsTrigger className='h-9 flex-none px-3' value='overview'>
Overview
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='upstream'>
Upstream
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='fork'>
Fork changes
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
Pull requests
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
AI review
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
Agent work
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
Activity
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='settings'>
Settings
</TabsTrigger>
</TabsList>
<TabsContent value='overview' className='space-y-4'>
<div className='grid gap-4 xl:grid-cols-[1.15fr_0.85fr]'>
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Repository health</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 text-sm md:grid-cols-2'>
<div>
<p className='text-muted-foreground'>Drift state</p>
<p className='mt-1 text-xl font-semibold capitalize'>
{(
details.state?.status ??
details.spoon.syncStatus ??
'unknown'
).replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground'>Default branches</p>
<p className='mt-1 font-medium'>
{details.state?.upstreamDefaultBranch ??
details.spoon.upstreamDefaultBranch}{' '}
{' '}
{details.state?.forkDefaultBranch ??
details.spoon.forkDefaultBranch ??
details.spoon.upstreamDefaultBranch}
</p>
</div>
<div>
<p className='text-muted-foreground'>Merge base</p>
<p className='mt-1 truncate font-mono text-xs'>
{details.state?.mergeBaseSha ??
details.spoon.lastMergeBaseCommit ??
'Unknown'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Cadence</p>
<p className='mt-1 font-medium capitalize'>
{details.spoon.syncCadence}
</p>
</div>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Latest AI review</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
{details.latestReview ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div>
<p className='text-muted-foreground'>Risk</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.risk}
</p>
</div>
<div>
<p className='text-muted-foreground'>Action</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.recommendedAction.replaceAll(
'_',
' ',
)}
</p>
</div>
</div>
<p className='text-muted-foreground'>
{details.latestReview.outputSummary ??
details.latestReview.inputSummary}
</p>
</>
) : (
<p className='text-muted-foreground'>
Run a refresh and AI review to get a compatibility summary
for upstream changes.
</p>
)}
</CardContent>
</Card>
<SpoonClonePanel spoon={details.spoon} />
</div>
<div className='grid gap-4 xl:grid-cols-2'>
<section className='space-y-3'>
<div>
<h2 className='text-base font-semibold'>Upstream waiting</h2>
<p className='text-muted-foreground text-sm'>
Commits upstream has that your fork does not.
</p>
</div>
<SpoonCommitList
commits={upstreamCommits.slice(0, 5)}
empty='No upstream-only commits are cached. Refresh from GitHub to check drift.'
/>
</section>
<section className='space-y-3'>
<div>
<h2 className='text-base font-semibold'>Fork changes</h2>
<p className='text-muted-foreground text-sm'>
Custom commits Spoon should preserve during maintenance.
</p>
</div>
<SpoonCommitList
commits={forkCommits.slice(0, 5)}
empty='No fork-only commits are cached.'
/>
</section>
</div>
</TabsContent>
<TabsContent value='upstream'>
<SpoonCommitList
commits={upstreamCommits}
empty='No upstream changes are waiting, or this Spoon has not been refreshed yet.'
/>
</TabsContent>
<TabsContent value='fork'>
<SpoonCommitList
commits={forkCommits}
empty='No fork-only commits are cached. Your customizations will appear here after refresh.'
/>
</TabsContent>
<TabsContent value='pulls'>
<SpoonPrList pullRequests={pullRequests} />
</TabsContent>
<TabsContent value='ai' className='space-y-4'>
<SpoonAiReviewPanel
latestReview={details.latestReview}
reviews={reviews}
/>
</TabsContent>
<TabsContent value='agent' className='space-y-4'>
<AgentRequestForm
spoon={details.spoon}
agentSettings={agentSettings}
/>
<AgentJobList jobs={agentJobs} />
</TabsContent>
<TabsContent value='activity'>
<SpoonActivityTimeline
syncRuns={syncRuns}
reviews={reviews}
requests={agentRequests}
/>
</TabsContent>
<TabsContent value='settings' className='space-y-4'>
<SpoonSettingsForm
spoon={details.spoon}
settings={details.settings}
/>
<SpoonAgentSettingsForm
spoon={details.spoon}
settings={agentSettings}
/>
<SpoonSecretsForm spoonId={spoonId} />
</TabsContent>
</Tabs>
</main>
);
};
export default SpoonDetailPage;
+14 -2
View File
@@ -1,3 +1,5 @@
import { GitHubConnectionPanel } from '@/components/github/github-connection-panel';
import { GitHubForkForm } from '@/components/spoons/github-fork-form';
import { NewSpoonForm } from '@/components/spoons/new-spoon-form'; import { NewSpoonForm } from '@/components/spoons/new-spoon-form';
const NewSpoonPage = () => ( const NewSpoonPage = () => (
@@ -5,8 +7,18 @@ const NewSpoonPage = () => (
<div> <div>
<h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1> <h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1>
<p className='text-muted-foreground mt-2 max-w-2xl'> <p className='text-muted-foreground mt-2 max-w-2xl'>
Create a provider-neutral managed fork record. This does not call a Git Connect GitHub to create real forks, or add an existing managed fork
provider yet; it prepares the dashboard surface for future automation. manually.
</p>
</div>
<GitHubConnectionPanel />
<GitHubForkForm />
<div>
<h2 className='text-xl font-semibold tracking-normal'>
Add an existing fork manually
</h2>
<p className='text-muted-foreground mt-2 max-w-2xl text-sm'>
Use this path for non-GitHub providers or forks that already exist.
</p> </p>
</div> </div>
<NewSpoonForm /> <NewSpoonForm />
+5
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
@@ -76,6 +77,10 @@ const UpdatesPage = () => {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
</section>
</main> </main>
); );
}; };
+3 -48
View File
@@ -1,51 +1,6 @@
'use server'; import { redirect } from 'next/navigation';
import { const Profile = () => {
AvatarUpload, redirect('/settings/profile');
ProfileHeader,
ResetPasswordForm,
SignOutForm,
UserInfoForm,
} from '@/components/layout/auth/profile';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, Separator } from '@spoon/ui';
const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser, {});
const preloadedUserProvider = await preloadQuery(
api.auth.getUserProvider,
{},
);
return (
<main className='container mx-auto px-4 py-12 md:py-16'>
<div className='mx-auto max-w-3xl'>
{/* Page Header */}
<div className='mb-8 text-center'>
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
Your Profile
</h1>
<p className='text-muted-foreground'>
Manage your personal information and preferences
</p>
</div>
{/* Profile Card */}
<Card className='border-border/40'>
<ProfileHeader />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator className='my-6' />
<UserInfoForm
preloadedUser={preloadedUser}
preloadedProvider={preloadedUserProvider}
/>
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
<Separator className='my-6' />
<SignOutForm />
</Card>
</div>
</main>
);
}; };
export default Profile; export default Profile;
+8 -3
View File
@@ -3,7 +3,10 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AuthentikSignInButton } from '@/components/layout/auth/buttons'; import {
AuthentikSignInButton,
GitHubSignInButton,
} from '@/components/layout/auth/buttons';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { ConvexError } from 'convex/values'; import { ConvexError } from 'convex/values';
@@ -342,7 +345,8 @@ const SignIn = () => {
<Separator className='ml-3 py-0.5' /> <Separator className='ml-3 py-0.5' />
</div> </div>
</div> </div>
<div className='mt-3 flex justify-center'> <div className='mt-3 flex flex-col items-center gap-3'>
<GitHubSignInButton />
<AuthentikSignInButton /> <AuthentikSignInButton />
</div> </div>
</CardContent> </CardContent>
@@ -450,7 +454,8 @@ const SignIn = () => {
<Separator className='ml-3 py-0.5' /> <Separator className='ml-3 py-0.5' />
</div> </div>
</div> </div>
<div className='mt-3 flex justify-center'> <div className='mt-3 flex flex-col items-center gap-3'>
<GitHubSignInButton type='signUp' />
<AuthentikSignInButton type='signUp' /> <AuthentikSignInButton type='signUp' />
</div> </div>
</CardContent> </CardContent>
+7 -17
View File
@@ -7,9 +7,6 @@ import { Geist, Geist_Mono } from 'next/font/google';
import '@/app/styles.css'; import '@/app/styles.css';
import { useEffect } from 'react'; import { useEffect } from 'react';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
import { env } from '@/env'; import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata'; import { generateMetadata } from '@/lib/metadata';
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
@@ -59,20 +56,13 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<ConvexClientProvider> <main className='flex min-h-screen flex-col items-center justify-center gap-4 p-6'>
<main className='flex min-h-screen flex-col items-center'> <NextError statusCode={0} />
<Header /> {reset !== undefined && (
<NextError statusCode={0} /> <Button onClick={() => reset()}>Try Again</Button>
{reset !== undefined && ( )}
<Button onClick={() => reset()}>Try Again</Button> <Toaster />
)} </main>
<Toaster />
<Footer />
</main>
<main className='flex min-h-[90vh] flex-col items-center'>
<Toaster />
</main>
</ConvexClientProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
@@ -0,0 +1,51 @@
'use client';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
export const AgentArtifactViewer = ({
artifacts,
}: {
artifacts: Doc<'agentJobArtifacts'>[];
}) => {
if (!artifacts.length) {
return (
<p className='text-muted-foreground text-sm'>
No artifacts captured yet.
</p>
);
}
return (
<div className='space-y-3'>
{artifacts.map((artifact) => (
<section key={artifact._id} className='border-border rounded-md border'>
<div className='flex items-center justify-between gap-3 border-b p-3'>
<div>
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
</div>
<Button
type='button'
variant='outline'
size='icon'
aria-label='Copy artifact'
onClick={async () => {
await navigator.clipboard.writeText(artifact.content);
toast.success('Artifact copied.');
}}
>
<Copy className='size-4' />
</Button>
</div>
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
{artifact.content}
</pre>
</section>
))}
</div>
);
};
@@ -0,0 +1,47 @@
'use client';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
const formatTime = (value: number) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
export const AgentEventLog = ({
events,
}: {
events: Doc<'agentJobEvents'>[];
}) => {
if (!events.length) {
return (
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
);
}
return (
<div className='divide-border overflow-hidden rounded-md border'>
{events.map((event) => (
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-mono text-xs uppercase'>{event.phase}</span>
<span className='text-muted-foreground text-xs'>
{formatTime(event.createdAt)}
</span>
<span className='text-muted-foreground text-xs capitalize'>
{event.level}
</span>
</div>
<p className='whitespace-pre-wrap'>{event.message}</p>
{event.metadata ? (
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
{event.metadata}
</pre>
) : null}
</div>
))}
</div>
);
};
@@ -0,0 +1,66 @@
'use client';
import { useQuery } from 'convex/react';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import { AgentArtifactViewer } from './agent-artifact-viewer';
import { AgentEventLog } from './agent-event-log';
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
const events =
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
const artifacts =
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Job details</CardTitle>
</CardHeader>
<CardContent className='space-y-5'>
<div className='grid gap-3 text-sm md:grid-cols-3'>
<div>
<p className='text-muted-foreground text-xs'>Status</p>
<p className='font-medium capitalize'>
{job.status.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Branch</p>
<p className='font-mono text-xs'>{job.workBranch}</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Model</p>
<p className='font-medium'>{job.model}</p>
</div>
</div>
{job.pullRequestUrl ? (
<a
href={job.pullRequestUrl}
target='_blank'
rel='noreferrer'
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
>
Open draft PR #{job.pullRequestNumber}
</a>
) : null}
{job.error ? (
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
{job.error}
</pre>
) : null}
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>Events</h3>
<AgentEventLog events={events} />
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>Artifacts</h3>
<AgentArtifactViewer artifacts={artifacts} />
</section>
</CardContent>
</Card>
);
};
@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { ExternalLink, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Badge, Button } from '@spoon/ui';
import { AgentJobDetail } from './agent-job-detail';
const formatTime = (value: number) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
const cancel = useMutation(api.agentJobs.cancel);
const [selectedJobId, setSelectedJobId] = useState<string | null>(
jobs[0]?._id ?? null,
);
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
if (!jobs.length) {
return (
<div className='border-border rounded-md border p-5'>
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
<p className='text-muted-foreground mt-1 text-sm'>
Queue a job to have Spoon open a draft PR against this fork.
</p>
</div>
);
}
return (
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
<div className='divide-border overflow-hidden rounded-md border'>
{jobs.map((job) => (
<button
key={job._id}
type='button'
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
data-selected={job._id === selectedJob?._id}
onClick={() => setSelectedJobId(job._id)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>{job.prompt}</p>
<p className='text-muted-foreground mt-1 font-mono text-xs'>
{job.workBranch}
</p>
</div>
<Badge variant='outline' className='capitalize'>
{job.status.replaceAll('_', ' ')}
</Badge>
</div>
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
<span>{formatTime(job.createdAt)}</span>
{job.pullRequestUrl ? (
<a
href={job.pullRequestUrl}
target='_blank'
rel='noreferrer'
className='text-primary inline-flex items-center gap-1'
>
PR <ExternalLink className='size-3' />
</a>
) : null}
</div>
</button>
))}
</div>
{selectedJob ? (
<div className='space-y-3'>
{[
'queued',
'claimed',
'preparing',
'running',
'checks_running',
].includes(selectedJob.status) ? (
<Button
type='button'
variant='outline'
onClick={async () => {
try {
await cancel({ jobId: selectedJob._id });
toast.success('Agent job cancelled.');
} catch (error) {
console.error(error);
toast.error('Could not cancel job.');
}
}}
>
<XCircle className='size-4' />
Cancel job
</Button>
) : null}
<AgentJobDetail job={selectedJob} />
</div>
) : null}
</div>
);
};
@@ -0,0 +1,145 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Textarea,
} from '@spoon/ui';
import { SecretSelector } from './secret-selector';
type AgentSettings = {
defaultBaseBranch?: string;
agentModel: string;
reasoningEffort: string;
};
export const AgentRequestForm = ({
spoon,
agentSettings,
}: {
spoon: Doc<'spoons'>;
agentSettings?: AgentSettings | null;
}) => {
const secrets = useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id,
});
const createRequest = useMutation(api.agentRequests.create);
const createJob = useMutation(api.agentJobs.createFromRequest);
const [prompt, setPrompt] = useState('');
const [baseBranch, setBaseBranch] = useState(
agentSettings?.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
const [requestedBranchName, setRequestedBranchName] = useState('');
const [selectedSecretIds, setSelectedSecretIds] = useState<
Id<'spoonSecrets'>[]
>([]);
const [submitting, setSubmitting] = useState(false);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
const requestId = await createRequest({
spoonId: spoon._id,
prompt,
targetBranch: baseBranch,
});
await createJob({
requestId,
selectedSecretIds,
baseBranch,
requestedBranchName: requestedBranchName || undefined,
});
setPrompt('');
setRequestedBranchName('');
setSelectedSecretIds([]);
toast.success('Agent job queued.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent job.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='flex items-center gap-2 text-base'>
<Bot className='size-4' />
Request agent work
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className='space-y-4'>
<div className='grid gap-2'>
<Label htmlFor='agentPrompt'>Prompt</Label>
<Textarea
id='agentPrompt'
required
minLength={12}
value={prompt}
placeholder='Update this fork to use Authentik as the sole Auth.js provider.'
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='baseBranch'>Base branch</Label>
<Input
id='baseBranch'
value={baseBranch}
onChange={(event) => setBaseBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='workBranch'>Work branch</Label>
<Input
id='workBranch'
value={requestedBranchName}
placeholder='Auto-generated if blank'
onChange={(event) => setRequestedBranchName(event.target.value)}
/>
</div>
</div>
<div className='grid gap-2'>
<Label>Secrets exposed to this job</Label>
<SecretSelector
secrets={secrets ?? []}
selectedSecretIds={selectedSecretIds}
onChange={setSelectedSecretIds}
/>
</div>
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
<span>
Model:{' '}
<strong>{agentSettings?.agentModel ?? 'gpt-5.1-codex'}</strong>
</span>
<span>
Reasoning:{' '}
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
</span>
</div>
<Button type='submit' disabled={submitting}>
{submitting ? 'Queueing...' : 'Queue agent job'}
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,60 @@
'use client';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Checkbox, Label } from '@spoon/ui';
type Secret = {
_id: Id<'spoonSecrets'>;
name: string;
valuePreview?: string;
description?: string;
};
export const SecretSelector = ({
secrets,
selectedSecretIds,
onChange,
}: {
secrets: Secret[];
selectedSecretIds: Id<'spoonSecrets'>[];
onChange: (secretIds: Id<'spoonSecrets'>[]) => void;
}) => {
const toggle = (secretId: Id<'spoonSecrets'>, checked: boolean) => {
onChange(
checked
? [...selectedSecretIds, secretId]
: selectedSecretIds.filter((id) => id !== secretId),
);
};
if (!secrets.length) {
return (
<p className='text-muted-foreground text-sm'>
No Spoon secrets saved. Add project secrets in Settings when a job needs
environment variables.
</p>
);
}
return (
<div className='grid gap-2'>
{secrets.map((secret) => (
<label
key={secret._id}
className='border-border flex items-start gap-3 rounded-md border p-3'
>
<Checkbox
checked={selectedSecretIds.includes(secret._id)}
onCheckedChange={(checked) => toggle(secret._id, checked === true)}
/>
<span className='grid gap-1'>
<Label className='font-mono text-xs'>{secret.name}</Label>
<span className='text-muted-foreground text-xs'>
{secret.description ?? secret.valuePreview ?? 'Configured'}
</span>
</span>
</label>
))}
</div>
);
};
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Bot, GitBranch, LayoutDashboard, RefreshCw, User } from 'lucide-react'; import { GitBranch, LayoutDashboard, RefreshCw, Settings } from 'lucide-react';
import { cn } from '@spoon/ui'; import { cn } from '@spoon/ui';
@@ -11,8 +11,7 @@ const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/spoons', label: 'My Spoons', icon: GitBranch }, { href: '/spoons', label: 'My Spoons', icon: GitBranch },
{ href: '/updates', label: 'Updates', icon: RefreshCw }, { href: '/updates', label: 'Updates', icon: RefreshCw },
{ href: '/agents', label: 'Agents', icon: Bot }, { href: '/settings/profile', label: 'Settings', icon: Settings },
{ href: '/profile', label: 'Profile', icon: User },
]; ];
export const AppShell = ({ children }: { children: ReactNode }) => { export const AppShell = ({ children }: { children: ReactNode }) => {
@@ -0,0 +1,51 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent } from '@spoon/ui';
export const GitHubConnectClient = ({
installationId,
}: {
installationId?: string;
}) => {
const connectInstallation = useMutation(api.github.connectInstallation);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>(
installationId ? 'saving' : 'idle',
);
const hasSubmitted = useRef(false);
useEffect(() => {
if (!installationId || hasSubmitted.current) return;
hasSubmitted.current = true;
void connectInstallation({ installationId })
.then(() => setStatus('saved'))
.catch((error) => {
console.error(error);
setStatus('failed');
});
}, [connectInstallation, installationId]);
return (
<Card className='shadow-none'>
<CardContent className='p-6'>
<p className='text-lg font-medium'>GitHub App connection</p>
<p className='text-muted-foreground mt-2'>
{status === 'idle'
? 'GitHub did not provide an installation ID in this callback.'
: status === 'saving'
? 'Saving your GitHub installation...'
: status === 'saved'
? 'GitHub is connected. You can create forks from Spoon now.'
: 'Could not save this GitHub installation.'}
</p>
<Button className='mt-5' asChild>
<Link href='/spoons/new'>Create a Spoon</Link>
</Button>
</CardContent>
</Card>
);
};
@@ -0,0 +1,63 @@
'use client';
import Link from 'next/link';
import { useAction, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Github, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent } from '@spoon/ui';
const syncConfiguredInstallationRef = makeFunctionReference<
'action',
Record<string, never>,
string
>('githubNode:syncConfiguredInstallation');
export const GitHubConnectionPanel = () => {
const connection = useQuery(api.github.getConnection, {});
const installUrl = useQuery(api.github.getInstallUrl, {});
const syncConfiguredInstallation = useAction(syncConfiguredInstallationRef);
const handleSync = async () => {
try {
await syncConfiguredInstallation({});
toast.success('GitHub installation connected.');
} catch (error) {
console.error(error);
toast.error('Could not connect the configured GitHub installation.');
}
};
return (
<Card className='shadow-none'>
<CardContent className='flex flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between'>
<div className='flex gap-3'>
<div className='bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg'>
<Github className='size-5' />
</div>
<div>
<p className='font-medium'>GitHub App connection</p>
<p className='text-muted-foreground mt-1 text-sm'>
{connection
? `Connected to ${connection.displayName}`
: 'Install or sync the Spoon GitHub App before creating forks.'}
</p>
</div>
</div>
<div className='flex flex-wrap gap-2'>
{installUrl ? (
<Button asChild variant='outline'>
<Link href={installUrl}>Install GitHub App</Link>
</Button>
) : null}
<Button type='button' onClick={handleSync}>
<RefreshCw className='size-4' />
Sync configured installation
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,84 @@
'use client';
import { useAction, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Github, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const listReposRef = makeFunctionReference<
'action',
Record<string, never>,
{
id: number;
name: string;
fullName: string;
owner: string;
private: boolean;
fork: boolean;
url: string;
defaultBranch: string;
description?: string;
}[]
>('githubNode:listInstallationRepositories');
export const GithubIntegrationPanel = () => {
const connection = useQuery(api.github.getConnection, {});
const installUrl = useQuery(api.github.getInstallUrl, {});
const listRepos = useAction(listReposRef);
const refresh = async () => {
try {
const repos = await listRepos({});
toast.success(`GitHub can access ${repos.length} repositories.`);
} catch (error) {
console.error(error);
toast.error('Could not list GitHub repositories.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Github className='size-4' />
GitHub App
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{connection ? (
<div className='grid gap-2 text-sm'>
<div>
<p className='text-muted-foreground'>Connected account</p>
<p className='font-medium'>{connection.displayName}</p>
</div>
<div>
<p className='text-muted-foreground'>Installation ID</p>
<p className='font-mono text-xs'>{connection.installationId}</p>
</div>
</div>
) : (
<p className='text-muted-foreground text-sm'>
Install the GitHub App to let Spoon create forks and refresh
repository state.
</p>
)}
<div className='flex flex-wrap gap-2'>
{installUrl ? (
<Button asChild>
<a href={installUrl} target='_blank' rel='noreferrer'>
Configure GitHub App
</a>
</Button>
) : null}
<Button variant='outline' onClick={refresh} disabled={!connection}>
<RefreshCw className='size-4' />
Check repository access
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,197 @@
'use client';
import { useEffect, useState } from 'react';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveOpenAiSettingsRef = makeFunctionReference<
'action',
{ apiKey: string; model: string; reasoningEffort: ReasoningEffort },
{ success: true }
>('aiSettingsNode:saveOpenAiSettings');
const modelOptions = [
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
];
const reasoningOptions: { value: ReasoningEffort; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' },
];
export const OpenAiStatusPanel = () => {
const status = useQuery(api.integrations.getStatus, {});
const settings = useQuery(api.aiSettings.getMine, {});
const saveOpenAiSettings = useAction(saveOpenAiSettingsRef);
const updatePreferences = useMutation(api.aiSettings.updatePreferences);
const removeOpenAiKey = useMutation(api.aiSettings.removeOpenAiKey);
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('gpt-5.5');
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!settings) return;
setModel(settings.model);
setReasoningEffort(settings.reasoningEffort as ReasoningEffort);
}, [settings]);
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
if (apiKey.trim()) {
await saveOpenAiSettings({
apiKey,
model,
reasoningEffort,
});
setApiKey('');
} else {
await updatePreferences({
model,
reasoningEffort,
});
}
toast.success('OpenAI settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save OpenAI settings.');
} finally {
setSubmitting(false);
}
};
const remove = async () => {
try {
await removeOpenAiKey({});
toast.success('OpenAI API key removed.');
} catch (error) {
console.error(error);
toast.error('Could not remove OpenAI API key.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Brain className='size-4' />
OpenAI reviews
</CardTitle>
</CardHeader>
<CardContent className='space-y-2 text-sm'>
<p className='text-muted-foreground'>
Compatibility reviews use your own OpenAI API key. Spoon encrypts the
key before storing it and only shows a short preview.
</p>
<div>
<p className='text-muted-foreground'>Encryption</p>
<p className='font-medium'>
{status?.encryptionConfigured ? 'Configured' : 'Missing server key'}
</p>
</div>
<div>
<p className='text-muted-foreground'>OpenAI API key</p>
<p className='font-medium'>
{settings?.configured ? settings.apiKeyPreview : 'Not configured'}
</p>
</div>
<form onSubmit={save} className='space-y-4 pt-2'>
<div className='grid gap-2'>
<Label htmlFor='openai-api-key'>API key</Label>
<Input
id='openai-api-key'
type='password'
value={apiKey}
placeholder={
settings?.configured
? 'Leave blank to keep current key'
: 'sk-...'
}
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className='grid gap-2 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Review model</Label>
<Select value={model} onValueChange={(value) => setModel(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(value as ReasoningEffort)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button type='submit' disabled={submitting}>
{submitting ? 'Saving...' : 'Save OpenAI settings'}
</Button>
<Button
type='button'
variant='outline'
onClick={remove}
disabled={!settings?.configured}
>
Remove key
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
@@ -1,7 +1,7 @@
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { KeyRound } from 'lucide-react'; import { Github, KeyRound } from 'lucide-react';
import type { buttonVariants } from '@spoon/ui'; import type { buttonVariants } from '@spoon/ui';
import { Button } from '@spoon/ui'; import { Button } from '@spoon/ui';
@@ -33,3 +33,21 @@ export const AuthentikSignInButton = ({
</Button> </Button>
); );
}; };
export const GitHubSignInButton = ({ buttonProps, type = 'signIn' }: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
variant='outline'
onClick={() => signIn('github')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='my-auto flex flex-row items-center gap-2'>
<Github className='size-5' />
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with GitHub</p>
</div>
</Button>
);
};
@@ -1 +1 @@
export { AuthentikSignInButton } from './gibs-auth'; export { AuthentikSignInButton, GitHubSignInButton } from './gibs-auth';
@@ -3,6 +3,7 @@
import type { Preloaded } from 'convex/react'; import type { Preloaded } from 'convex/react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { isRemoteImageUrl } from '@/lib/avatar';
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react'; import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react'; import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -48,10 +49,12 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const generateUploadUrl = useMutation(api.files.generateUploadUrl); const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser); const updateUser = useMutation(api.auth.updateUser);
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
const currentImageUrl = useQuery( const currentImageUrl = useQuery(
api.files.getImageUrl, api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip', user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
); );
const avatarUrl = remoteImageUrl ?? currentImageUrl;
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null; const file = event.target.files?.[0] ?? null;
@@ -117,7 +120,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
<BasedAvatar <BasedAvatar
src={currentImageUrl ?? undefined} src={avatarUrl ?? undefined}
fullName={user?.name} fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold' className='h-42 w-42 text-6xl font-semibold'
userIconProps={{ size: 100 }} userIconProps={{ size: 100 }}
@@ -54,6 +54,7 @@ export const UserInfoForm = ({
const providerMap: Record<string, string> = { const providerMap: Record<string, string> = {
unknown: 'Provider', unknown: 'Provider',
authentik: 'Authentik', authentik: 'Authentik',
github: 'GitHub',
}; };
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -2,10 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { isRemoteImageUrl } from '@/lib/avatar';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react'; import { useConvexAuth, useQuery } from 'convex/react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { import {
BasedAvatar, BasedAvatar,
@@ -23,10 +23,12 @@ export const AvatarDropdown = () => {
const { isLoading, isAuthenticated } = useConvexAuth(); const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions(); const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {}); const user = useQuery(api.auth.getUser, {});
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
const currentImageUrl = useQuery( const currentImageUrl = useQuery(
api.files.getImageUrl, api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip', user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
); );
const avatarUrl = remoteImageUrl ?? currentImageUrl;
if (isLoading) { if (isLoading) {
return ( return (
@@ -51,7 +53,7 @@ export const AvatarDropdown = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<BasedAvatar <BasedAvatar
src={currentImageUrl} src={avatarUrl}
fullName={user?.name} fullName={user?.name}
className='h-9 w-9' className='h-9 w-9'
fallbackProps={{ className: 'text-sm font-semibold' }} fallbackProps={{ className: 'text-sm font-semibold' }}
@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAction, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { GitFork } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, Input, Label, Textarea } from '@spoon/ui';
type FormState = {
upstreamOwner: string;
upstreamRepo: string;
forkName: string;
organization: string;
description: string;
};
const initialState: FormState = {
upstreamOwner: '',
upstreamRepo: '',
forkName: '',
organization: '',
description: '',
};
const createForkRef = makeFunctionReference<
'action',
{
upstreamOwner: string;
upstreamRepo: string;
name?: string;
description?: string;
organization?: string;
},
string
>('githubNode:createFork');
const TextField = ({
id,
label,
value,
onChange,
required,
placeholder,
}: {
id: keyof FormState;
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
placeholder?: string;
}) => (
<div className='grid gap-2'>
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
value={value}
required={required}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
export const GitHubForkForm = () => {
const router = useRouter();
const connection = useQuery(api.github.getConnection, {});
const createFork = useAction(createForkRef);
const [form, setForm] = useState<FormState>(initialState);
const [submitting, setSubmitting] = useState(false);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
await createFork({
upstreamOwner: form.upstreamOwner,
upstreamRepo: form.upstreamRepo,
name: form.forkName || undefined,
organization: form.organization || undefined,
description: form.description || undefined,
});
toast.success('Fork created and added as a Spoon.');
router.push('/spoons');
} catch (error) {
console.error(error);
toast.error('Could not create the GitHub fork.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<CardContent className='p-5'>
<div className='mb-5 flex gap-3'>
<div className='bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg'>
<GitFork className='size-5' />
</div>
<div>
<p className='font-medium'>Fork with GitHub</p>
<p className='text-muted-foreground mt-1 text-sm'>
Create a real GitHub fork through the connected GitHub App and
record it as a Spoon.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className='grid gap-5'>
<div className='grid gap-4 md:grid-cols-2'>
<TextField
id='upstreamOwner'
label='Upstream owner'
value={form.upstreamOwner}
required
placeholder='vercel'
onChange={(value) => update('upstreamOwner', value)}
/>
<TextField
id='upstreamRepo'
label='Upstream repository'
value={form.upstreamRepo}
required
placeholder='next.js'
onChange={(value) => update('upstreamRepo', value)}
/>
<TextField
id='forkName'
label='Fork name'
value={form.forkName}
placeholder='Optional custom repository name'
onChange={(value) => update('forkName', value)}
/>
<TextField
id='organization'
label='Organization'
value={form.organization}
placeholder='Optional org target'
onChange={(value) => update('organization', value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
value={form.description}
placeholder='Optional Spoon note'
onChange={(event) => update('description', event.target.value)}
/>
</div>
<div className='flex justify-end'>
<Button type='submit' disabled={!connection || submitting}>
{submitting ? 'Forking...' : 'Create GitHub fork'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,71 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent } from '@spoon/ui';
const formatDate = (value: number) =>
new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value);
export const SpoonActivityTimeline = ({
syncRuns,
reviews,
requests,
}: {
syncRuns: Doc<'syncRuns'>[];
reviews: Doc<'aiReviews'>[];
requests: Doc<'agentRequests'>[];
}) => {
const items = [
...syncRuns.map((item) => ({
id: item._id,
kind: item.kind.replaceAll('_', ' '),
status: item.status,
summary: item.summary ?? item.error ?? 'Sync run recorded.',
time: item.createdAt,
})),
...reviews.map((item) => ({
id: item._id,
kind: 'AI review',
status: item.status,
summary: item.outputSummary ?? item.inputSummary,
time: item.createdAt,
})),
...requests.map((item) => ({
id: item._id,
kind: 'Agent request',
status: item.status,
summary: item.prompt,
time: item.createdAt,
})),
].sort((a, b) => b.time - a.time);
return (
<div className='space-y-3'>
{items.length ? (
items.map((item) => (
<Card key={item.id} className='shadow-none'>
<CardContent className='p-4'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{item.kind}</p>
<Badge variant='outline'>{item.status}</Badge>
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{item.summary}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{formatDate(item.time)}
</p>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Refreshes, AI reviews, and queued requests will build this timeline.
</CardContent>
</Card>
)}
</div>
);
};
@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
type AgentSettings = {
enabled: boolean;
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
checkCommand?: string;
testCommand?: string;
agentModel: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
export const SpoonAgentSettingsForm = ({
spoon,
settings,
}: {
spoon: Doc<'spoons'>;
settings?: AgentSettings | null;
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
settings?.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
const [branchPrefix, setBranchPrefix] = useState(
settings?.branchPrefix ?? 'spoon/agent',
);
const [installCommand, setInstallCommand] = useState(
settings?.installCommand ?? '',
);
const [checkCommand, setCheckCommand] = useState(
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
const [agentModel, setAgentModel] = useState(
settings?.agentModel ?? 'gpt-5.1-codex',
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
>(
settings?.reasoningEffort === 'none'
? 'minimal'
: (settings?.reasoningEffort ?? 'high'),
);
const save = async () => {
try {
await update({
spoonId: spoon._id,
enabled,
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel,
reasoningEffort,
});
toast.success('Agent settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save agent settings.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Bot className='size-4' />
Agent runtime
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
<Switch
id='agentEnabled'
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
<Input
id='defaultBaseBranch'
value={defaultBaseBranch}
onChange={(event) => setDefaultBaseBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='branchPrefix'>Branch prefix</Label>
<Input
id='branchPrefix'
value={branchPrefix}
onChange={(event) => setBranchPrefix(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label>
<Input
id='agentModel'
value={agentModel}
onChange={(event) => setAgentModel(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(
value as 'minimal' | 'low' | 'medium' | 'high' | 'xhigh',
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{efforts.map((effort) => (
<SelectItem key={effort} value={effort}>
{effort}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='installCommand'>Install command</Label>
<Input
id='installCommand'
value={installCommand}
placeholder='bun install'
onChange={(event) => setInstallCommand(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='checkCommand'>Check command</Label>
<Input
id='checkCommand'
value={checkCommand}
placeholder='bun typecheck'
onChange={(event) => setCheckCommand(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='testCommand'>Test command</Label>
<Input
id='testCommand'
value={testCommand}
placeholder='bun test'
onChange={(event) => setTestCommand(event.target.value)}
/>
</div>
</div>
<Button type='button' onClick={save}>
Save agent settings
</Button>
</CardContent>
</Card>
);
};
@@ -0,0 +1,75 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
export const SpoonAiReviewPanel = ({
latestReview,
reviews,
}: {
latestReview?: Doc<'aiReviews'> | null;
reviews: Doc<'aiReviews'>[];
}) => (
<div className='space-y-4'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
</CardHeader>
<CardContent>
{latestReview ? (
<div className='space-y-4'>
<div className='flex flex-wrap gap-2'>
<Badge>{latestReview.risk}</Badge>
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
{latestReview.requiresHumanReview ? (
<Badge variant='secondary'>Human review required</Badge>
) : null}
</div>
<p className='text-sm'>
{latestReview.outputSummary ?? latestReview.inputSummary}
</p>
{latestReview.reasoningSummary ? (
<p className='text-muted-foreground text-sm'>
{latestReview.reasoningSummary}
</p>
) : null}
{latestReview.potentialConflicts?.length ? (
<div>
<p className='text-sm font-medium'>Potential conflicts</p>
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
{latestReview.potentialConflicts.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
) : (
<p className='text-muted-foreground text-sm'>
Run an AI review after a GitHub refresh to get compatibility notes.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Review history</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{reviews.length ? (
reviews.map((review) => (
<div key={review._id} className='border-border border p-3 text-sm'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline'>{review.status}</Badge>
<Badge variant='secondary'>{review.risk}</Badge>
</div>
<p className='mt-2'>
{review.outputSummary ?? review.inputSummary}
</p>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
)}
</CardContent>
</Card>
</div>
);
+19 -4
View File
@@ -1,5 +1,8 @@
import Link from 'next/link';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const formatDate = (value?: number) => const formatDate = (value?: number) =>
value value
@@ -7,15 +10,19 @@ const formatDate = (value?: number) =>
: 'Never'; : 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => ( export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<Card className='shadow-none'> <Card className='hover:border-primary/50 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'> <CardHeader className='flex-row items-start justify-between gap-4'>
<div> <div>
<CardTitle className='text-lg'>{spoon.name}</CardTitle> <CardTitle className='text-lg'>
<Link href={`/spoons/${spoon._id}`} className='hover:underline'>
{spoon.name}
</Link>
</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'> <p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo} {spoon.upstreamOwner}/{spoon.upstreamRepo}
</p> </p>
</div> </div>
<Badge variant='outline'>{spoon.status.replaceAll('_', ' ')}</Badge> <SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
</CardHeader> </CardHeader>
<CardContent className='grid gap-3 text-sm md:grid-cols-2'> <CardContent className='grid gap-3 text-sm md:grid-cols-2'>
<div> <div>
@@ -38,6 +45,14 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<p className='text-muted-foreground'>Last checked</p> <p className='text-muted-foreground'>Last checked</p>
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p> <p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div> </div>
<div>
<p className='text-muted-foreground'>Upstream waiting</p>
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork-only commits</p>
<p className='font-medium'>{spoon.forkAheadBy ?? 0}</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Check, Copy, ExternalLink, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
} from '@spoon/ui';
const RemoteRow = ({
label,
url,
remoteName,
onRemove,
}: {
label: string;
url: string;
remoteName?: string;
onRemove?: () => Promise<void>;
}) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success(`${label} URL copied.`);
window.setTimeout(() => setCopied(false), 1800);
} catch (error) {
console.error(error);
toast.error('Could not copy remote URL.');
}
};
return (
<div className='border-border space-y-2 border-t pt-3 first:border-t-0 first:pt-0'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>{label}</p>
{remoteName ? (
<p className='text-muted-foreground text-xs'>
git remote: {remoteName}
</p>
) : null}
</div>
<div className='flex gap-2'>
<Button type='button' variant='outline' size='icon' onClick={copy}>
{copied ? (
<Check className='size-4' />
) : (
<Copy className='size-4' />
)}
</Button>
<Button type='button' variant='outline' size='icon' asChild>
<a
href={url}
target='_blank'
rel='noreferrer'
aria-label={`Open ${label} repository`}
>
<ExternalLink className='size-4' />
</a>
</Button>
{onRemove ? (
<Button
type='button'
variant='outline'
size='icon'
onClick={() => void onRemove()}
>
<Trash2 className='size-4' />
</Button>
) : null}
</div>
</div>
<Input
readOnly
value={url}
className='font-mono text-xs'
onFocus={(event) => event.currentTarget.select()}
/>
</div>
);
};
export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
const remotes =
useQuery(api.spoonRemotes.listForSpoon, { spoonId: spoon._id }) ?? [];
const createRemote = useMutation(api.spoonRemotes.create);
const removeRemote = useMutation(api.spoonRemotes.remove);
const [label, setLabel] = useState('');
const [remoteName, setRemoteName] = useState('');
const [url, setUrl] = useState('');
const [submitting, setSubmitting] = useState(false);
const cloneUrl = spoon.forkUrl;
const addRemote = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
await createRemote({
spoonId: spoon._id,
label,
url,
remoteName: remoteName || undefined,
});
setLabel('');
setRemoteName('');
setUrl('');
toast.success('Remote added.');
} catch (error) {
console.error(error);
toast.error('Could not add remote.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Clone your fork</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{cloneUrl ? (
<>
<RemoteRow
label='Primary fork'
remoteName='origin'
url={cloneUrl}
/>
<p className='text-muted-foreground text-xs'>
This GitHub fork remains Spoon&apos;s source of truth for upstream
maintenance.
</p>
</>
) : (
<p className='text-muted-foreground text-sm'>
Add fork metadata before Spoon can show a clone URL.
</p>
)}
{remotes.length ? (
<div className='space-y-3 pt-2'>
{remotes.map((remote) => (
<RemoteRow
key={remote._id}
label={remote.label}
remoteName={remote.remoteName}
url={remote.url}
onRemove={async () => {
await removeRemote({ remoteId: remote._id });
toast.success('Remote removed.');
}}
/>
))}
</div>
) : null}
<form
onSubmit={addRemote}
className='border-border space-y-3 border-t pt-4'
>
<div className='grid gap-3 md:grid-cols-[1fr_0.7fr]'>
<div className='grid gap-2'>
<Label htmlFor='remote-label'>Label</Label>
<Input
id='remote-label'
value={label}
placeholder='Gitea mirror'
required
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-name'>Git remote name</Label>
<Input
id='remote-name'
value={remoteName}
placeholder='gitea'
onChange={(event) => setRemoteName(event.target.value)}
/>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-url'>Repository URL</Label>
<Input
id='remote-url'
value={url}
placeholder='https://git.example.com/you/project.git'
required
onChange={(event) => setUrl(event.target.value)}
/>
</div>
<Button type='submit' variant='outline' disabled={submitting}>
<Plus className='size-4' />
{submitting ? 'Adding...' : 'Add remote'}
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,51 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Card, CardContent } from '@spoon/ui';
const shortSha = (sha: string) => sha.slice(0, 7);
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Unknown date';
export const SpoonCommitList = ({
commits,
empty,
}: {
commits: Doc<'spoonCommits'>[];
empty: string;
}) => (
<div className='space-y-3'>
{commits.length ? (
commits.map((commit) => (
<Card key={`${commit.side}-${commit.sha}`} className='shadow-none'>
<CardContent className='flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{commit.message.split('\n')[0]}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{shortSha(commit.sha)} by{' '}
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
{formatDate(commit.committedAt)}
</p>
</div>
{commit.htmlUrl ? (
<Button variant='outline' size='sm' asChild>
<a href={commit.htmlUrl} target='_blank' rel='noreferrer'>
Open
</a>
</Button>
) : null}
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
{empty}
</CardContent>
</Card>
)}
</div>
);
@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useAction } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
const refreshRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{
success: boolean;
status: string;
upstreamAheadBy: number;
forkAheadBy: number;
}
>('githubSync:refreshSpoonGithubState');
const syncRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
unknown
>('githubSync:syncForkWithUpstream');
const reviewRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
>('aiReviewActions:reviewLatestUpstreamChanges');
export const SpoonDetailHeader = ({
spoon,
state,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
}) => {
const refresh = useAction(refreshRef);
const sync = useAction(syncRef);
const review = useAction(reviewRef);
const [busy, setBusy] = useState<string | null>(null);
const canSync =
spoon.provider === 'github' &&
state?.status === 'behind' &&
state.forkAheadBy === 0;
const run = async (label: string, action: () => Promise<unknown>) => {
setBusy(label);
try {
await action();
toast.success(`${label} complete.`);
} catch (error) {
console.error(error);
toast.error(`${label} failed.`);
} finally {
setBusy(null);
}
};
return (
<div className='border-border bg-card flex flex-col justify-between gap-5 rounded-lg border p-5 shadow-sm lg:flex-row lg:items-start'>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-3'>
<h1 className='truncate text-3xl font-semibold tracking-normal'>
{spoon.name}
</h1>
<SpoonStatusBadge
status={state?.status ?? spoon.syncStatus ?? spoon.status}
/>
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm'>
<a
href={spoon.upstreamUrl}
target='_blank'
rel='noreferrer'
className='hover:text-foreground'
>
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
</a>
{spoon.forkUrl ? (
<a
href={spoon.forkUrl}
target='_blank'
rel='noreferrer'
className='hover:text-foreground'
>
Fork: {spoon.forkOwner}/{spoon.forkRepo}
</a>
) : (
<span>Fork metadata missing</span>
)}
</div>
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => run('Refresh', () => refresh({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<RefreshCw className='size-4' />
Refresh
</Button>
<Button
variant='outline'
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<Brain className='size-4' />
Review with AI
</Button>
<Button
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
disabled={Boolean(busy) || !canSync}
>
<RotateCw className='size-4' />
Sync fork
</Button>
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import {
Clock,
GitCommit,
GitPullRequest,
ShieldCheck,
TrendingUp,
} from 'lucide-react';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Card, CardContent } from '@spoon/ui';
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
export const SpoonMetrics = ({
spoon,
state,
latestReview,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
latestReview?: Doc<'aiReviews'> | null;
}) => {
const metrics = [
{
label: 'Upstream waiting',
value: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
icon: TrendingUp,
},
{
label: 'Fork-only commits',
value: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
icon: GitCommit,
},
{
label: 'Open PRs',
value:
(state?.openForkPullRequestCount ?? 0) +
(state?.openUpstreamPullRequestCount ?? 0),
icon: GitPullRequest,
},
{
label: 'Latest AI risk',
value: latestReview?.risk ?? 'unknown',
icon: ShieldCheck,
},
{
label: 'Last check',
value: formatDate(spoon.lastCheckedAt ?? state?.refreshedAt),
icon: Clock,
},
];
return (
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<Card key={metric.label} className='shadow-none'>
<CardContent className='flex items-center justify-between gap-3 p-4'>
<div className='min-w-0'>
<p className='text-muted-foreground text-xs'>{metric.label}</p>
<p className='mt-1 truncate text-lg font-semibold capitalize'>
{metric.value}
</p>
</div>
<div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md'>
<Icon className='size-4' />
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -0,0 +1,48 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Card, CardContent } from '@spoon/ui';
export const SpoonPrList = ({
pullRequests,
}: {
pullRequests: Doc<'spoonPullRequests'>[];
}) => (
<div className='space-y-3'>
{pullRequests.length ? (
pullRequests.map((pullRequest) => (
<Card
key={`${pullRequest.repoFullName}-${pullRequest.githubId}`}
className='shadow-none'
>
<CardContent className='flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>
#{pullRequest.number} {pullRequest.title}
</p>
<Badge variant='outline'>{pullRequest.state}</Badge>
<Badge variant='secondary'>
{pullRequest.scope.replaceAll('_', ' ')}
</Badge>
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{pullRequest.repoFullName}: {pullRequest.headRef} {' '}
{pullRequest.baseRef}
</p>
</div>
<Button variant='outline' size='sm' asChild>
<a href={pullRequest.htmlUrl} target='_blank' rel='noreferrer'>
Open
</a>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Pull requests will appear after the next GitHub refresh.
</CardContent>
</Card>
)}
</div>
);
@@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import { useAction, useMutation, useQuery } from 'convex/react';
import { KeyRound, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
} from '@spoon/ui';
export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
const createSecret = useAction(api.spoonSecretsNode.create);
const removeSecret = useMutation(api.spoonSecrets.remove);
const [name, setName] = useState('');
const [value, setValue] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
try {
await createSecret({
spoonId,
name,
value,
description: description || undefined,
});
setName('');
setValue('');
setDescription('');
toast.success('Spoon secret saved.');
} catch (error) {
console.error(error);
toast.error('Could not save secret.');
} finally {
setSaving(false);
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<KeyRound className='size-4' />
Project secrets
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<form
onSubmit={save}
className='grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]'
>
<div className='grid gap-2'>
<Label htmlFor='secretName'>Name</Label>
<Input
id='secretName'
value={name}
placeholder='AUTHENTIK_CLIENT_ID'
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='secretValue'>Value</Label>
<Input
id='secretValue'
type='password'
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='secretDescription'>Description</Label>
<Input
id='secretDescription'
value={description}
placeholder='Used for local validation'
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className='flex items-end'>
<Button type='submit' disabled={saving}>
Save
</Button>
</div>
</form>
<div className='divide-border overflow-hidden rounded-md border'>
{secrets.length ? (
secrets.map((secret) => (
<div
key={secret._id}
className='flex items-center justify-between gap-3 border-b p-3'
>
<div>
<p className='font-mono text-sm'>{secret.name}</p>
<p className='text-muted-foreground text-xs'>
{secret.description ?? secret.valuePreview ?? 'Configured'}
</p>
</div>
<Button
type='button'
variant='ghost'
size='icon'
aria-label={`Remove ${secret.name}`}
onClick={async () => {
await removeSecret({ secretId: secret._id });
toast.success('Secret removed.');
}}
>
<Trash2 className='size-4' />
</Button>
</div>
))
) : (
<p className='text-muted-foreground p-3 text-sm'>
No secrets saved for this Spoon.
</p>
)}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Label,
Switch,
} from '@spoon/ui';
export const SpoonSettingsForm = ({
spoon,
settings,
}: {
spoon: Doc<'spoons'>;
settings?: Doc<'spoonSettings'> | null;
}) => {
const update = useMutation(api.spoonSettings.update);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(
settings?.autoRefreshEnabled ?? true,
);
const [autoReviewEnabled, setAutoReviewEnabled] = useState(
settings?.autoReviewEnabled ?? true,
);
const [autoSyncEnabled, setAutoSyncEnabled] = useState(
settings?.autoSyncEnabled ?? false,
);
const [requireAiLowRiskForSync, setRequireAiLowRiskForSync] = useState(
settings?.requireAiLowRiskForSync ?? true,
);
const [requireCleanCompareForSync, setRequireCleanCompareForSync] = useState(
settings?.requireCleanCompareForSync ?? true,
);
const save = async () => {
try {
await update({
spoonId: spoon._id,
autoRefreshEnabled,
autoReviewEnabled,
autoSyncEnabled,
requireAiLowRiskForSync,
requireCleanCompareForSync,
});
toast.success('Spoon settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save settings.');
}
};
const rows = [
{
label: 'Auto refresh',
value: autoRefreshEnabled,
onChange: setAutoRefreshEnabled,
},
{
label: 'Auto AI review',
value: autoReviewEnabled,
onChange: setAutoReviewEnabled,
},
{
label: 'Auto sync',
value: autoSyncEnabled,
onChange: setAutoSyncEnabled,
},
{
label: 'Require low AI risk for sync',
value: requireAiLowRiskForSync,
onChange: setRequireAiLowRiskForSync,
},
{
label: 'Require clean compare for sync',
value: requireCleanCompareForSync,
onChange: setRequireCleanCompareForSync,
},
];
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Maintenance settings</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div>
<p className='text-muted-foreground text-xs'>Sync cadence</p>
<p className='font-medium'>{spoon.syncCadence}</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Maintenance mode</p>
<p className='font-medium'>
{spoon.maintenanceMode.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Production ref</p>
<p className='font-medium'>
{spoon.productionRefStrategy.replaceAll('_', ' ')}
</p>
</div>
</div>
<div className='space-y-3'>
{rows.map((row) => (
<div
key={row.label}
className='flex items-center justify-between gap-4 border-t pt-3'
>
<Label>{row.label}</Label>
<Switch checked={row.value} onCheckedChange={row.onChange} />
</div>
))}
</div>
<Button onClick={save}>Save settings</Button>
</CardContent>
</Card>
);
};
@@ -0,0 +1,45 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge } from '@spoon/ui';
type Status =
| NonNullable<Doc<'spoons'>['syncStatus']>
| Doc<'spoons'>['status'];
const labels: Record<string, string> = {
up_to_date: 'Up to date',
behind: 'Behind',
ahead: 'Ahead',
diverged: 'Diverged',
checking: 'Checking',
conflict: 'Conflict',
error: 'Error',
unknown: 'Unknown',
active: 'Active',
draft: 'Draft',
needs_connection: 'Needs connection',
paused: 'Paused',
archived: 'Archived',
};
const variants: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
up_to_date: 'default',
behind: 'secondary',
ahead: 'outline',
diverged: 'destructive',
conflict: 'destructive',
error: 'destructive',
checking: 'secondary',
active: 'default',
};
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
const value = status ?? 'unknown';
return (
<Badge variant={variants[value] ?? 'outline'}>
{labels[value] ?? value.replaceAll('_', ' ')}
</Badge>
);
};
@@ -0,0 +1,52 @@
import Link from 'next/link';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Card, CardContent } from '@spoon/ui';
export const MaintenanceQueue = ({ spoons }: { spoons: Doc<'spoons'>[] }) => {
const queued = spoons
.filter((spoon) =>
['behind', 'diverged', 'conflict', 'error'].includes(
spoon.syncStatus ?? '',
),
)
.sort((a, b) => (b.upstreamAheadBy ?? 0) - (a.upstreamAheadBy ?? 0));
return (
<div className='space-y-3'>
{queued.length ? (
queued.map((spoon) => (
<Card key={spoon._id} className='shadow-none'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{spoon.name}</p>
<SpoonStatusBadge status={spoon.syncStatus} />
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo} {' '}
{spoon.forkOwner ?? 'unknown'}/{spoon.forkRepo ?? 'unknown'}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{spoon.upstreamAheadBy ?? 0} upstream commit(s),{' '}
{spoon.forkAheadBy ?? 0} fork-only commit(s)
</p>
</div>
<Button variant='outline' size='sm' asChild>
<Link href={`/spoons/${spoon._id}`}>Open Spoon</Link>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
No Spoons currently need review. Refresh GitHub state to populate
this queue.
</CardContent>
</Card>
)}
</div>
);
};
+20
View File
@@ -9,6 +9,16 @@ export const env = createEnv({
SKIP_ENV_VALIDATION: z.boolean().default(false), SKIP_ENV_VALIDATION: z.boolean().default(false),
SENTRY_AUTH_TOKEN: z.string(), SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(false), CI: z.boolean().default(false),
AUTH_GITHUB_ID: z.string().optional(),
AUTH_GITHUB_SECRET: z.string().optional(),
GITHUB_APP_ID: z.string().optional(),
GITHUB_APP_CLIENT_ID: z.string().optional(),
GITHUB_APP_CLIENT_SECRET: z.string().optional(),
GITHUB_APP_PRIVATE_KEY: z.string().optional(),
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
GITHUB_APP_SLUG: z.string().optional(),
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
GITHUB_APP_OWNER: z.string().optional(),
}, },
/** /**
@@ -32,6 +42,16 @@ export const env = createEnv({
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION, SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI, CI: process.env.CI,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
GITHUB_APP_ID: process.env.GITHUB_APP_ID,
GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID,
GITHUB_APP_CLIENT_SECRET: process.env.GITHUB_APP_CLIENT_SECRET,
GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY,
GITHUB_APP_WEBHOOK_SECRET: process.env.GITHUB_APP_WEBHOOK_SECRET,
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL, NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
+4
View File
@@ -0,0 +1,4 @@
export const isRemoteImageUrl = (value: string | null | undefined) => {
if (!value) return false;
return value.startsWith('http://') || value.startsWith('https://');
};
+2
View File
@@ -10,6 +10,8 @@ const isProtectedRoute = createRouteMatcher([
'/spoons(.*)', '/spoons(.*)',
'/updates(.*)', '/updates(.*)',
'/agents(.*)', '/agents(.*)',
'/github(.*)',
'/settings(.*)',
'/profile(.*)', '/profile(.*)',
]); ]);
+210
View File
@@ -15,6 +15,29 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"apps/agent-worker": {
"name": "@spoon/agent-worker",
"version": "0.1.0",
"dependencies": {
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@openai/agents": "latest",
"convex": "catalog:convex",
"execa": "latest",
"openai": "^6.44.0",
"zod": "catalog:",
},
"devDependencies": {
"@spoon/eslint-config": "workspace:*",
"@spoon/prettier-config": "workspace:*",
"@spoon/tsconfig": "workspace:*",
"@types/node": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:test",
},
},
"apps/expo": { "apps/expo": {
"name": "@spoon/expo", "name": "@spoon/expo",
"dependencies": { "dependencies": {
@@ -108,10 +131,13 @@
"name": "@spoon/backend", "name": "@spoon/backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@react-email/components": "1.0.10", "@react-email/components": "1.0.10",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.4",
"convex": "catalog:convex", "convex": "catalog:convex",
"openai": "^6.44.0",
"react": "catalog:react19", "react": "catalog:react19",
"react-dom": "catalog:react19", "react-dom": "catalog:react19",
"usesend-js": "^1.6.3", "usesend-js": "^1.6.3",
@@ -674,6 +700,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@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=="], "@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=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -802,6 +830,8 @@
"@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=="], "@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=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
"@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="], "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="],
@@ -890,6 +920,50 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@octokit/auth-app": ["@octokit/auth-app@8.2.0", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g=="],
"@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="],
"@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="],
"@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
"@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
"@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
"@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="],
"@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
"@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="],
"@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@openai/agents": ["@openai/agents@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "@openai/agents-openai": "0.11.8", "@openai/agents-realtime": "0.11.8", "debug": "^4.4.0", "openai": "^6.35.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-D4XHF2g+Ub/L9fRJT/xpuiCqHyxiKzZbi0BqQxnso42t+J049O/OSvVzFBcRskF4uPFAvs0TOOB7KBbanCwaYQ=="],
"@openai/agents-core": ["@openai/agents-core@0.11.8", "", { "dependencies": { "debug": "^4.4.0", "openai": "^6.35.0" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.26.0" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-TrE34RXXPoWYv2PjXf5hq3Eq+uvRJMNiY+Q5WBgEPjAg60yt2hya8cS2I8qkO6i25MjNJl37a25X0vL/gs5Wdg=="],
"@openai/agents-openai": ["@openai/agents-openai@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "debug": "^4.4.0", "openai": "^6.35.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-XjHCnJPGapgZBlh8y5oxU7zV0hrAQTF5im6HpUwaPcH5CeRFLtc06VXLso0vJ5G3g9e/J5gIh3S1iAxiJqEAVQ=="],
"@openai/agents-realtime": ["@openai/agents-realtime@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-i1qEGUE8GTW0neWgAc1aj/3wZFtstz8bVG2BvVbU/BzQbyhZV8j3CvndkMJGFfgeobvVmn2qGTV5Ry6ibfuxeQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="],
@@ -1250,6 +1324,8 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.46.0", "", { "dependencies": { "@sentry/core": "10.46.0" } }, "sha512-WB1gBT9G13V02ekZ6NpUhoI1aGHV2eNfjEPthkU2bGBvFpQKnstwzjg7waIRGR7cu+YSW2Q6UI6aQLgBeOPD1g=="], "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.46.0", "", { "dependencies": { "@sentry/core": "10.46.0" } }, "sha512-WB1gBT9G13V02ekZ6NpUhoI1aGHV2eNfjEPthkU2bGBvFpQKnstwzjg7waIRGR7cu+YSW2Q6UI6aQLgBeOPD1g=="],
@@ -1306,12 +1382,16 @@
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@spoon/agent-worker": ["@spoon/agent-worker@workspace:apps/agent-worker"],
"@spoon/backend": ["@spoon/backend@workspace:packages/backend"], "@spoon/backend": ["@spoon/backend@workspace:packages/backend"],
"@spoon/eslint-config": ["@spoon/eslint-config@workspace:tools/eslint"], "@spoon/eslint-config": ["@spoon/eslint-config@workspace:tools/eslint"],
@@ -1670,6 +1750,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
"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=="], "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -1678,6 +1760,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "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=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
@@ -1780,6 +1864,10 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"convex": ["convex@1.34.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA=="], "convex": ["convex@1.34.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA=="],
@@ -1788,6 +1876,8 @@
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="], "core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
@@ -2004,6 +2094,12 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"expo": ["expo@54.0.33", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", "@expo/metro-config": "54.0.14", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.10", "expo-asset": "~12.0.12", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", "expo-font": "~14.0.11", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw=="], "expo": ["expo@54.0.33", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", "@expo/metro-config": "54.0.14", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.10", "expo-asset": "~12.0.12", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", "expo-font": "~14.0.11", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw=="],
@@ -2062,6 +2158,10 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -2084,6 +2184,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -2104,6 +2206,8 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
@@ -2140,6 +2244,8 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.8.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg=="], "get-tsconfig": ["get-tsconfig@4.8.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg=="],
@@ -2184,6 +2290,8 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hono": ["hono@4.12.26", "", {}, "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw=="],
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
@@ -2196,6 +2304,8 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
@@ -2236,6 +2346,10 @@
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
@@ -2280,8 +2394,12 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -2290,6 +2408,8 @@
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
@@ -2362,6 +2482,8 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
@@ -2452,12 +2574,16 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="], "memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="],
"memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="], "memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -2558,6 +2684,8 @@
"npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], "nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
@@ -2594,6 +2722,8 @@
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"openai": ["openai@6.44.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
@@ -2610,6 +2740,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
@@ -2646,6 +2778,8 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
@@ -2680,6 +2814,8 @@
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
@@ -2692,12 +2828,16 @@
"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=="], "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=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "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=="], "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
@@ -2710,6 +2850,8 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -2822,6 +2964,8 @@
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -2962,6 +3106,8 @@
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@@ -3034,6 +3180,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
@@ -3060,6 +3208,8 @@
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@@ -3090,6 +3240,12 @@
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="],
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
@@ -3204,6 +3360,8 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], "@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
@@ -3360,6 +3518,12 @@
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"@modelcontextprotocol/sdk/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@node-rs/argon2-wasm32-wasi/@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="], "@node-rs/argon2-wasm32-wasi/@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="],
@@ -3370,6 +3534,8 @@
"@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="],
"@openai/agents-realtime/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=="],
"@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], "@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
@@ -3676,6 +3842,8 @@
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
"chrome-launcher/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="], "chrome-launcher/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
@@ -3762,6 +3930,22 @@
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"express/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
@@ -3854,6 +4038,8 @@
"npm-package-arg/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "npm-package-arg/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], "nypm/tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@@ -3882,6 +4068,8 @@
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
@@ -3916,6 +4104,8 @@
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -3968,6 +4158,8 @@
"tsx/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=="], "tsx/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=="],
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="], "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
"usesend-js/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="], "usesend-js/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="],
@@ -4122,6 +4314,8 @@
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@opentelemetry/sql-common/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], "@opentelemetry/sql-common/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
"@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="],
@@ -4360,6 +4554,8 @@
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], "babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
"body-parser/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"chrome-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "chrome-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -4546,6 +4742,18 @@
"expo-router/@react-navigation/native/@react-navigation/core": ["@react-navigation/core@7.16.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.3", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw=="], "expo-router/@react-navigation/native/@react-navigation/core": ["@react-navigation/core@7.16.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.3", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw=="],
"express/accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"express/finalhandler/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"express/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"express/send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"express/send/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -4594,6 +4802,8 @@
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-bookworm
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
build-essential \
ca-certificates \
curl \
git \
jq \
openssh-client \
python3 \
ripgrep \
&& corepack enable \
&& npm install -g bun@1.3.10 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash agent
USER agent
WORKDIR /workspace
CMD ["bash"]
+24
View File
@@ -0,0 +1,24 @@
FROM oven/bun:1.3.10
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
docker.io \
git \
jq \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json bun.lock* turbo.json ./
COPY apps ./apps
COPY packages ./packages
COPY tools ./tools
COPY scripts ./scripts
RUN bun install --frozen-lockfile
CMD ["bun", "apps/agent-worker/src/index.ts"]
+28
View File
@@ -58,6 +58,34 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
spoon-agent-worker:
profiles: ['agent']
build:
context: ../
dockerfile: ./docker/agent-worker.Dockerfile
image: spoon-agent-worker:latest
container_name: spoon-local-agent-worker
environment:
- NEXT_PUBLIC_CONVEX_URL=http://convex-backend:3210
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
- SPOON_AGENT_WORKDIR=${SPOON_AGENT_WORKDIR:-/var/lib/spoon-agent/work}
- GITHUB_APP_ID=${GITHUB_APP_ID}
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- agent-work:/var/lib/spoon-agent/work
depends_on:
convex-backend:
condition: service_healthy
restart: unless-stopped
volumes: volumes:
postgres-data: postgres-data:
convex-data: convex-data:
agent-work:
+31
View File
@@ -87,3 +87,34 @@ services:
restart: unless-stopped restart: unless-stopped
stop_grace_period: 10s stop_grace_period: 10s
stop_signal: SIGINT stop_signal: SIGINT
spoon-agent-worker:
build:
context: ../
dockerfile: ./docker/agent-worker.Dockerfile
image: spoon-agent-worker:latest
container_name: ${AGENT_WORKER_CONTAINER_NAME:-spoon-agent-worker}
hostname: ${AGENT_WORKER_CONTAINER_NAME:-spoon-agent-worker}
networks: ['${NETWORK:-nginx-bridge}']
environment:
- NEXT_PUBLIC_CONVEX_URL=${CONVEX_SELF_HOSTED_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
- SPOON_AGENT_WORKDIR=${SPOON_AGENT_WORKDIR:-/var/lib/spoon-agent/work}
- GITHUB_APP_ID=${GITHUB_APP_ID}
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- spoon-agent-work:/var/lib/spoon-agent/work
depends_on:
spoon-backend:
condition: service_healthy
restart: unless-stopped
volumes:
spoon-agent-work:
+2
View File
@@ -52,11 +52,13 @@
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:tunnel": "turbo run dev:tunnel", "dev:tunnel": "turbo run dev:tunnel",
"dev:next": "turbo run dev -F @spoon/next -F @spoon/backend", "dev:next": "turbo run dev -F @spoon/next -F @spoon/backend",
"dev:agent": "turbo run dev -F @spoon/agent-worker",
"dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend", "dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend",
"dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend", "dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend",
"dev:backend": "turbo run dev -F @spoon/backend", "dev:backend": "turbo run dev -F @spoon/backend",
"dev:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend", "dev:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend",
"dev:expo:tunnel": "turbo run dev:tunnel -F @spoon/expo -F @spoon/backend", "dev:expo:tunnel": "turbo run dev:tunnel -F @spoon/expo -F @spoon/backend",
"sync:convex": "scripts/sync-convex-env ${INFISICAL_ENV:-dev}",
"db:up": "bash scripts/db/up", "db:up": "bash scripts/db/up",
"db:down": "bash scripts/db/down", "db:down": "bash scripts/db/down",
"db:down:wipe": "bash scripts/db/down --wipe", "db:down:wipe": "bash scripts/db/down --wipe",
+479
View File
@@ -0,0 +1,479 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
const jobStatus = v.union(
v.literal('queued'),
v.literal('claimed'),
v.literal('preparing'),
v.literal('running'),
v.literal('checks_running'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('failed'),
v.literal('cancelled'),
v.literal('timed_out'),
);
const eventLevel = v.union(
v.literal('debug'),
v.literal('info'),
v.literal('warn'),
v.literal('error'),
);
const eventPhase = v.union(
v.literal('queued'),
v.literal('clone'),
v.literal('plan'),
v.literal('edit'),
v.literal('install'),
v.literal('check'),
v.literal('test'),
v.literal('commit'),
v.literal('push'),
v.literal('pr'),
v.literal('cleanup'),
);
const artifactKind = v.union(
v.literal('plan'),
v.literal('diff'),
v.literal('test_output'),
v.literal('summary'),
v.literal('error'),
v.literal('pr_body'),
);
const artifactContentType = v.union(
v.literal('text/markdown'),
v.literal('text/plain'),
v.literal('application/json'),
v.literal('text/x-diff'),
);
const defaultAgentSettings = {
enabled: true,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high' as const,
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
};
const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();
const requireWorkerToken = (workerToken: string) => {
const expected = getWorkerToken();
if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
};
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 36) || 'task';
const shortId = (id: string) => id.replace(/[^a-zA-Z0-9]/g, '').slice(-8);
const buildBranch = (
requestId: Id<'agentRequests'>,
prompt: string,
prefix: string,
requestedBranchName?: string,
) => {
const requested = optionalText(requestedBranchName);
if (requested) return requested.replace(/^\/+|\/+$/g, '');
return `${prefix.replace(/\/+$/g, '')}/${shortId(requestId)}/${slugify(
prompt,
)}`;
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
.first();
return {
...defaultAgentSettings,
defaultBaseBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
...settings,
};
};
const assertSecretOwnership = async (
ctx: MutationCtx,
spoonId: Id<'spoons'>,
ownerId: Id<'users'>,
secretIds: Id<'spoonSecrets'>[],
) => {
for (const secretId of secretIds) {
const secret = await ctx.db.get(secretId);
if (secret?.ownerId !== ownerId || secret.spoonId !== spoonId) {
throw new ConvexError('Selected secrets must belong to this Spoon.');
}
}
};
export const createFromRequest = mutation({
args: {
requestId: v.id('agentRequests'),
selectedSecretIds: v.array(v.id('spoonSecrets')),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const request = await ctx.db.get(args.requestId);
if (request?.ownerId !== ownerId) {
throw new ConvexError('Agent request not found.');
}
if (request.agentJobId) {
throw new ConvexError('This request already has an agent job.');
}
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
if (spoon.provider !== 'github') {
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
}
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
throw new ConvexError(
'Add fork repository metadata before queueing a job.',
);
}
const settings = await getAgentSettings(ctx, spoon);
if (!settings.enabled) {
throw new ConvexError('Agent jobs are disabled for this Spoon.');
}
await assertSecretOwnership(
ctx,
spoon._id,
ownerId,
args.selectedSecretIds,
);
const now = Date.now();
const baseBranch =
optionalText(args.baseBranch) ?? settings.defaultBaseBranch;
const workBranch = buildBranch(
request._id,
request.prompt,
settings.branchPrefix,
args.requestedBranchName,
);
const jobId = await ctx.db.insert('agentJobs', {
spoonId: spoon._id,
ownerId,
agentRequestId: request._id,
status: 'queued',
prompt: request.prompt,
baseBranch,
workBranch,
githubInstallationId: spoon.githubInstallationId,
forkOwner: spoon.forkOwner,
forkRepo: spoon.forkRepo,
forkUrl: spoon.forkUrl,
upstreamOwner: spoon.upstreamOwner,
upstreamRepo: spoon.upstreamRepo,
selectedSecretIds: args.selectedSecretIds,
model: settings.agentModel,
reasoningEffort: settings.reasoningEffort,
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(request._id, {
agentJobId: jobId,
selectedSecretIds: args.selectedSecretIds,
baseBranch,
requestedBranchName: optionalText(args.requestedBranchName),
status: 'queued',
updatedAt: now,
});
await ctx.db.insert('agentJobEvents', {
jobId,
spoonId: spoon._id,
ownerId,
level: 'info',
phase: 'queued',
message: 'Agent job queued.',
createdAt: now,
});
return jobId;
},
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('agentJobs')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const get = query({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return job;
},
});
export const listEvents = query({
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
handler: async (ctx, { jobId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return await ctx.db
.query('agentJobEvents')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('asc')
.take(limit ?? 200);
},
});
export const listArtifacts = query({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return await ctx.db
.query('agentJobArtifacts')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('asc')
.collect();
},
});
export const cancel = mutation({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
if (
!['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
job.status,
)
) {
throw new ConvexError('This job cannot be cancelled.');
}
const now = Date.now();
await ctx.db.patch(jobId, {
status: 'cancelled',
completedAt: now,
updatedAt: now,
});
await ctx.db.patch(job.agentRequestId, {
status: 'cancelled',
updatedAt: now,
});
await ctx.db.insert('agentJobEvents', {
jobId,
spoonId: job.spoonId,
ownerId,
level: 'warn',
phase: 'cleanup',
message: 'Agent job cancelled by user.',
createdAt: now,
});
return { success: true };
},
});
export const claimNextInternal = internalMutation({
args: { workerId: v.string() },
handler: async (ctx, { workerId }) => {
const job = await ctx.db
.query('agentJobs')
.withIndex('by_claim', (q) => q.eq('status', 'queued'))
.order('asc')
.first();
if (!job) return null;
const spoon = await ctx.db.get(job.spoonId);
const aiSettings = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', job.ownerId).eq('provider', 'openai'),
)
.first();
const agentSettings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
.first();
const secrets = [];
for (const secretId of job.selectedSecretIds) {
const secret = await ctx.db.get(secretId);
if (secret?.ownerId === job.ownerId && secret.spoonId === job.spoonId) {
secrets.push(secret);
}
}
const now = Date.now();
await ctx.db.patch(job._id, {
status: 'claimed',
claimedBy: workerId,
claimedAt: now,
updatedAt: now,
});
await ctx.db.patch(job.agentRequestId, {
status: 'running',
updatedAt: now,
});
await ctx.db.insert('agentJobEvents', {
jobId: job._id,
spoonId: job.spoonId,
ownerId: job.ownerId,
level: 'info',
phase: 'queued',
message: `Claimed by ${workerId}.`,
createdAt: now,
});
return {
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
spoon,
aiSettings,
agentSettings,
secrets,
};
},
});
export const updateStatus = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
status: jobStatus,
error: v.optional(v.string()),
summary: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
const patch: Partial<Doc<'agentJobs'>> = {
status: args.status,
updatedAt: now,
};
if (args.status === 'running' && !job.startedAt) patch.startedAt = now;
if (
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
args.status,
)
) {
patch.completedAt = now;
}
if (args.error !== undefined) patch.error = args.error;
if (args.summary !== undefined) patch.summary = args.summary;
await ctx.db.patch(args.jobId, patch);
return { success: true };
},
});
export const completeWithDraftPr = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
commitSha: v.string(),
pullRequestUrl: v.string(),
pullRequestNumber: v.number(),
summary: v.string(),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
await ctx.db.patch(args.jobId, {
status: 'draft_pr_opened',
commitSha: args.commitSha,
pullRequestUrl: args.pullRequestUrl,
pullRequestNumber: args.pullRequestNumber,
summary: args.summary,
completedAt: now,
updatedAt: now,
});
await ctx.db.patch(job.agentRequestId, {
status: 'merge_request_opened',
mergeRequestUrl: args.pullRequestUrl,
summary: args.summary,
updatedAt: now,
});
return { success: true };
},
});
export const appendEvent = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
level: eventLevel,
phase: eventPhase,
message: v.string(),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
return await ctx.db.insert('agentJobEvents', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
level: args.level,
phase: args.phase,
message: args.message,
metadata: args.metadata,
createdAt: Date.now(),
});
},
});
export const addArtifact = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
kind: artifactKind,
title: v.string(),
content: v.string(),
contentType: artifactContentType,
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
return await ctx.db.insert('agentJobArtifacts', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
kind: args.kind,
title: args.title,
content: args.content,
contentType: args.contentType,
createdAt: Date.now(),
});
},
});
+82
View File
@@ -0,0 +1,82 @@
'use node';
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { decryptSecret } from './secretCrypto';
type ClaimedJob = {
job: Doc<'agentJobs'>;
spoon: Doc<'spoons'> | null;
aiSettings: Doc<'userAiSettings'> | null;
agentSettings: Doc<'spoonAgentSettings'> | null;
secrets: Doc<'spoonSecrets'>[];
};
type WorkerClaim = {
job: Doc<'agentJobs'>;
spoon: Doc<'spoons'>;
openai: {
apiKey: string;
model: string;
reasoningEffort: Doc<'agentJobs'>['reasoningEffort'];
};
agentSettings: Doc<'spoonAgentSettings'> | null;
github: {
installationId?: string;
};
secrets: { name: string; value: string }[];
};
const requireWorkerToken = (workerToken: string) => {
const expected = process.env.SPOON_WORKER_TOKEN?.trim();
if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
};
export const claimNextForWorker = action({
args: {
workerId: v.string(),
workerToken: v.string(),
},
handler: async (ctx, args): Promise<WorkerClaim | null> => {
requireWorkerToken(args.workerToken);
const claimed = (await ctx.runMutation(
internal.agentJobs.claimNextInternal,
{
workerId: args.workerId,
},
)) as ClaimedJob | null;
if (!claimed) return null;
if (!claimed.spoon) {
throw new ConvexError('Claimed job points at a missing Spoon.');
}
if (!claimed.aiSettings?.encryptedApiKey) {
throw new ConvexError(
'OpenAI is not configured for this user. Add an OpenAI API key in settings.',
);
}
return {
job: claimed.job,
spoon: claimed.spoon,
openai: {
apiKey: decryptSecret(claimed.aiSettings.encryptedApiKey),
model: claimed.job.model,
reasoningEffort: claimed.job.reasoningEffort,
},
agentSettings: claimed.agentSettings,
github: {
installationId:
claimed.job.githubInstallationId ??
claimed.spoon.githubInstallationId ??
process.env.GITHUB_APP_INSTALLATION_ID,
},
secrets: claimed.secrets.map((secret: Doc<'spoonSecrets'>) => ({
name: secret.name,
value: decryptSecret(secret.encryptedValue),
})),
};
},
});
+40
View File
@@ -20,6 +20,19 @@ export const listRecent = query({
}, },
}); });
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('agentRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const create = mutation({ export const create = mutation({
args: { args: {
spoonId: v.id('spoons'), spoonId: v.id('spoons'),
@@ -35,6 +48,9 @@ export const create = mutation({
ownerId, ownerId,
prompt: requireText(args.prompt, 'Prompt'), prompt: requireText(args.prompt, 'Prompt'),
status: 'queued', status: 'queued',
requestType: 'future_code_change',
priority: 'normal',
source: 'user',
targetBranch: optionalText(args.targetBranch), targetBranch: optionalText(args.targetBranch),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -42,6 +58,30 @@ export const create = mutation({
}, },
}); });
export const updatePrompt = mutation({
args: {
requestId: v.id('agentRequests'),
prompt: v.string(),
targetBranch: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const request = await ctx.db.get(args.requestId);
if (request?.ownerId !== ownerId) {
throw new ConvexError('Agent request not found.');
}
if (request.status !== 'draft' && request.status !== 'queued') {
throw new ConvexError('Only draft or queued requests can be edited.');
}
await ctx.db.patch(args.requestId, {
prompt: requireText(args.prompt, 'Prompt'),
targetBranch: optionalText(args.targetBranch),
updatedAt: Date.now(),
});
return { success: true };
},
});
export const cancel = mutation({ export const cancel = mutation({
args: { requestId: v.id('agentRequests') }, args: { requestId: v.id('agentRequests') },
handler: async (ctx, { requestId }) => { handler: async (ctx, { requestId }) => {
+201
View File
@@ -0,0 +1,201 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { reviewUpstreamCompatibility } from './openaiClient';
import { decryptSecret } from './secretCrypto';
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
export const reviewLatestUpstreamChanges = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
reviewId: Id<'aiReviews'>;
risk: 'low' | 'medium' | 'high';
recommendedAction:
| 'sync'
| 'open_review_pr'
| 'manual_review'
| 'do_not_sync';
}> => {
const ownerId = await getRequiredUserId(ctx);
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
const [state, settings, upstreamCommits, forkCommits]: [
Doc<'spoonRepositoryStates'> | null,
Doc<'spoonSettings'> | null,
Doc<'spoonCommits'>[],
Doc<'spoonCommits'>[],
] = await Promise.all([
ctx.runQuery(internal.spoonState.getInternal, { spoonId, ownerId }),
ctx.runQuery(internal.spoonSettings.getInternal, { spoonId, ownerId }),
ctx.runQuery(internal.spoonCommits.listInternal, {
spoonId,
ownerId,
side: 'upstream',
limit: 80,
}),
ctx.runQuery(internal.spoonCommits.listInternal, {
spoonId,
ownerId,
side: 'fork',
limit: 80,
}),
]);
const aiSettings: Doc<'userAiSettings'> | null = await ctx.runQuery(
internal.aiSettings.getForUserInternal,
{ userId: ownerId },
);
if (!aiSettings?.encryptedApiKey) {
throw new ConvexError(
'Add your OpenAI API key in Settings before running AI review.',
);
}
const model = aiSettings.model;
const syncRunId: Id<'syncRuns'> = await ctx.runMutation(
internal.syncRuns.createInternal,
{
spoonId,
ownerId,
kind: 'ai_review',
status: 'running',
summary: 'Reviewing upstream changes with OpenAI.',
},
);
const reviewId: Id<'aiReviews'> = await ctx.runMutation(
internal.aiReviews.createInternal,
{
spoonId,
ownerId,
syncRunId,
model,
status: 'running',
reviewType: 'upstream_update',
inputSummary: `${upstreamCommits.length} upstream commit(s), ${forkCommits.length} fork-only commit(s).`,
},
);
try {
if (upstreamCommits.length === 0) {
await ctx.runMutation(internal.aiReviews.completeInternal, {
reviewId,
outputSummary: 'The fork is already up to date with upstream.',
risk: 'low',
compatible: true,
requiresHumanReview: false,
recommendedAction: 'sync',
potentialConflicts: [],
importantFiles: [],
reasoningSummary:
'No upstream-only commits are cached for this Spoon, so there is nothing to review.',
});
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
lastAiReviewId: reviewId,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'clean',
aiAssessment: 'No upstream-only commits are waiting.',
}),
]);
return { reviewId, risk: 'low', recommendedAction: 'sync' };
}
const review = await reviewUpstreamCompatibility(
{
spoonName: spoon.name,
upstreamFullName:
state?.upstreamFullName ??
`${spoon.upstreamOwner}/${spoon.upstreamRepo}`,
forkFullName:
state?.forkFullName ??
`${spoon.forkOwner ?? 'unknown'}/${spoon.forkRepo ?? 'unknown'}`,
status: state?.status ?? spoon.syncStatus ?? 'unknown',
upstreamAheadBy: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
upstreamCommits: upstreamCommits.map((commit) => ({
sha: commit.sha,
message: commit.message,
authorName: commit.authorName,
committedAt: commit.committedAt,
})),
forkCommits: forkCommits.map((commit) => ({
sha: commit.sha,
message: commit.message,
authorName: commit.authorName,
committedAt: commit.committedAt,
})),
importantFilePatterns: settings?.importantFilePatterns,
ignoredFilePatterns: settings?.ignoredFilePatterns,
},
{
apiKey: decryptSecret(aiSettings.encryptedApiKey),
model,
reasoningEffort: aiSettings.reasoningEffort,
},
);
await ctx.runMutation(internal.aiReviews.completeInternal, {
reviewId,
outputSummary: review.summary,
risk: review.risk,
compatible: review.compatible,
requiresHumanReview: review.requiresHumanReview,
recommendedAction: review.recommendedAction,
potentialConflicts: review.potentialConflicts,
importantFiles: review.importantFiles,
reasoningSummary: review.reasoningSummary,
});
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
lastAiReviewId: reviewId,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status:
review.compatible && review.risk === 'low'
? 'clean'
: 'needs_review',
aiAssessment: review.summary,
}),
]);
return {
reviewId,
risk: review.risk,
recommendedAction: review.recommendedAction,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await Promise.all([
ctx.runMutation(internal.aiReviews.failInternal, {
reviewId,
error: message,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error: message,
}),
]);
throw new ConvexError(message);
}
},
});
+140
View File
@@ -0,0 +1,140 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const reviewStatus = v.union(
v.literal('queued'),
v.literal('running'),
v.literal('completed'),
v.literal('failed'),
);
const reviewType = v.union(
v.literal('upstream_update'),
v.literal('manual_prompt'),
v.literal('merge_safety'),
);
const risk = v.union(
v.literal('unknown'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
);
const recommendedAction = v.union(
v.literal('sync'),
v.literal('open_review_pr'),
v.literal('manual_review'),
v.literal('do_not_sync'),
v.literal('unknown'),
);
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const getLatestForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.first();
},
});
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const ownerId = await getRequiredUserId(ctx);
return await ctx.db
.query('aiReviews')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.take(limit ?? 25);
},
});
export const createInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
syncRunId: v.optional(v.id('syncRuns')),
model: v.string(),
status: reviewStatus,
reviewType,
inputSummary: v.string(),
},
handler: async (ctx, args): Promise<Id<'aiReviews'>> => {
const now = Date.now();
return await ctx.db.insert('aiReviews', {
...args,
risk: 'unknown',
compatible: false,
requiresHumanReview: true,
recommendedAction: 'unknown',
createdAt: now,
updatedAt: now,
});
},
});
export const completeInternal = internalMutation({
args: {
reviewId: v.id('aiReviews'),
outputSummary: v.string(),
risk,
compatible: v.boolean(),
requiresHumanReview: v.boolean(),
recommendedAction,
potentialConflicts: v.array(v.string()),
importantFiles: v.array(v.string()),
reasoningSummary: v.string(),
},
handler: async (ctx, args) => {
const review = await ctx.db.get(args.reviewId);
if (!review) throw new ConvexError('AI review not found.');
const patch: Partial<Doc<'aiReviews'>> = {
status: 'completed',
outputSummary: args.outputSummary,
risk: args.risk,
compatible: args.compatible,
requiresHumanReview: args.requiresHumanReview,
recommendedAction: args.recommendedAction,
potentialConflicts: args.potentialConflicts,
importantFiles: args.importantFiles,
reasoningSummary: args.reasoningSummary,
updatedAt: Date.now(),
completedAt: Date.now(),
};
await ctx.db.patch(args.reviewId, patch);
return review;
},
});
export const failInternal = internalMutation({
args: { reviewId: v.id('aiReviews'), error: v.string() },
handler: async (ctx, { reviewId, error }) => {
await ctx.db.patch(reviewId, {
status: 'failed',
error,
updatedAt: Date.now(),
});
return { success: true };
},
});
+141
View File
@@ -0,0 +1,141 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getRequiredUserId } from './model';
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
export const getMine = query({
args: {},
handler: async (ctx) => {
const userId = await getRequiredUserId(ctx);
const settings = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
if (!settings) {
return {
configured: false,
apiKeyPreview: undefined,
model: 'gpt-5.5',
reasoningEffort: 'medium' as const,
updatedAt: undefined,
};
}
return {
configured: Boolean(settings.encryptedApiKey),
apiKeyPreview: settings.apiKeyPreview,
model: settings.model,
reasoningEffort: settings.reasoningEffort,
updatedAt: settings.updatedAt,
};
},
});
export const getForUserInternal = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
return await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
},
});
const upsert = async (
ctx: MutationCtx,
userId: Id<'users'>,
patch: Partial<Doc<'userAiSettings'>>,
) => {
const now = Date.now();
const existing = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
if (existing) {
await ctx.db.patch(existing._id, { ...patch, updatedAt: now });
return existing._id;
}
return await ctx.db.insert('userAiSettings', {
userId,
provider: 'openai',
model: patch.model ?? 'gpt-5.5',
reasoningEffort: patch.reasoningEffort ?? 'medium',
encryptedApiKey: patch.encryptedApiKey,
apiKeyPreview: patch.apiKeyPreview,
createdAt: now,
updatedAt: now,
});
};
export const upsertEncryptedInternal = internalMutation({
args: {
userId: v.id('users'),
encryptedApiKey: v.string(),
apiKeyPreview: v.string(),
model: v.string(),
reasoningEffort,
},
handler: async (ctx, args) => {
return await upsert(ctx, args.userId, {
encryptedApiKey: args.encryptedApiKey,
apiKeyPreview: args.apiKeyPreview,
model: args.model,
reasoningEffort: args.reasoningEffort,
});
},
});
export const updatePreferences = mutation({
args: {
model: v.string(),
reasoningEffort,
},
handler: async (ctx, args) => {
const userId = await getRequiredUserId(ctx);
return await upsert(ctx, userId, {
model: args.model.trim() || 'gpt-5.5',
reasoningEffort: args.reasoningEffort,
});
},
});
export const removeOpenAiKey = mutation({
args: {},
handler: async (ctx) => {
const userId = await getRequiredUserId(ctx);
const settings = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
if (!settings) throw new ConvexError('OpenAI settings not found.');
await ctx.db.patch(settings._id, {
encryptedApiKey: undefined,
apiKeyPreview: undefined,
updatedAt: Date.now(),
});
return { success: true };
},
});
+52
View File
@@ -0,0 +1,52 @@
'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 { encryptSecret } from './secretCrypto';
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const previewKey = (apiKey: string) => {
const trimmed = apiKey.trim();
if (trimmed.length <= 10) return 'configured';
return `${trimmed.slice(0, 7)}...${trimmed.slice(-4)}`;
};
export const saveOpenAiSettings = action({
args: {
apiKey: v.string(),
model: v.string(),
reasoningEffort,
},
handler: async (ctx, args): Promise<{ success: true }> => {
const userId = await getRequiredUserId(ctx);
const apiKey = args.apiKey.trim();
if (!apiKey) throw new ConvexError('OpenAI API key is required.');
await ctx.runMutation(internal.aiSettings.upsertEncryptedInternal, {
userId,
encryptedApiKey: encryptSecret(apiKey),
apiKeyPreview: previewKey(apiKey),
model: args.model.trim() || 'gpt-5.5',
reasoningEffort: args.reasoningEffort,
});
return { success: true };
},
});
+14 -1
View File
@@ -1,4 +1,5 @@
import Authentik from '@auth/core/providers/authentik'; import Authentik from '@auth/core/providers/authentik';
import GitHub from '@auth/core/providers/github';
import { import {
convexAuth, convexAuth,
getAuthUserId, getAuthUserId,
@@ -21,6 +22,12 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
clientSecret: process.env.AUTH_AUTHENTIK_SECRET, clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER, issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}), }),
GitHub({
allowDangerousEmailAccountLinking: true,
clientId: process.env.AUTH_GITHUB_ID ?? process.env.GITHUB_APP_CLIENT_ID,
clientSecret:
process.env.AUTH_GITHUB_SECRET ?? process.env.GITHUB_APP_CLIENT_SECRET,
}),
Password, Password,
], ],
}); });
@@ -39,6 +46,7 @@ const getAuthAccountById = async (ctx: QueryCtx, userId: Id<'users'>) => {
const authAccount = await ctx.db const authAccount = await ctx.db
.query('authAccounts') .query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId)) .withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
.order('desc')
.first(); .first();
if (!authAccount) throw new ConvexError('Auth account not found'); if (!authAccount) throw new ConvexError('Auth account not found');
return authAccount; return authAccount;
@@ -83,7 +91,12 @@ export const updateUser = mutation({
if (args.image !== undefined) { if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined; const oldImage = user.image as Id<'_storage'> | undefined;
patch.image = args.image; patch.image = args.image;
if (oldImage && oldImage !== args.image) { if (
oldImage &&
oldImage !== args.image &&
!oldImage.startsWith('http://') &&
!oldImage.startsWith('https://')
) {
await ctx.storage.delete(oldImage); await ctx.storage.delete(oldImage);
} }
} }
+10
View File
@@ -1,7 +1,17 @@
import { cronJobs } from 'convex/server'; import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
// Cron order: Minute Hour DayOfMonth Month DayOfWeek // Cron order: Minute Hour DayOfMonth Month DayOfWeek
const crons = cronJobs(); const crons = cronJobs();
crons.interval(
'Refresh due GitHub Spoons',
{ hours: 1 },
internal.githubSync.refreshDueSpoons,
{
limit: 10,
},
);
/* Example cron jobs /* Example cron jobs
crons.cron( crons.cron(
// Run at 7:00 AM CST / 8:00 AM CDT // Run at 7:00 AM CST / 8:00 AM CDT
+12 -3
View File
@@ -1,6 +1,7 @@
import { getAuthUserId } from '@convex-dev/auth/server'; import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values'; import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import { mutation, query } from './_generated/server'; import { mutation, query } from './_generated/server';
export const generateUploadUrl = mutation(async (ctx) => { export const generateUploadUrl = mutation(async (ctx) => {
@@ -9,10 +10,18 @@ export const generateUploadUrl = mutation(async (ctx) => {
return await ctx.storage.generateUploadUrl(); return await ctx.storage.generateUploadUrl();
}); });
const isRemoteImageUrl = (value: string) =>
value.startsWith('http://') || value.startsWith('https://');
export const getImageUrl = query({ export const getImageUrl = query({
args: { storageId: v.id('_storage') }, args: { storageId: v.string() },
handler: async (ctx, { storageId }) => { handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId); if (isRemoteImageUrl(storageId)) return storageId;
return url ?? null; try {
const url = await ctx.storage.getUrl(storageId as Id<'_storage'>);
return url ?? null;
} catch {
return null;
}
}, },
}); });
+214
View File
@@ -0,0 +1,214 @@
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getRequiredUserId } from './model';
export const getInstallUrl = query({
args: {},
handler: () => {
const slug = process.env.GITHUB_APP_SLUG;
if (!slug) return null;
return `https://github.com/apps/${slug}/installations/new`;
},
});
export const getConnection = query({
args: {},
handler: async (ctx) => {
const userId = await getRequiredUserId(ctx);
return await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'github'),
)
.first();
},
});
export const connectInstallation = mutation({
args: { installationId: v.string() },
handler: async (ctx, { installationId }) => {
const userId = await getRequiredUserId(ctx);
const trimmedInstallationId = installationId.trim();
if (!trimmedInstallationId) {
throw new ConvexError('GitHub installation ID is required.');
}
const now = Date.now();
const existing = await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'github'),
)
.first();
const patch = {
provider: 'github' as const,
displayName: `GitHub installation ${trimmedInstallationId}`,
installationId: trimmedInstallationId,
scopes: [
'metadata:read',
'administration:write',
'contents:write',
'pull_requests:write',
],
status: 'active' as const,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('gitConnections', {
userId,
...patch,
connectedAt: now,
});
},
});
export const getConnectionForUser = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
return await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'github'),
)
.first();
},
});
export const upsertConnectionForUser = internalMutation({
args: {
userId: v.id('users'),
providerAccountId: v.optional(v.string()),
displayName: v.string(),
username: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
installationId: v.string(),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', args.userId).eq('provider', 'github'),
)
.first();
const patch = {
providerAccountId: args.providerAccountId,
displayName: args.displayName,
username: args.username,
avatarUrl: args.avatarUrl,
installationId: args.installationId,
scopes: [
'metadata:read',
'administration:write',
'contents:write',
'pull_requests:write',
'checks:write',
'statuses:write',
'issues:write',
],
status: 'active' as const,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('gitConnections', {
userId: args.userId,
provider: 'github',
...patch,
connectedAt: now,
});
},
});
export const createForkSpoonRecord = internalMutation({
args: {
ownerId: v.id('users'),
name: v.string(),
description: v.optional(v.string()),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
upstreamDefaultBranch: v.string(),
upstreamUrl: v.string(),
forkOwner: v.string(),
forkRepo: v.string(),
forkDefaultBranch: v.string(),
forkUrl: v.string(),
visibility: v.union(
v.literal('public'),
v.literal('private'),
v.literal('internal'),
v.literal('unknown'),
),
connectionId: v.optional(v.id('gitConnections')),
},
handler: async (ctx, args): Promise<Id<'spoons'>> => {
const now = Date.now();
const spoonId = await ctx.db.insert('spoons', {
ownerId: args.ownerId,
name: args.name,
description: args.description,
provider: 'github',
upstreamOwner: args.upstreamOwner,
upstreamRepo: args.upstreamRepo,
upstreamDefaultBranch: args.upstreamDefaultBranch,
upstreamUrl: args.upstreamUrl,
forkOwner: args.forkOwner,
forkRepo: args.forkRepo,
forkDefaultBranch: args.forkDefaultBranch,
forkUrl: args.forkUrl,
visibility: args.visibility,
maintenanceMode: 'watch',
syncCadence: 'daily',
productionRefStrategy: 'default_branch',
status: 'active',
syncStatus: 'unknown',
connectionId: args.connectionId,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonSettings', {
spoonId,
ownerId: args.ownerId,
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [],
importantFilePatterns: [],
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('syncRuns', {
spoonId,
ownerId: args.ownerId,
kind: 'manual_check',
status: 'clean',
summary: `Created GitHub fork ${args.forkOwner}/${args.forkRepo} from ${args.upstreamOwner}/${args.upstreamRepo}.`,
createdAt: now,
updatedAt: now,
});
return spoonId;
},
});
+185
View File
@@ -0,0 +1,185 @@
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
import { ConvexError } from 'convex/values';
import type { Doc } from './_generated/dataModel';
export type GitHubCommitSummary = {
sha: string;
message: string;
authorName?: string;
authorEmail?: string;
authorLogin?: string;
committedAt?: number;
htmlUrl?: string;
filesChanged?: number;
additions?: number;
deletions?: number;
};
export type GitHubPullRequestSummary = {
githubId: number;
number: number;
repoFullName: string;
title: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
authorLogin?: string;
baseRef: string;
headRef: string;
headRepoFullName?: string;
htmlUrl: string;
createdAtGithub?: number;
updatedAtGithub?: number;
mergedAtGithub?: number;
};
export type GitHubCompareSummary = {
aheadBy: number;
mergeBaseSha?: string;
headSha?: string;
baseSha?: string;
htmlUrl?: string;
commits: GitHubCommitSummary[];
};
const getEnv = (name: string) => {
const value = process.env[name]?.trim();
if (!value) throw new ConvexError(`${name} is not configured.`);
return value;
};
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
export const getInstallationOctokit = (installationId: string) =>
new Octokit({
authStrategy: createAppAuth,
auth: {
appId: getEnv('GITHUB_APP_ID'),
privateKey: normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY')),
installationId,
},
userAgent: 'Spoon',
request: {
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
},
});
export const getSpoonInstallationId = (
spoon: Doc<'spoons'>,
connection?: Doc<'gitConnections'> | null,
) => {
const installationId =
spoon.githubInstallationId ?? connection?.installationId ?? undefined;
if (!installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
return installationId;
};
export const getRepository = async (
octokit: Octokit,
owner: string,
repo: string,
) => {
const result = await octokit.rest.repos.get({ owner, repo });
return result.data;
};
const toMillis = (value?: string | null) =>
value ? new Date(value).getTime() : undefined;
const normalizeCompareCommit = (
commit: Awaited<
ReturnType<Octokit['rest']['repos']['compareCommitsWithBasehead']>
>['data']['commits'][number],
): GitHubCommitSummary => ({
sha: commit.sha,
message: commit.commit.message,
authorName: commit.commit.author?.name ?? undefined,
authorEmail: commit.commit.author?.email ?? undefined,
authorLogin: commit.author?.login ?? undefined,
committedAt: toMillis(
commit.commit.author?.date ?? commit.commit.committer?.date,
),
htmlUrl: commit.html_url,
});
export const compareAcrossForkNetwork = async (
octokit: Octokit,
args: {
owner: string;
repo: string;
baseOwner: string;
baseBranch: string;
headOwner: string;
headBranch: string;
},
): Promise<GitHubCompareSummary> => {
const basehead = `${args.baseOwner}:${args.baseBranch}...${args.headOwner}:${args.headBranch}`;
const result = await octokit.rest.repos.compareCommitsWithBasehead({
owner: args.owner,
repo: args.repo,
basehead,
per_page: 100,
});
const commits = result.data.commits.map(normalizeCompareCommit);
return {
aheadBy: result.data.ahead_by,
mergeBaseSha: result.data.merge_base_commit.sha,
headSha: commits[commits.length - 1]?.sha,
baseSha: result.data.base_commit.sha,
htmlUrl: result.data.html_url,
commits,
};
};
const normalizePullRequest = (
repoFullName: string,
pull: Awaited<ReturnType<Octokit['rest']['pulls']['list']>>['data'][number],
): GitHubPullRequestSummary => ({
githubId: pull.id,
number: pull.number,
repoFullName,
title: pull.title,
state: pull.merged_at ? 'merged' : pull.state === 'open' ? 'open' : 'closed',
draft: pull.draft === true,
authorLogin: pull.user?.login ?? undefined,
baseRef: pull.base.ref,
headRef: pull.head.ref,
headRepoFullName: pull.head.repo.full_name,
htmlUrl: pull.html_url,
createdAtGithub: toMillis(pull.created_at),
updatedAtGithub: toMillis(pull.updated_at),
mergedAtGithub: toMillis(pull.merged_at),
});
export const listPullRequests = async (
octokit: Octokit,
args: { owner: string; repo: string; head?: string },
) => {
const result = await octokit.rest.pulls.list({
owner: args.owner,
repo: args.repo,
state: 'all',
per_page: 100,
head: args.head,
});
return result.data.map((pull) =>
normalizePullRequest(`${args.owner}/${args.repo}`, pull),
);
};
export const syncForkBranch = async (
octokit: Octokit,
args: { forkOwner: string; forkRepo: string; branch: string },
) => {
const result = await octokit.rest.repos.mergeUpstream({
owner: args.forkOwner,
repo: args.forkRepo,
branch: args.branch,
});
return result.data;
};
+261
View File
@@ -0,0 +1,261 @@
'use node';
import { createSign } from 'node:crypto';
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';
type GitHubInstallationAccount = {
id?: number;
login?: string;
avatar_url?: string;
};
type GitHubInstallation = {
id: number;
account?: GitHubInstallationAccount;
};
type GitHubRepository = {
id: number;
name: string;
full_name: string;
private: boolean;
fork: boolean;
html_url: string;
default_branch: string;
owner: {
login: string;
};
description?: string | null;
};
type GitHubListRepositoriesResponse = {
repositories: GitHubRepository[];
};
const base64Url = (value: string | Buffer) =>
Buffer.from(value)
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
const getEnv = (name: string) => {
const value = process.env[name]?.trim();
if (!value) throw new ConvexError(`${name} is not configured.`);
return value;
};
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
const firstText = (...values: (string | null | undefined)[]) =>
values.map((value) => value?.trim()).find((value) => value);
const createGitHubAppJwt = () => {
const appId = getEnv('GITHUB_APP_ID');
const privateKey = normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY'));
const now = Math.floor(Date.now() / 1000);
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64Url(
JSON.stringify({
iat: now - 60,
exp: now + 9 * 60,
iss: appId,
}),
);
const body = `${header}.${payload}`;
const signature = createSign('RSA-SHA256').update(body).sign(privateKey);
return `${body}.${base64Url(signature)}`;
};
const githubFetch = async <T>(
path: string,
token: string,
init: RequestInit = {},
): Promise<T> => {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'User-Agent': 'Spoon',
'X-GitHub-Api-Version': '2022-11-28',
...init.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new ConvexError(
`GitHub API request failed (${response.status}): ${body}`,
);
}
return (await response.json()) as T;
};
const createInstallationToken = async (installationId: string) => {
const jwt = createGitHubAppJwt();
const result = await githubFetch<{ token: string; expires_at: string }>(
`/app/installations/${installationId}/access_tokens`,
jwt,
{ method: 'POST' },
);
return result.token;
};
const getRequiredUserId = async (ctx: ActionCtx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
export const syncConfiguredInstallation = action({
args: {},
handler: async (ctx): Promise<Id<'gitConnections'>> => {
const userId = await getRequiredUserId(ctx);
const installationId = getEnv('GITHUB_APP_INSTALLATION_ID');
const jwt = createGitHubAppJwt();
const installation = await githubFetch<GitHubInstallation>(
`/app/installations/${installationId}`,
jwt,
);
const account = installation.account;
const displayName =
account?.login ?? `GitHub installation ${installationId}`;
return await ctx.runMutation(internal.github.upsertConnectionForUser, {
userId,
providerAccountId: account?.id?.toString(),
displayName,
username: account?.login,
avatarUrl: account?.avatar_url,
installationId: installation.id.toString(),
});
},
});
export const listInstallationRepositories = action({
args: {},
handler: async (
ctx,
): Promise<
{
id: number;
name: string;
fullName: string;
owner: string;
private: boolean;
fork: boolean;
url: string;
defaultBranch: string;
description?: string;
}[]
> => {
const userId = await getRequiredUserId(ctx);
const connection = await ctx.runQuery(
internal.github.getConnectionForUser,
{
userId,
},
);
if (!connection?.installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
const token = await createInstallationToken(connection.installationId);
const result = await githubFetch<GitHubListRepositoriesResponse>(
'/installation/repositories?per_page=100',
token,
);
return result.repositories.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
private: repo.private,
fork: repo.fork,
url: repo.html_url,
defaultBranch: repo.default_branch,
description: repo.description ?? undefined,
}));
},
});
export const createFork = action({
args: {
upstreamOwner: v.string(),
upstreamRepo: v.string(),
name: v.optional(v.string()),
description: v.optional(v.string()),
organization: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'spoons'>> => {
const userId = await getRequiredUserId(ctx);
const connection = await ctx.runQuery(
internal.github.getConnectionForUser,
{
userId,
},
);
if (!connection?.installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
const upstreamOwner = args.upstreamOwner.trim();
const upstreamRepo = args.upstreamRepo.trim();
if (!upstreamOwner || !upstreamRepo) {
throw new ConvexError('Upstream owner and repository are required.');
}
const token = await createInstallationToken(connection.installationId);
const upstream = await githubFetch<GitHubRepository>(
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
upstreamRepo,
)}`,
token,
);
const body: Record<string, string | boolean> = {
default_branch_only: false,
};
const forkName = args.name?.trim();
if (forkName) body.name = forkName;
const organization = args.organization?.trim();
if (organization) body.organization = organization;
const fork = await githubFetch<GitHubRepository>(
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
upstreamRepo,
)}/forks`,
token,
{
method: 'POST',
body: JSON.stringify(body),
},
);
const description = firstText(args.description, upstream.description);
return await ctx.runMutation(internal.github.createForkSpoonRecord, {
ownerId: userId,
name: fork.name,
description,
upstreamOwner,
upstreamRepo,
upstreamDefaultBranch: upstream.default_branch,
upstreamUrl: upstream.html_url,
forkOwner: fork.owner.login,
forkRepo: fork.name,
forkDefaultBranch: fork.default_branch,
forkUrl: fork.html_url,
visibility: fork.private ? 'private' : 'public',
connectionId: connection._id,
});
},
});
+368
View File
@@ -0,0 +1,368 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import type { GitHubCompareSummary } from './githubClient';
import { internal } from './_generated/api';
import { action, internalAction } from './_generated/server';
import {
compareAcrossForkNetwork,
getInstallationOctokit,
getRepository,
getSpoonInstallationId,
listPullRequests,
syncForkBranch,
} from './githubClient';
const getRequiredUserId = async (ctx: ActionCtx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const toStatus = (upstreamAheadBy: number, forkAheadBy: number) => {
if (upstreamAheadBy === 0 && forkAheadBy === 0) return 'up_to_date' as const;
if (upstreamAheadBy > 0 && forkAheadBy === 0) return 'behind' as const;
if (upstreamAheadBy === 0 && forkAheadBy > 0) return 'ahead' as const;
if (upstreamAheadBy > 0 && forkAheadBy > 0) return 'diverged' as const;
return 'unknown' as const;
};
const getLastCommitAt = (compare: GitHubCompareSummary) =>
compare.commits[compare.commits.length - 1]?.committedAt;
const ensureForkMetadata = (spoon: Doc<'spoons'>) => {
if (!spoon.forkOwner || !spoon.forkRepo) {
throw new ConvexError('Fork metadata is required before GitHub refresh.');
}
return {
forkOwner: spoon.forkOwner,
forkRepo: spoon.forkRepo,
forkBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
};
};
const refreshOwnedSpoon = async (
ctx: ActionCtx,
ownerId: Id<'users'>,
spoonId: Id<'spoons'>,
kind: 'manual_check' | 'scheduled_check' = 'manual_check',
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}> => {
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
if (spoon.provider !== 'github') {
throw new ConvexError(
'GitHub refresh is only available for GitHub Spoons.',
);
}
const connection = await ctx.runQuery(internal.github.getConnectionForUser, {
userId: ownerId,
});
const installationId = getSpoonInstallationId(spoon, connection);
const { forkOwner, forkRepo, forkBranch } = ensureForkMetadata(spoon);
const syncRunId = await ctx.runMutation(internal.syncRuns.createInternal, {
spoonId,
ownerId,
kind,
status: 'running',
summary: 'Refreshing GitHub repository state.',
});
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: 'checking',
lastSyncRunId: syncRunId,
lastGithubRefreshAt: Date.now(),
});
try {
const octokit = getInstallationOctokit(installationId);
const [upstreamRepo, forkRepoData] = await Promise.all([
getRepository(octokit, spoon.upstreamOwner, spoon.upstreamRepo),
getRepository(octokit, forkOwner, forkRepo),
]);
const upstreamBranch =
spoon.upstreamDefaultBranch || upstreamRepo.default_branch;
const resolvedForkBranch = forkBranch || forkRepoData.default_branch;
const [upstreamCompare, forkCompare, forkPulls, upstreamPulls]: [
GitHubCompareSummary,
GitHubCompareSummary,
Awaited<ReturnType<typeof listPullRequests>>,
Awaited<ReturnType<typeof listPullRequests>>,
] = await Promise.all([
compareAcrossForkNetwork(octokit, {
owner: spoon.upstreamOwner,
repo: spoon.upstreamRepo,
baseOwner: forkOwner,
baseBranch: resolvedForkBranch,
headOwner: spoon.upstreamOwner,
headBranch: upstreamBranch,
}),
compareAcrossForkNetwork(octokit, {
owner: spoon.upstreamOwner,
repo: spoon.upstreamRepo,
baseOwner: spoon.upstreamOwner,
baseBranch: upstreamBranch,
headOwner: forkOwner,
headBranch: resolvedForkBranch,
}),
listPullRequests(octokit, { owner: forkOwner, repo: forkRepo }),
listPullRequests(octokit, {
owner: spoon.upstreamOwner,
repo: spoon.upstreamRepo,
head: `${forkOwner}:${resolvedForkBranch}`,
}),
]);
const status = toStatus(upstreamCompare.aheadBy, forkCompare.aheadBy);
const openForkPullRequestCount = forkPulls.filter(
(pull) => pull.state === 'open',
).length;
const openUpstreamPullRequestCount = upstreamPulls.filter(
(pull) => pull.state === 'open',
).length;
const now = Date.now();
await Promise.all([
ctx.runMutation(internal.spoonState.upsert, {
spoonId,
ownerId,
upstreamFullName: upstreamRepo.full_name,
forkFullName: forkRepoData.full_name,
upstreamDefaultBranch: upstreamRepo.default_branch,
forkDefaultBranch: forkRepoData.default_branch,
upstreamHeadSha: upstreamCompare.headSha,
forkHeadSha: forkCompare.headSha,
mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
status,
openForkPullRequestCount,
openUpstreamPullRequestCount,
lastCommitAt:
getLastCommitAt(upstreamCompare) ?? getLastCommitAt(forkCompare),
rawCompareUrl: upstreamCompare.htmlUrl,
}),
ctx.runMutation(internal.spoonCommits.replaceForSpoon, {
spoonId,
ownerId,
side: 'upstream',
commits: upstreamCompare.commits,
}),
ctx.runMutation(internal.spoonCommits.replaceForSpoon, {
spoonId,
ownerId,
side: 'fork',
commits: forkCompare.commits,
}),
ctx.runMutation(internal.spoonPullRequests.replaceForSpoon, {
spoonId,
ownerId,
scope: 'fork',
pullRequests: forkPulls,
}),
ctx.runMutation(internal.spoonPullRequests.replaceForSpoon, {
spoonId,
ownerId,
scope: 'from_fork_to_upstream',
pullRequests: upstreamPulls,
}),
]);
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: status,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
lastMergeBaseCommit:
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
lastUpstreamCommit: upstreamCompare.headSha,
lastForkCommit: forkCompare.headSha,
lastGithubRefreshAt: now,
lastSuccessfulRefreshAt: now,
lastCheckedAt: now,
lastError: '',
});
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: status === 'diverged' ? 'needs_review' : 'clean',
summary: `GitHub refresh complete: ${upstreamCompare.aheadBy} upstream commit(s), ${forkCompare.aheadBy} fork-only commit(s).`,
});
return {
success: true,
status,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: 'error',
lastGithubRefreshAt: Date.now(),
lastCheckedAt: Date.now(),
lastError: message,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error: message,
}),
]);
throw new ConvexError(message);
}
};
export const refreshSpoonGithubState = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}> => {
const ownerId = await getRequiredUserId(ctx);
return await refreshOwnedSpoon(ctx, ownerId, spoonId);
},
});
export const syncForkWithUpstream = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}> => {
const ownerId = await getRequiredUserId(ctx);
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
const state = await ctx.runQuery(internal.spoonState.getInternal, {
spoonId,
ownerId,
});
if (state?.status !== 'behind' || state.forkAheadBy !== 0) {
throw new ConvexError(
'Sync is only available for behind, non-diverged forks.',
);
}
const connection = await ctx.runQuery(
internal.github.getConnectionForUser,
{
userId: ownerId,
},
);
const installationId = getSpoonInstallationId(spoon, connection);
const { forkOwner, forkRepo, forkBranch } = ensureForkMetadata(spoon);
const syncRunId = await ctx.runMutation(internal.syncRuns.createInternal, {
spoonId,
ownerId,
kind: 'merge_attempt',
status: 'running',
summary: 'Syncing fork branch with upstream.',
});
try {
const octokit = getInstallationOctokit(installationId);
await syncForkBranch(octokit, {
forkOwner,
forkRepo,
branch: forkBranch,
});
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'merged',
summary: 'GitHub fork sync completed.',
});
return await refreshOwnedSpoon(ctx, ownerId, spoonId, 'manual_check');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const conflict = message.toLowerCase().includes('conflict');
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: conflict ? 'conflict' : 'failed',
error: message,
});
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: conflict ? 'conflict' : 'error',
lastError: message,
});
throw new ConvexError(message);
}
},
});
export const refreshDueSpoons = internalAction({
args: { limit: v.optional(v.number()) },
handler: async (
ctx,
{ limit },
): Promise<
(
| {
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}
| { success: false; spoonId: Id<'spoons'>; error: string }
)[]
> => {
const due: { spoonId: Id<'spoons'>; ownerId: Id<'users'> }[] =
await ctx.runQuery(internal.spoonSettings.listRefreshDue, {
limit: limit ?? 10,
});
const results: (
| {
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}
| { success: false; spoonId: Id<'spoons'>; error: string }
)[] = [];
for (const item of due) {
try {
results.push(
await refreshOwnedSpoon(
ctx,
item.ownerId,
item.spoonId,
'scheduled_check',
),
);
} catch (error) {
results.push({
success: false,
spoonId: item.spoonId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
},
});
+12
View File
@@ -5,6 +5,18 @@ declare const process: {
readonly USESEND_API_KEY?: string; readonly USESEND_API_KEY?: string;
readonly USESEND_URL?: string; readonly USESEND_URL?: string;
readonly USESEND_FROM_EMAIL?: string; readonly USESEND_FROM_EMAIL?: string;
readonly AUTH_GITHUB_ID?: string;
readonly AUTH_GITHUB_SECRET?: string;
readonly GITHUB_APP_ID?: string;
readonly GITHUB_APP_CLIENT_ID?: string;
readonly GITHUB_APP_CLIENT_SECRET?: string;
readonly GITHUB_APP_PRIVATE_KEY?: string;
readonly GITHUB_APP_WEBHOOK_SECRET?: string;
readonly GITHUB_APP_SLUG?: string;
readonly GITHUB_APP_INSTALLATION_ID?: string;
readonly GITHUB_APP_OWNER?: string;
readonly SPOON_ENCRYPTION_KEY?: string;
readonly SPOON_WORKER_TOKEN?: string;
readonly [key: string]: string | undefined; readonly [key: string]: string | undefined;
}; };
}; };
+15
View File
@@ -0,0 +1,15 @@
import { query } from './_generated/server';
import { getRequiredUserId } from './model';
export const getStatus = query({
args: {},
handler: async (ctx) => {
await getRequiredUserId(ctx);
return {
encryptionConfigured: Boolean(
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
process.env.INSTANCE_SECRET?.trim(),
),
};
},
});
+160
View File
@@ -0,0 +1,160 @@
import { ConvexError } from 'convex/values';
import OpenAI from 'openai';
type ReviewRisk = 'low' | 'medium' | 'high';
type ReviewAction = 'sync' | 'open_review_pr' | 'manual_review' | 'do_not_sync';
export type AiCompatibilityReview = {
summary: string;
risk: ReviewRisk;
compatible: boolean;
requiresHumanReview: boolean;
recommendedAction: ReviewAction;
potentialConflicts: string[];
importantFiles: string[];
reasoningSummary: string;
};
export type ReviewInput = {
spoonName: string;
upstreamFullName: string;
forkFullName: string;
status: string;
upstreamAheadBy: number;
forkAheadBy: number;
upstreamCommits: {
sha: string;
message: string;
authorName?: string;
committedAt?: number;
}[];
forkCommits: {
sha: string;
message: string;
authorName?: string;
committedAt?: number;
}[];
importantFilePatterns?: string[];
ignoredFilePatterns?: string[];
};
export type ReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh';
export type OpenAiReviewSettings = {
apiKey: string;
model: string;
reasoningEffort: ReasoningEffort;
};
const reviewSchema = {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'string' },
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
compatible: { type: 'boolean' },
requiresHumanReview: { type: 'boolean' },
recommendedAction: {
type: 'string',
enum: ['sync', 'open_review_pr', 'manual_review', 'do_not_sync'],
},
potentialConflicts: { type: 'array', items: { type: 'string' } },
importantFiles: { type: 'array', items: { type: 'string' } },
reasoningSummary: { type: 'string' },
},
required: [
'summary',
'risk',
'compatible',
'requiresHumanReview',
'recommendedAction',
'potentialConflicts',
'importantFiles',
'reasoningSummary',
],
} as const;
const isReviewRisk = (value: unknown): value is ReviewRisk =>
value === 'low' || value === 'medium' || value === 'high';
const isReviewAction = (value: unknown): value is ReviewAction =>
value === 'sync' ||
value === 'open_review_pr' ||
value === 'manual_review' ||
value === 'do_not_sync';
const validateReview = (value: unknown): AiCompatibilityReview => {
if (!value || typeof value !== 'object') {
throw new ConvexError('OpenAI returned an invalid review payload.');
}
const record = value as Record<string, unknown>;
if (
typeof record.summary !== 'string' ||
!isReviewRisk(record.risk) ||
typeof record.compatible !== 'boolean' ||
typeof record.requiresHumanReview !== 'boolean' ||
!isReviewAction(record.recommendedAction) ||
!Array.isArray(record.potentialConflicts) ||
!Array.isArray(record.importantFiles) ||
typeof record.reasoningSummary !== 'string'
) {
throw new ConvexError('OpenAI review did not match the expected schema.');
}
return {
summary: record.summary,
risk: record.risk,
compatible: record.compatible,
requiresHumanReview: record.requiresHumanReview,
recommendedAction: record.recommendedAction,
potentialConflicts: record.potentialConflicts.filter(
(item): item is string => typeof item === 'string',
),
importantFiles: record.importantFiles.filter(
(item): item is string => typeof item === 'string',
),
reasoningSummary: record.reasoningSummary,
};
};
export const reviewUpstreamCompatibility = async (
input: ReviewInput,
settings: OpenAiReviewSettings,
): Promise<AiCompatibilityReview> => {
const response = await new OpenAI({
apiKey: settings.apiKey,
}).responses.create({
model: settings.model,
store: false,
reasoning: {
effort: settings.reasoningEffort,
},
input: [
{
role: 'system',
content:
'You are reviewing whether upstream changes can be safely brought into a maintained fork. You do not execute code. You do not claim tests passed. Treat fork-only commits as user customizations that must be preserved. If changed files overlap with fork-only changes, increase risk. If patch context is incomplete, say so and require human review. Prefer conservative recommendations. Return only the required structured output.',
},
{
role: 'user',
content: JSON.stringify(input, null, 2),
},
],
text: {
format: {
type: 'json_schema',
name: 'spoon_upstream_compatibility_review',
strict: true,
schema: reviewSchema,
},
},
});
const raw = response.output_text;
if (!raw) throw new ConvexError('OpenAI returned an empty review.');
return validateReview(JSON.parse(raw));
};
+356
View File
@@ -104,6 +104,30 @@ const applicationTables = {
lastCheckedAt: v.optional(v.number()), lastCheckedAt: v.optional(v.number()),
lastUpstreamCommit: v.optional(v.string()), lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()), lastForkCommit: v.optional(v.string()),
connectionId: v.optional(v.id('gitConnections')),
githubInstallationId: v.optional(v.string()),
githubRepositoryId: v.optional(v.number()),
upstreamRepositoryId: v.optional(v.number()),
syncStatus: v.optional(
v.union(
v.literal('unknown'),
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('checking'),
v.literal('conflict'),
v.literal('error'),
),
),
upstreamAheadBy: v.optional(v.number()),
forkAheadBy: v.optional(v.number()),
lastMergeBaseCommit: v.optional(v.string()),
lastSyncRunId: v.optional(v.id('syncRuns')),
lastAiReviewId: v.optional(v.id('aiReviews')),
lastGithubRefreshAt: v.optional(v.number()),
lastSuccessfulRefreshAt: v.optional(v.number()),
lastError: v.optional(v.string()),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
@@ -111,6 +135,87 @@ const applicationTables = {
.index('by_owner_status', ['ownerId', 'status']) .index('by_owner_status', ['ownerId', 'status'])
.index('by_owner_provider', ['ownerId', 'provider']) .index('by_owner_provider', ['ownerId', 'provider'])
.index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']), .index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']),
spoonRepositoryStates: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFullName: v.string(),
forkFullName: v.string(),
upstreamDefaultBranch: v.string(),
forkDefaultBranch: v.string(),
upstreamHeadSha: v.optional(v.string()),
forkHeadSha: v.optional(v.string()),
mergeBaseSha: v.optional(v.string()),
upstreamAheadBy: v.number(),
forkAheadBy: v.number(),
status: v.union(
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('unknown'),
),
openForkPullRequestCount: v.number(),
openUpstreamPullRequestCount: v.number(),
lastCommitAt: v.optional(v.number()),
rawCompareUrl: v.optional(v.string()),
refreshedAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_status', ['ownerId', 'status']),
spoonCommits: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
sha: v.string(),
side: v.union(v.literal('upstream'), v.literal('fork')),
message: v.string(),
authorName: v.optional(v.string()),
authorEmail: v.optional(v.string()),
authorLogin: v.optional(v.string()),
committedAt: v.optional(v.number()),
htmlUrl: v.optional(v.string()),
filesChanged: v.optional(v.number()),
additions: v.optional(v.number()),
deletions: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon_side', ['spoonId', 'side'])
.index('by_owner', ['ownerId'])
.index('by_sha', ['spoonId', 'sha'])
.index('by_committed', ['spoonId', 'committedAt']),
spoonPullRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
githubId: v.number(),
number: v.number(),
repoFullName: v.string(),
scope: v.union(
v.literal('fork'),
v.literal('upstream'),
v.literal('from_fork_to_upstream'),
),
title: v.string(),
state: v.union(v.literal('open'), v.literal('closed'), v.literal('merged')),
draft: v.boolean(),
authorLogin: v.optional(v.string()),
baseRef: v.string(),
headRef: v.string(),
headRepoFullName: v.optional(v.string()),
htmlUrl: v.string(),
createdAtGithub: v.optional(v.number()),
updatedAtGithub: v.optional(v.number()),
mergedAtGithub: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_spoon_scope', ['spoonId', 'scope'])
.index('by_owner', ['ownerId'])
.index('by_github_id', ['githubId'])
.index('by_state', ['spoonId', 'state']),
syncRuns: defineTable({ syncRuns: defineTable({
spoonId: v.id('spoons'), spoonId: v.id('spoons'),
ownerId: v.id('users'), ownerId: v.id('users'),
@@ -143,10 +248,113 @@ const applicationTables = {
.index('by_spoon', ['spoonId']) .index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status']) .index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']), .index('by_created', ['createdAt']),
aiReviews: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
syncRunId: v.optional(v.id('syncRuns')),
model: v.string(),
status: v.union(
v.literal('queued'),
v.literal('running'),
v.literal('completed'),
v.literal('failed'),
),
reviewType: v.union(
v.literal('upstream_update'),
v.literal('manual_prompt'),
v.literal('merge_safety'),
),
inputSummary: v.string(),
outputSummary: v.optional(v.string()),
risk: v.union(
v.literal('unknown'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
),
compatible: v.boolean(),
requiresHumanReview: v.boolean(),
recommendedAction: v.union(
v.literal('sync'),
v.literal('open_review_pr'),
v.literal('manual_review'),
v.literal('do_not_sync'),
v.literal('unknown'),
),
potentialConflicts: v.optional(v.array(v.string())),
importantFiles: v.optional(v.array(v.string())),
reasoningSummary: v.optional(v.string()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_status', ['ownerId', 'status'])
.index('by_sync_run', ['syncRunId'])
.index('by_created', ['createdAt']),
spoonSettings: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
autoRefreshEnabled: v.boolean(),
autoReviewEnabled: v.boolean(),
autoSyncEnabled: v.boolean(),
requireAiLowRiskForSync: v.boolean(),
requireCleanCompareForSync: v.boolean(),
ignoredFilePatterns: v.optional(v.array(v.string())),
importantFilePatterns: v.optional(v.array(v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
spoonRemotes: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
label: v.string(),
url: v.string(),
remoteName: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
userAiSettings: defineTable({
userId: v.id('users'),
provider: v.literal('openai'),
encryptedApiKey: v.optional(v.string()),
apiKeyPreview: v.optional(v.string()),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider']),
agentRequests: defineTable({ agentRequests: defineTable({
spoonId: v.id('spoons'), spoonId: v.id('spoons'),
ownerId: v.id('users'), ownerId: v.id('users'),
agentJobId: v.optional(v.id('agentJobs')),
prompt: v.string(), prompt: v.string(),
requestType: v.optional(
v.union(
v.literal('manual_prompt'),
v.literal('upstream_review'),
v.literal('future_code_change'),
),
),
priority: v.optional(
v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
),
source: v.optional(v.union(v.literal('user'), v.literal('system'))),
status: v.union( status: v.union(
v.literal('draft'), v.literal('draft'),
v.literal('queued'), v.literal('queued'),
@@ -157,6 +365,9 @@ const applicationTables = {
v.literal('cancelled'), v.literal('cancelled'),
), ),
targetBranch: v.optional(v.string()), targetBranch: v.optional(v.string()),
selectedSecretIds: v.optional(v.array(v.id('spoonSecrets'))),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()), mergeRequestUrl: v.optional(v.string()),
summary: v.optional(v.string()), summary: v.optional(v.string()),
error: v.optional(v.string()), error: v.optional(v.string()),
@@ -167,6 +378,151 @@ const applicationTables = {
.index('by_spoon', ['spoonId']) .index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status']) .index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']), .index('by_created', ['createdAt']),
spoonSecrets: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
name: v.string(),
encryptedValue: v.string(),
valuePreview: v.optional(v.string()),
description: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_name', ['spoonId', 'name']),
spoonAgentSettings: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
enabled: v.boolean(),
defaultBaseBranch: v.optional(v.string()),
branchPrefix: v.string(),
installCommand: v.optional(v.string()),
checkCommand: v.optional(v.string()),
testCommand: v.optional(v.string()),
agentModel: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
maxJobDurationMs: v.number(),
maxOutputBytes: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
agentJobs: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
agentRequestId: v.id('agentRequests'),
status: v.union(
v.literal('queued'),
v.literal('claimed'),
v.literal('preparing'),
v.literal('running'),
v.literal('checks_running'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('failed'),
v.literal('cancelled'),
v.literal('timed_out'),
),
prompt: v.string(),
baseBranch: v.string(),
workBranch: v.string(),
githubInstallationId: v.optional(v.string()),
forkOwner: v.string(),
forkRepo: v.string(),
forkUrl: v.string(),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
selectedSecretIds: v.array(v.id('spoonSecrets')),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
commitSha: v.optional(v.string()),
pullRequestUrl: v.optional(v.string()),
pullRequestNumber: v.optional(v.number()),
summary: v.optional(v.string()),
error: v.optional(v.string()),
claimedBy: v.optional(v.string()),
claimedAt: v.optional(v.number()),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_request', ['agentRequestId'])
.index('by_status', ['status'])
.index('by_claim', ['status', 'createdAt']),
agentJobEvents: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
level: v.union(
v.literal('debug'),
v.literal('info'),
v.literal('warn'),
v.literal('error'),
),
phase: v.union(
v.literal('queued'),
v.literal('clone'),
v.literal('plan'),
v.literal('edit'),
v.literal('install'),
v.literal('check'),
v.literal('test'),
v.literal('commit'),
v.literal('push'),
v.literal('pr'),
v.literal('cleanup'),
),
message: v.string(),
metadata: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
agentJobArtifacts: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
kind: v.union(
v.literal('plan'),
v.literal('diff'),
v.literal('test_output'),
v.literal('summary'),
v.literal('error'),
v.literal('pr_body'),
),
title: v.string(),
content: v.string(),
contentType: v.union(
v.literal('text/markdown'),
v.literal('text/plain'),
v.literal('application/json'),
v.literal('text/x-diff'),
),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
}; };
export default defineSchema({ export default defineSchema({
+56
View File
@@ -0,0 +1,56 @@
'use node';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { ConvexError } from 'convex/values';
const getSecret = () => {
const secret =
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
process.env.INSTANCE_SECRET?.trim();
if (!secret) {
throw new ConvexError(
'SPOON_ENCRYPTION_KEY is not configured. Add it before storing user API keys.',
);
}
return secret;
};
const getKey = () => createHash('sha256').update(getSecret()).digest();
export const encryptSecret = (plaintext: string) => {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return [
iv.toString('base64url'),
tag.toString('base64url'),
ciphertext.toString('base64url'),
].join('.');
};
export const decryptSecret = (encrypted: string) => {
const [ivRaw, tagRaw, ciphertextRaw] = encrypted.split('.');
if (!ivRaw || !tagRaw || !ciphertextRaw) {
throw new ConvexError('Stored secret has an invalid format.');
}
const decipher = createDecipheriv(
'aes-256-gcm',
getKey(),
Buffer.from(ivRaw, 'base64url'),
);
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextRaw, 'base64url')),
decipher.final(),
]);
return plaintext.toString('utf8');
};
@@ -0,0 +1,118 @@
import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
const defaults = {
enabled: true,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high' as const,
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
};
export const getForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
const settings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
return (
settings ?? {
spoonId,
ownerId,
...defaults,
defaultBaseBranch:
spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
}
);
},
});
export const update = mutation({
args: {
spoonId: v.id('spoons'),
enabled: v.optional(v.boolean()),
defaultBaseBranch: v.optional(v.string()),
branchPrefix: v.optional(v.string()),
installCommand: v.optional(v.string()),
checkCommand: v.optional(v.string()),
testCommand: v.optional(v.string()),
agentModel: v.optional(v.string()),
reasoningEffort: v.optional(reasoningEffort),
maxJobDurationMs: v.optional(v.number()),
maxOutputBytes: v.optional(v.number()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, args.spoonId, ownerId);
const now = Date.now();
let settings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.first();
if (!settings) {
const id = await ctx.db.insert('spoonAgentSettings', {
spoonId: args.spoonId,
ownerId,
...defaults,
defaultBaseBranch:
spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
createdAt: now,
updatedAt: now,
});
settings = await ctx.db.get(id);
}
if (!settings) throw new Error('Agent settings not found.');
const patch: Partial<Doc<'spoonAgentSettings'>> = { updatedAt: now };
if (args.enabled !== undefined) patch.enabled = args.enabled;
if (args.defaultBaseBranch !== undefined) {
patch.defaultBaseBranch = optionalText(args.defaultBaseBranch);
}
if (args.branchPrefix !== undefined) {
patch.branchPrefix =
optionalText(args.branchPrefix) ?? defaults.branchPrefix;
}
if (args.installCommand !== undefined) {
patch.installCommand = optionalText(args.installCommand);
}
if (args.checkCommand !== undefined) {
patch.checkCommand = optionalText(args.checkCommand);
}
if (args.testCommand !== undefined) {
patch.testCommand = optionalText(args.testCommand);
}
if (args.agentModel !== undefined) {
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
}
if (args.reasoningEffort !== undefined) {
patch.reasoningEffort = args.reasoningEffort;
}
if (args.maxJobDurationMs !== undefined) {
patch.maxJobDurationMs = Math.max(60_000, args.maxJobDurationMs);
}
if (args.maxOutputBytes !== undefined) {
patch.maxOutputBytes = Math.max(10_000, args.maxOutputBytes);
}
await ctx.db.patch(settings._id, patch);
return { success: true };
},
});
+99
View File
@@ -0,0 +1,99 @@
import { v } from 'convex/values';
import { internalMutation, internalQuery, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const side = v.union(v.literal('upstream'), v.literal('fork'));
export const listForSpoon = query({
args: {
spoonId: v.id('spoons'),
side: v.optional(side),
limit: v.optional(v.number()),
},
handler: async (ctx, { spoonId, side, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
if (side) {
return await ctx.db
.query('spoonCommits')
.withIndex('by_spoon_side', (q) =>
q.eq('spoonId', spoonId).eq('side', side),
)
.order('desc')
.take(limit ?? 100);
}
const commits = await ctx.db
.query('spoonCommits')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.collect();
return commits
.filter((commit) => commit.spoonId === spoonId)
.slice(0, limit ?? 100);
},
});
export const listInternal = internalQuery({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
side,
limit: v.optional(v.number()),
},
handler: async (ctx, { spoonId, ownerId, side, limit }) => {
const rows = await ctx.db
.query('spoonCommits')
.withIndex('by_spoon_side', (q) =>
q.eq('spoonId', spoonId).eq('side', side),
)
.order('desc')
.take(limit ?? 100);
return rows.filter((row) => row.ownerId === ownerId);
},
});
export const replaceForSpoon = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
side,
commits: v.array(
v.object({
sha: v.string(),
message: v.string(),
authorName: v.optional(v.string()),
authorEmail: v.optional(v.string()),
authorLogin: v.optional(v.string()),
committedAt: v.optional(v.number()),
htmlUrl: v.optional(v.string()),
filesChanged: v.optional(v.number()),
additions: v.optional(v.number()),
deletions: v.optional(v.number()),
}),
),
},
handler: async (ctx, { spoonId, ownerId, side, commits }) => {
const existing = await ctx.db
.query('spoonCommits')
.withIndex('by_spoon_side', (q) =>
q.eq('spoonId', spoonId).eq('side', side),
)
.collect();
await Promise.all(existing.map((commit) => ctx.db.delete(commit._id)));
const now = Date.now();
await Promise.all(
commits.map((commit) =>
ctx.db.insert('spoonCommits', {
spoonId,
ownerId,
side,
...commit,
createdAt: now,
updatedAt: now,
}),
),
);
return { success: true };
},
});
@@ -0,0 +1,97 @@
import { v } from 'convex/values';
import { internalMutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const scope = v.union(
v.literal('fork'),
v.literal('upstream'),
v.literal('from_fork_to_upstream'),
);
const state = v.union(
v.literal('open'),
v.literal('closed'),
v.literal('merged'),
);
export const listForSpoon = query({
args: {
spoonId: v.id('spoons'),
scope: v.optional(scope),
state: v.optional(state),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const requestedScope = args.scope;
const rows = requestedScope
? await ctx.db
.query('spoonPullRequests')
.withIndex('by_spoon_scope', (q) =>
q.eq('spoonId', args.spoonId).eq('scope', requestedScope),
)
.order('desc')
.collect()
: await ctx.db
.query('spoonPullRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.order('desc')
.collect();
return rows
.filter((row) => !args.state || row.state === args.state)
.slice(0, args.limit ?? 100);
},
});
export const replaceForSpoon = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
scope,
pullRequests: v.array(
v.object({
githubId: v.number(),
number: v.number(),
repoFullName: v.string(),
title: v.string(),
state,
draft: v.boolean(),
authorLogin: v.optional(v.string()),
baseRef: v.string(),
headRef: v.string(),
headRepoFullName: v.optional(v.string()),
htmlUrl: v.string(),
createdAtGithub: v.optional(v.number()),
updatedAtGithub: v.optional(v.number()),
mergedAtGithub: v.optional(v.number()),
}),
),
},
handler: async (ctx, { spoonId, ownerId, scope, pullRequests }) => {
const existing = await ctx.db
.query('spoonPullRequests')
.withIndex('by_spoon_scope', (q) =>
q.eq('spoonId', spoonId).eq('scope', scope),
)
.collect();
await Promise.all(
existing.map((pullRequest) => ctx.db.delete(pullRequest._id)),
);
const now = Date.now();
await Promise.all(
pullRequests.map((pullRequest) =>
ctx.db.insert('spoonPullRequests', {
spoonId,
ownerId,
scope,
...pullRequest,
createdAt: now,
updatedAt: now,
}),
),
);
return { success: true };
},
});
+58
View File
@@ -0,0 +1,58 @@
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
optionalText,
requireText,
} from './model';
export const listForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('spoonRemotes')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('asc')
.collect();
},
});
export const create = mutation({
args: {
spoonId: v.id('spoons'),
label: v.string(),
url: v.string(),
remoteName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const now = Date.now();
return await ctx.db.insert('spoonRemotes', {
spoonId: args.spoonId,
ownerId,
label: requireText(args.label, 'Remote label'),
url: requireText(args.url, 'Remote URL'),
remoteName: optionalText(args.remoteName),
createdAt: now,
updatedAt: now,
});
},
});
export const remove = mutation({
args: { remoteId: v.id('spoonRemotes') },
handler: async (ctx, { remoteId }) => {
const ownerId = await getRequiredUserId(ctx);
const remote = await ctx.db.get(remoteId);
if (remote?.ownerId !== ownerId) {
throw new ConvexError('Remote not found.');
}
await ctx.db.delete(remoteId);
return { success: true };
},
});
+104
View File
@@ -0,0 +1,104 @@
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const publicSecret = (secret: Doc<'spoonSecrets'>) => ({
_id: secret._id,
_creationTime: secret._creationTime,
spoonId: secret.spoonId,
ownerId: secret.ownerId,
name: secret.name,
valuePreview: secret.valuePreview,
description: secret.description,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt,
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
const secrets = await ctx.db
.query('spoonSecrets')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('asc')
.collect();
return secrets.map(publicSecret);
},
});
export const remove = mutation({
args: { secretId: v.id('spoonSecrets') },
handler: async (ctx, { secretId }) => {
const ownerId = await getRequiredUserId(ctx);
const secret = await ctx.db.get(secretId);
if (secret?.ownerId !== ownerId) {
throw new ConvexError('Spoon secret not found.');
}
await ctx.db.delete(secretId);
return { success: true };
},
});
export const upsertEncryptedInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
name: v.string(),
encryptedValue: v.string(),
valuePreview: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query('spoonSecrets')
.withIndex('by_name', (q) =>
q.eq('spoonId', args.spoonId).eq('name', args.name),
)
.first();
if (existing) {
if (existing.ownerId !== args.ownerId) {
throw new ConvexError('Spoon secret not found.');
}
await ctx.db.patch(existing._id, {
encryptedValue: args.encryptedValue,
valuePreview: args.valuePreview,
description: args.description,
updatedAt: now,
});
return existing._id;
}
return await ctx.db.insert('spoonSecrets', {
...args,
createdAt: now,
updatedAt: now,
});
},
});
export const patchEncryptedInternal = internalMutation({
args: {
secretId: v.id('spoonSecrets'),
ownerId: v.id('users'),
encryptedValue: v.optional(v.string()),
valuePreview: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
const secret = await ctx.db.get(args.secretId);
if (secret?.ownerId !== args.ownerId) {
throw new ConvexError('Spoon secret not found.');
}
await ctx.db.patch(args.secretId, {
encryptedValue: args.encryptedValue ?? secret.encryptedValue,
valuePreview: args.valuePreview ?? secret.valuePreview,
description: args.description,
updatedAt: Date.now(),
});
return { success: true };
},
});
@@ -0,0 +1,94 @@
'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 { encryptSecret } from './secretCrypto';
const secretNamePattern = /^[A-Z_][A-Z0-9_]*$/;
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const previewSecret = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return 'empty';
if (trimmed.length <= 8) return 'configured';
return `${trimmed.slice(0, 3)}...${trimmed.slice(-3)}`;
};
const normalizeName = (name: string) => {
const normalized = name.trim().toUpperCase();
if (!secretNamePattern.test(normalized)) {
throw new ConvexError(
'Secret names must look like environment variables, for example AUTH_SECRET.',
);
}
return normalized;
};
const optionalText = (value?: string) => {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed;
};
export const create = action({
args: {
spoonId: v.id('spoons'),
name: v.string(),
value: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'spoonSecrets'>> => {
const ownerId = await getRequiredUserId(ctx);
await ctx.runQuery(internal.spoons.getOwnedForAction, {
spoonId: args.spoonId,
ownerId,
});
const value = args.value.trim();
if (!value) throw new ConvexError('Secret value is required.');
return await ctx.runMutation(
internal.spoonSecrets.upsertEncryptedInternal,
{
spoonId: args.spoonId,
ownerId,
name: normalizeName(args.name),
encryptedValue: encryptSecret(value),
valuePreview: previewSecret(value),
description: optionalText(args.description),
},
);
},
});
export const update = action({
args: {
secretId: v.id('spoonSecrets'),
value: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args): Promise<{ success: true }> => {
const ownerId = await getRequiredUserId(ctx);
const patch = args.value?.trim()
? {
encryptedValue: encryptSecret(args.value.trim()),
valuePreview: previewSecret(args.value.trim()),
}
: {};
await ctx.runMutation(internal.spoonSecrets.patchEncryptedInternal, {
secretId: args.secretId,
ownerId,
description: optionalText(args.description),
...patch,
});
return { success: true };
},
});
+154
View File
@@ -0,0 +1,154 @@
import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const defaultSettings = {
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [] as string[],
importantFilePatterns: [] as string[],
};
export const getForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return (
(await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first()) ?? { spoonId, ownerId, ...defaultSettings }
);
},
});
export const ensureForSpoon = internalMutation({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
const existing = await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
if (existing) return existing._id;
const now = Date.now();
return await ctx.db.insert('spoonSettings', {
spoonId,
ownerId,
...defaultSettings,
createdAt: now,
updatedAt: now,
});
},
});
export const getInternal = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
const settings = await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
if (settings && settings.ownerId !== ownerId) {
throw new Error('Spoon settings ownership mismatch.');
}
return settings;
},
});
export const listRefreshDue = internalQuery({
args: { limit: v.number() },
handler: async (ctx, { limit }) => {
const now = Date.now();
const settings = await ctx.db.query('spoonSettings').collect();
const due = [];
for (const item of settings) {
if (!item.autoRefreshEnabled) continue;
const spoon = await ctx.db.get(item.spoonId);
if (
!spoon ||
spoon.status === 'archived' ||
spoon.provider !== 'github'
) {
continue;
}
if (spoon.syncCadence === 'manual') continue;
const cadenceMs =
spoon.syncCadence === 'weekly'
? 7 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
const last = spoon.lastGithubRefreshAt ?? spoon.lastCheckedAt ?? 0;
if (now - last >= cadenceMs) {
due.push({ spoonId: item.spoonId, ownerId: item.ownerId });
}
if (due.length >= limit) break;
}
return due;
},
});
export const update = mutation({
args: {
spoonId: v.id('spoons'),
autoRefreshEnabled: v.optional(v.boolean()),
autoReviewEnabled: v.optional(v.boolean()),
autoSyncEnabled: v.optional(v.boolean()),
requireAiLowRiskForSync: v.optional(v.boolean()),
requireCleanCompareForSync: v.optional(v.boolean()),
ignoredFilePatterns: v.optional(v.array(v.string())),
importantFilePatterns: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
let settings = await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.first();
if (!settings) {
const id = await ctx.db.insert('spoonSettings', {
spoonId: args.spoonId,
ownerId,
...defaultSettings,
createdAt: Date.now(),
updatedAt: Date.now(),
});
settings = await ctx.db.get(id);
}
const patch: Partial<Doc<'spoonSettings'>> = { updatedAt: Date.now() };
if (args.autoRefreshEnabled !== undefined) {
patch.autoRefreshEnabled = args.autoRefreshEnabled;
}
if (args.autoReviewEnabled !== undefined) {
patch.autoReviewEnabled = args.autoReviewEnabled;
}
if (args.autoSyncEnabled !== undefined) {
patch.autoSyncEnabled = args.autoSyncEnabled;
}
if (args.requireAiLowRiskForSync !== undefined) {
patch.requireAiLowRiskForSync = args.requireAiLowRiskForSync;
}
if (args.requireCleanCompareForSync !== undefined) {
patch.requireCleanCompareForSync = args.requireCleanCompareForSync;
}
if (args.ignoredFilePatterns !== undefined) {
patch.ignoredFilePatterns = args.ignoredFilePatterns;
}
if (args.importantFilePatterns !== undefined) {
patch.importantFilePatterns = args.importantFilePatterns;
}
if (!settings) throw new Error('Spoon settings not found.');
await ctx.db.patch(settings._id, patch);
return { success: true };
},
});
+123
View File
@@ -0,0 +1,123 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, internalQuery, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const syncStatus = v.union(
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('unknown'),
);
export const deriveSyncStatus = ({
upstreamAheadBy,
forkAheadBy,
}: {
upstreamAheadBy: number;
forkAheadBy: number;
}): Doc<'spoonRepositoryStates'>['status'] => {
if (upstreamAheadBy === 0 && forkAheadBy === 0) return 'up_to_date';
if (upstreamAheadBy > 0 && forkAheadBy === 0) return 'behind';
if (upstreamAheadBy === 0 && forkAheadBy > 0) return 'ahead';
if (upstreamAheadBy > 0 && forkAheadBy > 0) return 'diverged';
return 'unknown';
};
export const getForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
},
});
export const listForOwner = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
return await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
},
});
export const getInternal = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
const state = await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
if (state && state.ownerId !== ownerId) {
throw new ConvexError('Repository state ownership mismatch.');
}
return state;
},
});
export const upsert = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFullName: v.string(),
forkFullName: v.string(),
upstreamDefaultBranch: v.string(),
forkDefaultBranch: v.string(),
upstreamHeadSha: v.optional(v.string()),
forkHeadSha: v.optional(v.string()),
mergeBaseSha: v.optional(v.string()),
upstreamAheadBy: v.number(),
forkAheadBy: v.number(),
status: syncStatus,
openForkPullRequestCount: v.number(),
openUpstreamPullRequestCount: v.number(),
lastCommitAt: v.optional(v.number()),
rawCompareUrl: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'spoonRepositoryStates'>> => {
const now = Date.now();
const existing = await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.first();
const patch = {
ownerId: args.ownerId,
upstreamFullName: args.upstreamFullName,
forkFullName: args.forkFullName,
upstreamDefaultBranch: args.upstreamDefaultBranch,
forkDefaultBranch: args.forkDefaultBranch,
upstreamHeadSha: args.upstreamHeadSha,
forkHeadSha: args.forkHeadSha,
mergeBaseSha: args.mergeBaseSha,
upstreamAheadBy: args.upstreamAheadBy,
forkAheadBy: args.forkAheadBy,
status: args.status,
openForkPullRequestCount: args.openForkPullRequestCount,
openUpstreamPullRequestCount: args.openUpstreamPullRequestCount,
lastCommitAt: args.lastCommitAt,
rawCompareUrl: args.rawCompareUrl,
refreshedAt: now,
updatedAt: now,
};
if (existing) {
if (existing.ownerId !== args.ownerId) {
throw new ConvexError('Repository state ownership mismatch.');
}
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('spoonRepositoryStates', {
spoonId: args.spoonId,
...patch,
createdAt: now,
});
},
});
+140 -3
View File
@@ -1,7 +1,12 @@
import { ConvexError, v } from 'convex/values'; import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel'; import type { Doc } from './_generated/dataModel';
import { mutation, query } from './_generated/server'; import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { import {
getOwnedSpoon, getOwnedSpoon,
getRequiredUserId, getRequiredUserId,
@@ -49,6 +54,17 @@ const spoonStatus = v.union(
v.literal('archived'), v.literal('archived'),
); );
const spoonSyncStatus = v.union(
v.literal('unknown'),
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('checking'),
v.literal('conflict'),
v.literal('error'),
);
const hasForkMetadata = (args: { const hasForkMetadata = (args: {
forkOwner?: string; forkOwner?: string;
forkRepo?: string; forkRepo?: string;
@@ -79,6 +95,48 @@ export const get = query({
}, },
}); });
export const getDetails = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
const [state, settings, latestReview, recentRuns, agentRequests] =
await Promise.all([
ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first(),
ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first(),
ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.first(),
ctx.db
.query('syncRuns')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
ctx.db
.query('agentRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
]);
return { spoon, state, settings, latestReview, recentRuns, agentRequests };
},
});
export const getOwnedForAction = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
return await getOwnedSpoon(ctx, spoonId, ownerId);
},
});
export const createManual = mutation({ export const createManual = mutation({
args: { args: {
name: v.string(), name: v.string(),
@@ -103,12 +161,13 @@ export const createManual = mutation({
const now = Date.now(); const now = Date.now();
const forkOwner = optionalText(args.forkOwner); const forkOwner = optionalText(args.forkOwner);
const forkRepo = optionalText(args.forkRepo); const forkRepo = optionalText(args.forkRepo);
const forkDefaultBranch = optionalText(args.forkDefaultBranch);
const forkUrl = optionalText(args.forkUrl); const forkUrl = optionalText(args.forkUrl);
const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl }) const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl })
? 'draft' ? 'draft'
: 'needs_connection'; : 'needs_connection';
return await ctx.db.insert('spoons', { const spoonId = await ctx.db.insert('spoons', {
ownerId, ownerId,
name: requireText(args.name, 'Spoon name'), name: requireText(args.name, 'Spoon name'),
description: optionalText(args.description), description: optionalText(args.description),
@@ -122,7 +181,7 @@ export const createManual = mutation({
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'), upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
forkOwner, forkOwner,
forkRepo, forkRepo,
forkDefaultBranch: optionalText(args.forkDefaultBranch), forkDefaultBranch,
forkUrl, forkUrl,
visibility: args.visibility, visibility: args.visibility,
maintenanceMode: args.maintenanceMode, maintenanceMode: args.maintenanceMode,
@@ -130,9 +189,87 @@ export const createManual = mutation({
productionRefStrategy: args.productionRefStrategy, productionRefStrategy: args.productionRefStrategy,
tagPattern: optionalText(args.tagPattern), tagPattern: optionalText(args.tagPattern),
status, status,
syncStatus: 'unknown',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
await ctx.db.insert('spoonSettings', {
spoonId,
ownerId,
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [],
importantFilePatterns: [],
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonAgentSettings', {
spoonId,
ownerId,
enabled: true,
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high',
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
createdAt: now,
updatedAt: now,
});
return spoonId;
},
});
export const patchSyncFields = internalMutation({
args: {
spoonId: v.id('spoons'),
syncStatus: v.optional(spoonSyncStatus),
upstreamAheadBy: v.optional(v.number()),
forkAheadBy: v.optional(v.number()),
lastMergeBaseCommit: v.optional(v.string()),
lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()),
lastSyncRunId: v.optional(v.id('syncRuns')),
lastAiReviewId: v.optional(v.id('aiReviews')),
lastGithubRefreshAt: v.optional(v.number()),
lastSuccessfulRefreshAt: v.optional(v.number()),
lastCheckedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
},
handler: async (ctx, args) => {
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
if (args.syncStatus !== undefined) patch.syncStatus = args.syncStatus;
if (args.upstreamAheadBy !== undefined) {
patch.upstreamAheadBy = args.upstreamAheadBy;
}
if (args.forkAheadBy !== undefined) patch.forkAheadBy = args.forkAheadBy;
if (args.lastMergeBaseCommit !== undefined) {
patch.lastMergeBaseCommit = args.lastMergeBaseCommit;
}
if (args.lastUpstreamCommit !== undefined) {
patch.lastUpstreamCommit = args.lastUpstreamCommit;
}
if (args.lastForkCommit !== undefined) {
patch.lastForkCommit = args.lastForkCommit;
}
if (args.lastSyncRunId !== undefined)
patch.lastSyncRunId = args.lastSyncRunId;
if (args.lastAiReviewId !== undefined)
patch.lastAiReviewId = args.lastAiReviewId;
if (args.lastGithubRefreshAt !== undefined) {
patch.lastGithubRefreshAt = args.lastGithubRefreshAt;
}
if (args.lastSuccessfulRefreshAt !== undefined) {
patch.lastSuccessfulRefreshAt = args.lastSuccessfulRefreshAt;
}
if (args.lastCheckedAt !== undefined)
patch.lastCheckedAt = args.lastCheckedAt;
if (args.lastError !== undefined) patch.lastError = args.lastError;
await ctx.db.patch(args.spoonId, patch);
return { success: true };
}, },
}); });
+72 -1
View File
@@ -1,8 +1,27 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { query } from './_generated/server'; import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model'; import { getOwnedSpoon, getRequiredUserId } from './model';
const syncKind = v.union(
v.literal('scheduled_check'),
v.literal('manual_check'),
v.literal('upstream_update'),
v.literal('merge_attempt'),
v.literal('ai_review'),
);
const syncStatus = v.union(
v.literal('queued'),
v.literal('running'),
v.literal('clean'),
v.literal('conflict'),
v.literal('needs_review'),
v.literal('failed'),
v.literal('merged'),
);
export const listRecent = query({ export const listRecent = query({
args: { limit: v.optional(v.number()) }, args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => { handler: async (ctx, { limit }) => {
@@ -28,3 +47,55 @@ export const listForSpoon = query({
.take(limit ?? 25); .take(limit ?? 25);
}, },
}); });
export const createInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
kind: syncKind,
status: syncStatus,
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
summary: v.optional(v.string()),
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'syncRuns'>> => {
const now = Date.now();
return await ctx.db.insert('syncRuns', {
...args,
createdAt: now,
updatedAt: now,
});
},
});
export const patchInternal = internalMutation({
args: {
syncRunId: v.id('syncRuns'),
status: v.optional(syncStatus),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
summary: v.optional(v.string()),
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const patch: Partial<Doc<'syncRuns'>> = { updatedAt: Date.now() };
if (args.status !== undefined) patch.status = args.status;
if (args.upstreamFrom !== undefined) patch.upstreamFrom = args.upstreamFrom;
if (args.upstreamTo !== undefined) patch.upstreamTo = args.upstreamTo;
if (args.summary !== undefined) patch.summary = args.summary;
if (args.aiAssessment !== undefined) {
patch.aiAssessment = args.aiAssessment;
}
if (args.mergeRequestUrl !== undefined) {
patch.mergeRequestUrl = args.mergeRequestUrl;
}
if (args.error !== undefined) patch.error = args.error;
await ctx.db.patch(args.syncRunId, patch);
return { success: true };
},
});
+1
View File
@@ -15,6 +15,7 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"types": ["node"],
"isolatedModules": true, "isolatedModules": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
+8 -4
View File
@@ -12,10 +12,11 @@
"./types": "./types/index.ts" "./types": "./types/index.ts"
}, },
"scripts": { "scripts": {
"dev": "bun with-env convex dev", "sync-env": "sh ../../scripts/sync-convex-env ${INFISICAL_ENV:-dev}",
"dev:tunnel": "bun with-env convex dev", "dev": "bun sync-env && bun with-env convex dev",
"dev:web": "bun with-env convex dev", "dev:tunnel": "bun sync-env && bun with-env convex dev",
"setup": "bun with-env convex dev --until-success", "dev:web": "bun sync-env && bun with-env convex dev",
"setup": "bun sync-env && bun with-env convex dev --until-success",
"clean": "git clean -xdf .cache .turbo dist node_modules", "clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config", "lint": "eslint --flag unstable_native_nodejs_ts_config",
@@ -26,10 +27,13 @@
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --" "with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
}, },
"dependencies": { "dependencies": {
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@react-email/components": "1.0.10", "@react-email/components": "1.0.10",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.4",
"convex": "catalog:convex", "convex": "catalog:convex",
"openai": "^6.44.0",
"react": "catalog:react19", "react": "catalog:react19",
"react-dom": "catalog:react19", "react-dom": "catalog:react19",
"usesend-js": "^1.6.3", "usesend-js": "^1.6.3",
-20
View File
@@ -62,24 +62,4 @@ fi
info "Deploying Convex schema and functions" info "Deploying Convex schema and functions"
(cd "$ROOT_DIR/packages/backend" && bun run setup) (cd "$ROOT_DIR/packages/backend" && bun run setup)
convex_env_names="$(
sh "$ROOT_DIR/scripts/with-env" dev -- bash -c \
'cd packages/backend && bunx convex env list' 2>/dev/null \
| sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p'
)"
if ! printf '%s\n' "$convex_env_names" | grep -qx 'JWT_PRIVATE_KEY' \
|| ! printf '%s\n' "$convex_env_names" | grep -qx 'JWKS' \
|| ! printf '%s\n' "$convex_env_names" | grep -qx 'SITE_URL'; then
info "Configuring local Convex Auth keys"
auth_keys="$(node "$ROOT_DIR/scripts/generate-convex-auth-keys.mjs")"
jwt="$(printf '%s\n' "$auth_keys" | sed -n 's/^JWT_PRIVATE_KEY="\(.*\)"$/\1/p')"
jwks="$(printf '%s\n' "$auth_keys" | sed -n 's/^JWKS=//p')"
JWT_VAL="$jwt" JWKS_VAL="$jwks" sh "$ROOT_DIR/scripts/with-env" dev -- bash -c '
cd packages/backend
bunx convex env set "JWT_PRIVATE_KEY=$JWT_VAL" >/dev/null
bunx convex env set "JWKS=$JWKS_VAL" >/dev/null
bunx convex env set "SITE_URL=http://localhost:3000" >/dev/null
'
fi
printf '\nLocal stack ready:\n App: http://localhost:3000\n Convex: http://localhost:%s\n Dashboard: http://localhost:%s\n Postgres: localhost:%s\n' "${BACKEND_PORT:-3210}" "${DASHBOARD_PORT:-6791}" "${POSTGRES_PORT:-5432}" printf '\nLocal stack ready:\n App: http://localhost:3000\n Convex: http://localhost:%s\n Dashboard: http://localhost:%s\n Postgres: localhost:%s\n' "${BACKEND_PORT:-3210}" "${DASHBOARD_PORT:-6791}" "${POSTGRES_PORT:-5432}"

Some files were not shown because too many files have changed in this diff Show More